From 4f576ceb04b5c738e35e624b2cc9b43255b010f0 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 12 Mar 2026 07:22:38 +0100 Subject: [PATCH] more fixes --- .env.cpad | 8 + .gitignore | 1 + .../Commands/ExportMissingTranslations.php | 108 + .../Admin/StoryAdminController.php | 206 ++ .../Api/Admin/UsernameApprovalController.php | 4 + .../Api/ArtworkDownloadController.php | 4 +- .../Api/NotificationController.php | 2 +- app/Http/Controllers/Api/ReportController.php | 7 +- .../Controllers/Api/StoriesApiController.php | 39 +- .../Controllers/ArtworkDownloadController.php | 133 ++ .../Auth/AuthenticatedSessionController.php | 2 +- app/Http/Controllers/Auth/OAuthController.php | 1 + .../Auth/RegisteredUserController.php | 1 + .../Auth/SetupUsernameController.php | 3 +- app/Http/Controllers/DashboardController.php | 284 +++ app/Http/Controllers/News/NewsController.php | 157 ++ .../Controllers/News/NewsRssController.php | 74 + app/Http/Controllers/StoryController.php | 1350 +++++++++++++ .../Studio/StudioArtworksApiController.php | 17 +- .../Controllers/User/AvatarController.php | 8 +- .../Controllers/User/ProfileController.php | 443 ++++- .../User/ProfileCoverController.php | 252 +++ .../Controllers/Web/ExploreController.php | 148 +- app/Http/Controllers/Web/SearchController.php | 16 +- app/Http/Middleware/ConditionalCors.php | 34 + app/Http/Middleware/EnsureCreatorAccess.php | 29 + app/Http/Requests/AvatarUploadRequest.php | 1 + .../Settings/RequestEmailChangeRequest.php | 45 + .../Settings/UpdateAccountSectionRequest.php | 32 + .../UpdateNotificationsSectionRequest.php | 26 + .../Settings/UpdatePersonalSectionRequest.php | 24 + .../Settings/UpdateProfileSectionRequest.php | 29 + .../UpdateSecurityPasswordRequest.php | 24 + .../Settings/VerifyEmailChangeRequest.php | 31 + app/Mail/EmailChangeVerificationCodeMail.php | 49 + app/Mail/EmailChangedSecurityAlertMail.php | 47 + app/Models/ArtworkDownload.php | 10 + app/Models/ForumCategory.php | 2 +- app/Models/ForumPost.php | 17 +- app/Models/Notification.php | 42 + app/Models/Story.php | 72 +- app/Models/StoryLike.php | 32 + app/Models/StoryTag.php | 9 +- app/Models/StoryView.php | 33 + app/Models/User.php | 27 + app/Models/UserProfile.php | 10 + app/Notifications/StoryStatusNotification.php | 47 + app/Providers/AppServiceProvider.php | 52 + app/Providers/AuthServiceProvider.php | 4 + app/Services/AvatarService.php | 47 +- app/Services/ThumbnailService.php | 15 +- .../Uploads/UploadPipelineService.php | 19 +- app/Support/CoverUrl.php | 62 + app/Support/ForumPostContent.php | 29 +- app/Support/UsernamePolicy.php | 9 +- bootstrap/app.php | 1 + bootstrap/providers.php | 3 + composer.json | 16 +- composer.lock | 1223 +++++++++--- config/auth.php | 11 + config/controlpanel.php | 27 + config/cors.php | 34 + config/cp.php | 23 + config/cpad.php | 14 + config/file-manager.php | 166 ++ config/services.php | 2 +- config/skinbase.php | 22 + config/usernames.php | 4 +- ..._000005_create_artwork_downloads_table.php | 8 +- ...180151_add_grid_data_to_contents_table.php | 29 + ...tics_fields_to_artwork_downloads_table.php | 54 + ...120000_add_cover_fields_to_users_table.php | 41 + ...ication_columns_to_user_profiles_table.php | 53 + ...last_username_change_at_to_users_table.php | 25 + ...3_07_140100_create_email_changes_table.php | 33 + ...005_upgrade_stories_for_creator_module.php | 98 + ...000006_create_creator_story_tag_tables.php | 62 + ..._03_09_000007_expand_story_status_enum.php | 30 + ...00008_add_story_target_type_to_reports.php | 30 + ...009_extend_stories_for_editor_workflow.php | 96 + ...27cf4124c0fe22ede8edf14fc424fd2ff4db1a.png | Bin 35400 -> 0 bytes ...1ad70a1fd7e1c6d404a9c5f1224d0fe4f446194.md | 150 -- ...629870f4c7115ca6ecf3fbdab6635f488755d81.md | 150 -- ...81296d252ac646ab4653df099b79dc471ca47a5.md | 150 -- ...87ab3164a8eeb27594dbca081219fa8ca0d55a.png | Bin 35751 -> 0 bytes ...8f4702ced4ef8e94d2c536ad22d9a05dc44f15.png | Bin 35468 -> 0 bytes ...c2cff1a8fb9da7d418ac790ca616f06b4712db8.md | 150 -- ...d23f2ffb7f3fefb71682e22cecb80e7bf7e0b49.md | 150 -- ...9ff97d3ae72495c56d81d699e7a67b0a347e7c.png | Bin 35805 -> 0 bytes ...d031fa0cf668f49f668b64bab35a726f83b3f9.png | Bin 35674 -> 0 bytes ...950e4b7b5a0f6db4890ef75bfee3280ae544e8e.md | 289 +++ ...5a1466f1e38aa2c03107e0514741f25d3d7870.png | Bin 0 -> 57355 bytes ...708b647f66cff7d940c10897968fa667310a72.png | Bin 35701 -> 0 bytes ...653bde67610a4982d92bf5a58a2e6316b861972.md | 150 -- ...6bdf54470447911dbe3e96b36a871228ccc894b.md | 150 -- ...10500bb7c540caacd7620b889063afcc714aa0.png | Bin 35432 -> 0 bytes ...aa84a80309e171a4eddbcfa95084dc250a10bfe.md | 150 -- ...d1fa561992f9211af90b98e46d73358effe47a9.md | 150 -- ...cc2a8e41a071ced8947c56610bb9a8637b8d3c.png | Bin 35355 -> 0 bytes ...5c1929e6993f864fab921cd3c24eac2b532fff5.md | 212 -- ...7d3fdf07939352b96152e1fbf9ad78fb7062fbe.md | 150 -- ...5459b81d44fa48dfd70e4bfa11ea4376f9ed6e.png | Bin 494574 -> 0 bytes ...64ae997511f3a8280af0bcf12444153f581f983.md | 150 -- ...d1c3968fad504c9212f76e86ee26671a55af10.png | Bin 35616 -> 0 bytes ...6e0416ce692848c77b6825f780e202d13d0a24.png | Bin 35814 -> 0 bytes ...043da4cc1dea49cd3e37909ea2358da472c4af0.md | 150 -- ...96cb57c67ad14ae778bd5071a8e999db4392fc.png | Bin 35452 -> 0 bytes ...5649af771e1e81ba0e2383e00326f52793e8b.webm | Bin 0 -> 152481 bytes ...1c45f2bec941523fc7af542b2d7280cb1c0a9d.png | Bin 35749 -> 0 bytes playwright-report/index.html | 2 +- playwright.config.ts | 32 +- public/nova.html | 2 +- .../vendor/file-manager/css/file-manager.css | 9 + public/vendor/file-manager/js/file-manager.js | 114 ++ resources/js/Layouts/SettingsLayout.jsx | 94 +- resources/js/Pages/Forum/ForumCategory.jsx | 12 +- resources/js/Pages/Forum/ForumEditPost.jsx | 4 +- resources/js/Pages/Forum/ForumIndex.jsx | 156 +- resources/js/Pages/Forum/ForumNewThread.jsx | 8 +- resources/js/Pages/Forum/ForumSection.jsx | 68 + resources/js/Pages/Forum/ForumThread.jsx | 14 +- resources/js/Pages/Home/HomeFresh.jsx | 2 +- resources/js/Pages/Home/HomeHero.jsx | 15 +- resources/js/Pages/Profile/ProfileShow.jsx | 10 +- resources/js/Pages/Settings/ProfileEdit.jsx | 1748 +++++++++++------ resources/js/Search/SearchBar.jsx | 112 +- resources/js/Search/SearchOverlay.jsx | 190 ++ resources/js/app.js | 34 + resources/js/components/Feed/FeedSidebar.jsx | 30 +- resources/js/components/Feed/PostComposer.jsx | 4 +- .../components/artwork/ArtworkActionBar.jsx | 13 +- .../js/components/artwork/ArtworkActions.jsx | 25 +- .../js/components/editor/StoryEditor.tsx | 816 ++++++++ resources/js/components/forum/AuthorBadge.jsx | 14 +- .../js/components/forum/CategoryCard.jsx | 173 +- resources/js/components/forum/PostCard.jsx | 78 +- resources/js/components/forum/ReplyForm.jsx | 6 +- resources/js/components/forum/ThreadRow.jsx | 4 +- .../js/components/gallery/ArtworkCard.jsx | 15 +- .../js/components/gallery/MasonryGallery.jsx | 44 +- .../components/profile/ProfileCoverEditor.jsx | 232 +++ .../js/components/profile/ProfileHero.jsx | 334 ++-- .../js/components/profile/ProfileStatsRow.jsx | 18 +- .../js/components/profile/ProfileTabs.jsx | 11 +- .../components/profile/tabs/TabArtworks.jsx | 116 +- .../profile/tabs/TabCollections.jsx | 2 +- .../js/components/profile/tabs/TabPosts.jsx | 4 +- .../js/components/profile/tabs/TabStories.jsx | 49 + resources/js/dashboard/DashboardPage.jsx | 39 + .../js/dashboard/components/ActivityFeed.jsx | 95 + .../dashboard/components/CreatorAnalytics.jsx | 66 + .../js/dashboard/components/QuickActions.jsx | 64 + .../components/RecommendedCreators.jsx | 78 + .../dashboard/components/TrendingArtworks.jsx | 71 + resources/js/dashboard/index.jsx | 16 + resources/js/entry-forum.jsx | 1 + resources/js/nova.js | 107 +- resources/lang/en/admin.php | 257 +++ resources/lang/en/fp.php | 5 + .../stories/comments-moderation.blade.php | 8 + .../views/admin/stories/create.blade.php | 8 + resources/views/admin/stories/edit.blade.php | 21 + resources/views/admin/stories/index.blade.php | 0 .../admin/stories/partials/form.blade.php | 71 + .../views/admin/stories/review.blade.php | 0 resources/views/admin/stories/show.blade.php | 0 resources/views/blank.blade.php | 2 +- .../views/components/story-card.blade.php | 38 + resources/views/dashboard.blade.php | 54 +- resources/views/dashboard/favorites.blade.php | 2 +- .../email-change-verification-code.blade.php | 27 + .../email-changed-security-alert.blade.php | 26 + resources/views/gallery/index.blade.php | 2 +- .../layouts/nova/explore-layout.blade.php | 2 +- .../views/layouts/nova/toolbar.blade.php | 201 +- resources/views/news/_article_card.blade.php | 31 + resources/views/news/_sidebar.blade.php | 59 + resources/views/news/category.blade.php | 30 + resources/views/news/index.blade.php | 69 + resources/views/news/layout.blade.php | 15 + resources/views/news/show.blade.php | 113 ++ resources/views/news/tag.blade.php | 29 + resources/views/search/index.blade.php | 123 +- resources/views/web/explore/index.blade.php | 2 +- .../views/web/stories/analytics.blade.php | 54 + .../views/web/stories/category.blade.php | 34 + resources/views/web/stories/create.blade.php | 65 + resources/views/web/stories/creator.blade.php | 30 + .../views/web/stories/dashboard.blade.php | 91 + resources/views/web/stories/editor.blade.php | 61 + resources/views/web/stories/index.blade.php | 191 +- resources/views/web/stories/preview.blade.php | 51 + resources/views/web/stories/show.blade.php | 334 ++-- resources/views/web/stories/tag.blade.php | 73 +- routes/api.php | 31 +- routes/console.php | 5 + routes/legacy.php | 163 ++ routes/web.php | 512 ++--- scripts/check_fp_translations.php | 32 + scripts/fill_translations_csv.php | 103 + scripts/populate_sl_translations.php | 120 ++ .../composer.json.backup | 101 + .../providers.php.backup | 6 + .../providers.php.backup.1 | 7 + sync.sh | 1 + tests/.auth/admin.json | 39 + tests/Feature/ArtworkDownloadTest.php | 133 ++ tests/Feature/AvatarUploadTest.php | 2 + .../AdminStoryModerationWorkflowTest.php | 76 + .../Stories/CreatorStoryWorkflowTest.php | 96 + tests/artifacts/.last-run.json | 6 + .../error-context.md | 289 +++ .../test-failed-1.png | Bin 0 -> 57355 bytes .../video.webm | Bin 0 -> 152481 bytes tests/cpad/auth.setup.ts | 28 + tests/cpad/auth.spec.ts | 149 ++ tests/cpad/dashboard.spec.ts | 122 ++ tests/cpad/modules/configuration.spec.ts | 123 ++ tests/cpad/modules/languages.spec.ts | 94 + tests/cpad/modules/translations.spec.ts | 112 ++ tests/cpad/navigation-discovery.spec.ts | 95 + tests/cpad/navigation.spec.ts | 107 + tests/helpers/auth.ts | 93 + tests/helpers/crudHelper.ts | 147 ++ tests/helpers/formFiller.ts | 127 ++ vite.config.js | 1 + 226 files changed, 14380 insertions(+), 4453 deletions(-) create mode 100644 .env.cpad create mode 100644 app/Console/Commands/ExportMissingTranslations.php create mode 100644 app/Http/Controllers/Admin/StoryAdminController.php create mode 100644 app/Http/Controllers/ArtworkDownloadController.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Controllers/News/NewsController.php create mode 100644 app/Http/Controllers/News/NewsRssController.php create mode 100644 app/Http/Controllers/StoryController.php create mode 100644 app/Http/Controllers/User/ProfileCoverController.php create mode 100644 app/Http/Middleware/ConditionalCors.php create mode 100644 app/Http/Middleware/EnsureCreatorAccess.php create mode 100644 app/Http/Requests/Settings/RequestEmailChangeRequest.php create mode 100644 app/Http/Requests/Settings/UpdateAccountSectionRequest.php create mode 100644 app/Http/Requests/Settings/UpdateNotificationsSectionRequest.php create mode 100644 app/Http/Requests/Settings/UpdatePersonalSectionRequest.php create mode 100644 app/Http/Requests/Settings/UpdateProfileSectionRequest.php create mode 100644 app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php create mode 100644 app/Http/Requests/Settings/VerifyEmailChangeRequest.php create mode 100644 app/Mail/EmailChangeVerificationCodeMail.php create mode 100644 app/Mail/EmailChangedSecurityAlertMail.php create mode 100644 app/Models/Notification.php create mode 100644 app/Models/StoryLike.php create mode 100644 app/Models/StoryView.php create mode 100644 app/Notifications/StoryStatusNotification.php create mode 100644 app/Support/CoverUrl.php create mode 100644 config/controlpanel.php create mode 100644 config/cors.php create mode 100644 config/cp.php create mode 100644 config/cpad.php create mode 100644 config/file-manager.php create mode 100644 config/skinbase.php create mode 100644 database/migrations/2026_03_06_180151_add_grid_data_to_contents_table.php create mode 100644 database/migrations/2026_03_07_000001_add_analytics_fields_to_artwork_downloads_table.php create mode 100644 database/migrations/2026_03_07_120000_add_cover_fields_to_users_table.php create mode 100644 database/migrations/2026_03_07_130000_add_settings_notification_columns_to_user_profiles_table.php create mode 100644 database/migrations/2026_03_07_140000_add_last_username_change_at_to_users_table.php create mode 100644 database/migrations/2026_03_07_140100_create_email_changes_table.php create mode 100644 database/migrations/2026_03_09_000005_upgrade_stories_for_creator_module.php create mode 100644 database/migrations/2026_03_09_000006_create_creator_story_tag_tables.php create mode 100644 database/migrations/2026_03_09_000007_expand_story_status_enum.php create mode 100644 database/migrations/2026_03_09_000008_add_story_target_type_to_reports.php create mode 100644 database/migrations/2026_03_09_000009_extend_stories_for_editor_workflow.php delete mode 100644 playwright-report/data/0c27cf4124c0fe22ede8edf14fc424fd2ff4db1a.png delete mode 100644 playwright-report/data/21ad70a1fd7e1c6d404a9c5f1224d0fe4f446194.md delete mode 100644 playwright-report/data/2629870f4c7115ca6ecf3fbdab6635f488755d81.md delete mode 100644 playwright-report/data/281296d252ac646ab4653df099b79dc471ca47a5.md delete mode 100644 playwright-report/data/2e87ab3164a8eeb27594dbca081219fa8ca0d55a.png delete mode 100644 playwright-report/data/488f4702ced4ef8e94d2c536ad22d9a05dc44f15.png delete mode 100644 playwright-report/data/4c2cff1a8fb9da7d418ac790ca616f06b4712db8.md delete mode 100644 playwright-report/data/4d23f2ffb7f3fefb71682e22cecb80e7bf7e0b49.md delete mode 100644 playwright-report/data/5b9ff97d3ae72495c56d81d699e7a67b0a347e7c.png delete mode 100644 playwright-report/data/5ed031fa0cf668f49f668b64bab35a726f83b3f9.png create mode 100644 playwright-report/data/7950e4b7b5a0f6db4890ef75bfee3280ae544e8e.md create mode 100644 playwright-report/data/9d5a1466f1e38aa2c03107e0514741f25d3d7870.png delete mode 100644 playwright-report/data/9f708b647f66cff7d940c10897968fa667310a72.png delete mode 100644 playwright-report/data/a653bde67610a4982d92bf5a58a2e6316b861972.md delete mode 100644 playwright-report/data/a6bdf54470447911dbe3e96b36a871228ccc894b.md delete mode 100644 playwright-report/data/a810500bb7c540caacd7620b889063afcc714aa0.png delete mode 100644 playwright-report/data/aaa84a80309e171a4eddbcfa95084dc250a10bfe.md delete mode 100644 playwright-report/data/bd1fa561992f9211af90b98e46d73358effe47a9.md delete mode 100644 playwright-report/data/c2cc2a8e41a071ced8947c56610bb9a8637b8d3c.png delete mode 100644 playwright-report/data/c5c1929e6993f864fab921cd3c24eac2b532fff5.md delete mode 100644 playwright-report/data/c7d3fdf07939352b96152e1fbf9ad78fb7062fbe.md delete mode 100644 playwright-report/data/cd5459b81d44fa48dfd70e4bfa11ea4376f9ed6e.png delete mode 100644 playwright-report/data/d64ae997511f3a8280af0bcf12444153f581f983.md delete mode 100644 playwright-report/data/d9d1c3968fad504c9212f76e86ee26671a55af10.png delete mode 100644 playwright-report/data/df6e0416ce692848c77b6825f780e202d13d0a24.png delete mode 100644 playwright-report/data/f043da4cc1dea49cd3e37909ea2358da472c4af0.md delete mode 100644 playwright-report/data/f296cb57c67ad14ae778bd5071a8e999db4392fc.png create mode 100644 playwright-report/data/f305649af771e1e81ba0e2383e00326f52793e8b.webm delete mode 100644 playwright-report/data/fe1c45f2bec941523fc7af542b2d7280cb1c0a9d.png create mode 100644 public/vendor/file-manager/css/file-manager.css create mode 100644 public/vendor/file-manager/js/file-manager.js create mode 100644 resources/js/Pages/Forum/ForumSection.jsx create mode 100644 resources/js/Search/SearchOverlay.jsx create mode 100644 resources/js/components/editor/StoryEditor.tsx create mode 100644 resources/js/components/profile/ProfileCoverEditor.jsx create mode 100644 resources/js/components/profile/tabs/TabStories.jsx create mode 100644 resources/js/dashboard/DashboardPage.jsx create mode 100644 resources/js/dashboard/components/ActivityFeed.jsx create mode 100644 resources/js/dashboard/components/CreatorAnalytics.jsx create mode 100644 resources/js/dashboard/components/QuickActions.jsx create mode 100644 resources/js/dashboard/components/RecommendedCreators.jsx create mode 100644 resources/js/dashboard/components/TrendingArtworks.jsx create mode 100644 resources/js/dashboard/index.jsx create mode 100644 resources/lang/en/admin.php create mode 100644 resources/lang/en/fp.php create mode 100644 resources/views/admin/stories/comments-moderation.blade.php create mode 100644 resources/views/admin/stories/create.blade.php create mode 100644 resources/views/admin/stories/edit.blade.php create mode 100644 resources/views/admin/stories/index.blade.php create mode 100644 resources/views/admin/stories/partials/form.blade.php create mode 100644 resources/views/admin/stories/review.blade.php create mode 100644 resources/views/admin/stories/show.blade.php create mode 100644 resources/views/components/story-card.blade.php create mode 100644 resources/views/emails/email-change-verification-code.blade.php create mode 100644 resources/views/emails/email-changed-security-alert.blade.php create mode 100644 resources/views/news/_article_card.blade.php create mode 100644 resources/views/news/_sidebar.blade.php create mode 100644 resources/views/news/category.blade.php create mode 100644 resources/views/news/index.blade.php create mode 100644 resources/views/news/layout.blade.php create mode 100644 resources/views/news/show.blade.php create mode 100644 resources/views/news/tag.blade.php create mode 100644 resources/views/web/stories/analytics.blade.php create mode 100644 resources/views/web/stories/category.blade.php create mode 100644 resources/views/web/stories/create.blade.php create mode 100644 resources/views/web/stories/creator.blade.php create mode 100644 resources/views/web/stories/dashboard.blade.php create mode 100644 resources/views/web/stories/editor.blade.php create mode 100644 resources/views/web/stories/preview.blade.php create mode 100644 routes/legacy.php create mode 100644 scripts/check_fp_translations.php create mode 100644 scripts/fill_translations_csv.php create mode 100644 scripts/populate_sl_translations.php create mode 100644 storage/backups/cpad_2026-03-05_10-26-43/composer.json.backup create mode 100644 storage/backups/cpad_2026-03-05_10-26-43/providers.php.backup create mode 100644 storage/backups/cpad_2026-03-05_10-26-43/providers.php.backup.1 create mode 100644 tests/.auth/admin.json create mode 100644 tests/Feature/ArtworkDownloadTest.php create mode 100644 tests/Feature/Stories/AdminStoryModerationWorkflowTest.php create mode 100644 tests/Feature/Stories/CreatorStoryWorkflowTest.php create mode 100644 tests/artifacts/.last-run.json create mode 100644 tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/error-context.md create mode 100644 tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/test-failed-1.png create mode 100644 tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/video.webm create mode 100644 tests/cpad/auth.setup.ts create mode 100644 tests/cpad/auth.spec.ts create mode 100644 tests/cpad/dashboard.spec.ts create mode 100644 tests/cpad/modules/configuration.spec.ts create mode 100644 tests/cpad/modules/languages.spec.ts create mode 100644 tests/cpad/modules/translations.spec.ts create mode 100644 tests/cpad/navigation-discovery.spec.ts create mode 100644 tests/cpad/navigation.spec.ts create mode 100644 tests/helpers/auth.ts create mode 100644 tests/helpers/crudHelper.ts create mode 100644 tests/helpers/formFiller.ts diff --git a/.env.cpad b/.env.cpad new file mode 100644 index 00000000..393fc6d6 --- /dev/null +++ b/.env.cpad @@ -0,0 +1,8 @@ +# cPad Configuration +# Template: custom + +CPAD_DEBUG=false +CPAD_CACHE_ENABLED=true +CPAD_LOG_LEVEL=WARNING +CPAD_SECURITY_LEVEL=MAXIMUM +CPAD_BACKUP_ENABLED=true diff --git a/.gitignore b/.gitignore index 4403682f..da577d53 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ Thumbs.db oldSite packages /packages/* +/public/admin/* \ No newline at end of file diff --git a/app/Console/Commands/ExportMissingTranslations.php b/app/Console/Commands/ExportMissingTranslations.php new file mode 100644 index 00000000..4d0c570d --- /dev/null +++ b/app/Console/Commands/ExportMissingTranslations.php @@ -0,0 +1,108 @@ +argument('file') ?? 'admin'; + $this->info('Exporting missing translations for: ' . $type); + + // Gather files to scan + $files = []; + $files = array_merge( + FileManager::getFileList(app_path(), true), + FileManager::getFileList(base_path('packages'), true), + FileManager::getFileList(resource_path(), true) + ); + + $tempTranslations = []; + + foreach ($files as $file) { + $res = TranslationUtil::findTranslations($file, $type); + if (!empty($res) && is_array($res)) { + $tempTranslations[] = $res; + } + } + + $tempTranslations = collect($tempTranslations)->collapse(); + + $missing = []; + foreach ($tempTranslations as $keycode => $row) { + $exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists(); + if (! $exists) { + $missing[] = $keycode; + } + } + + $this->info('Found ' . count($missing) . ' missing keys'); + + // Fetch suggested translations from external service for sl and en + $suggestions = []; + if (!empty($missing)) { + $payload = [ + 'keys' => $missing, + 'languages' => ['sl', 'en'], + ]; + + try { + $resp = Http::withToken($this->token)->post($this->translationURL, $payload); + if ($resp->successful()) { + $suggestions = $resp->json(); + } else { + $this->warn('Translation suggestion service returned ' . $resp->status()); + } + } catch (\Throwable $e) { + $this->warn('Failed to call suggestion service: ' . $e->getMessage()); + } + } + + // Build CSV + $out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv'); + $fh = fopen($out, 'w'); + if (! $fh) { + $this->error('Failed to open output file: ' . $out); + return 1; + } + + // Header + fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']); + + foreach ($missing as $key) { + $s_sl = $suggestions[$key]['sl'] ?? ''; + $s_en = $suggestions[$key]['en'] ?? ''; + $placeholder = $type . '.' . $key; + fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]); + } + + fclose($fh); + + $this->info('CSV exported to: ' . $out); + + return 0; + } +} diff --git a/app/Http/Controllers/Admin/StoryAdminController.php b/app/Http/Controllers/Admin/StoryAdminController.php new file mode 100644 index 00000000..29a1812d --- /dev/null +++ b/app/Http/Controllers/Admin/StoryAdminController.php @@ -0,0 +1,206 @@ +with(['creator']) + ->latest('created_at') + ->paginate(25); + + return view('admin.stories.index', ['stories' => $stories]); + } + + public function review(): View + { + $stories = Story::query() + ->with(['creator']) + ->where('status', 'pending_review') + ->orderByDesc('submitted_for_review_at') + ->paginate(25); + + return view('admin.stories.review', ['stories' => $stories]); + } + + public function create(): View + { + return view('admin.stories.create', [ + 'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']), + 'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'creator_id' => ['required', 'integer', 'exists:users,id'], + 'title' => ['required', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'content' => ['required', 'string'], + 'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'], + 'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['integer', 'exists:story_tags,id'], + ]); + + $story = Story::query()->create([ + 'creator_id' => (int) $validated['creator_id'], + 'title' => $validated['title'], + 'slug' => $this->uniqueSlug($validated['title']), + 'excerpt' => $validated['excerpt'] ?? null, + 'cover_image' => $validated['cover_image'] ?? null, + 'content' => $validated['content'], + 'story_type' => $validated['story_type'], + 'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)), + 'status' => $validated['status'], + 'published_at' => $validated['status'] === 'published' ? now() : null, + 'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null, + ]); + + if (! empty($validated['tags'])) { + $story->tags()->sync($validated['tags']); + } + + return redirect()->route('admin.stories.edit', ['story' => $story->id]) + ->with('status', 'Story created.'); + } + + public function edit(Story $story): View + { + $story->load('tags'); + + return view('admin.stories.edit', [ + 'story' => $story, + 'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']), + 'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, Story $story): RedirectResponse + { + $validated = $request->validate([ + 'creator_id' => ['required', 'integer', 'exists:users,id'], + 'title' => ['required', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'content' => ['required', 'string'], + 'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'], + 'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['integer', 'exists:story_tags,id'], + ]); + + $story->update([ + 'creator_id' => (int) $validated['creator_id'], + 'title' => $validated['title'], + 'excerpt' => $validated['excerpt'] ?? null, + 'cover_image' => $validated['cover_image'] ?? null, + 'content' => $validated['content'], + 'story_type' => $validated['story_type'], + 'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)), + 'status' => $validated['status'], + 'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at, + 'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, + ]); + + $story->tags()->sync($validated['tags'] ?? []); + + return back()->with('status', 'Story updated.'); + } + + public function destroy(Story $story): RedirectResponse + { + $story->delete(); + + return redirect()->route('admin.stories.index')->with('status', 'Story deleted.'); + } + + public function publish(Story $story): RedirectResponse + { + $story->update([ + 'status' => 'published', + 'published_at' => $story->published_at ?? now(), + 'reviewed_at' => now(), + ]); + + $story->creator?->notify(new StoryStatusNotification($story, 'published')); + + return back()->with('status', 'Story published.'); + } + + public function show(Story $story): View + { + return view('admin.stories.show', [ + 'story' => $story->load(['creator', 'tags']), + ]); + } + + public function approve(Request $request, Story $story): RedirectResponse + { + $story->update([ + 'status' => 'published', + 'published_at' => $story->published_at ?? now(), + 'reviewed_at' => now(), + 'reviewed_by_id' => (int) $request->user()->id, + 'rejected_reason' => null, + ]); + + $story->creator?->notify(new StoryStatusNotification($story, 'approved')); + + return back()->with('status', 'Story approved and published.'); + } + + public function reject(Request $request, Story $story): RedirectResponse + { + $validated = $request->validate([ + 'reason' => ['required', 'string', 'max:1000'], + ]); + + $story->update([ + 'status' => 'rejected', + 'reviewed_at' => now(), + 'reviewed_by_id' => (int) $request->user()->id, + 'rejected_reason' => $validated['reason'], + ]); + + $story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason'])); + + return back()->with('status', 'Story rejected and creator notified.'); + } + + public function moderateComments(): View + { + return view('admin.stories.comments-moderation'); + } + + private function uniqueSlug(string $title): string + { + $base = Str::slug($title); + $slug = $base; + $n = 2; + + while (Story::query()->where('slug', $slug)->exists()) { + $slug = $base . '-' . $n; + $n++; + } + + return $slug; + } +} diff --git a/app/Http/Controllers/Api/Admin/UsernameApprovalController.php b/app/Http/Controllers/Api/Admin/UsernameApprovalController.php index aa01168f..26f1ff1e 100644 --- a/app/Http/Controllers/Api/Admin/UsernameApprovalController.php +++ b/app/Http/Controllers/Api/Admin/UsernameApprovalController.php @@ -10,6 +10,7 @@ use App\Support\UsernamePolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Symfony\Component\HttpFoundation\Response; final class UsernameApprovalController extends Controller @@ -124,6 +125,9 @@ final class UsernameApprovalController extends Controller $user->username = $requested; $user->username_changed_at = now(); + if (Schema::hasColumn('users', 'last_username_change_at')) { + $user->last_username_change_at = now(); + } $user->save(); if ($old !== '') { diff --git a/app/Http/Controllers/Api/ArtworkDownloadController.php b/app/Http/Controllers/Api/ArtworkDownloadController.php index 08d247cc..c7eb1186 100644 --- a/app/Http/Controllers/Api/ArtworkDownloadController.php +++ b/app/Http/Controllers/Api/ArtworkDownloadController.php @@ -86,7 +86,9 @@ final class ArtworkDownloadController extends Controller 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'ip' => $bin !== false ? $bin : null, - 'user_agent' => mb_substr((string) $request->userAgent(), 0, 512), + 'ip_address' => mb_substr((string) $ip, 0, 45), + 'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024), + 'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535), 'created_at' => now(), ]); } catch (\Throwable) { diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 2c9f5fab..07ab2091 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -48,7 +48,7 @@ class NotificationController extends Controller public function readAll(Request $request): JsonResponse { - $request->user()->unreadNotifications->markAsRead(); + $request->user()->unreadNotifications()->update(['read_at' => now()]); return response()->json(['message' => 'All notifications marked as read.']); } diff --git a/app/Http/Controllers/Api/ReportController.php b/app/Http/Controllers/Api/ReportController.php index 0d4a711d..51e16f06 100644 --- a/app/Http/Controllers/Api/ReportController.php +++ b/app/Http/Controllers/Api/ReportController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\ConversationParticipant; use App\Models\Message; use App\Models\Report; +use App\Models\Story; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,7 +18,7 @@ class ReportController extends Controller $user = $request->user(); $data = $request->validate([ - 'target_type' => 'required|in:message,conversation,user', + 'target_type' => 'required|in:message,conversation,user,story', 'target_id' => 'required|integer|min:1', 'reason' => 'required|string|max:120', 'details' => 'nullable|string|max:4000', @@ -49,6 +50,10 @@ class ReportController extends Controller User::query()->findOrFail($targetId); } + if ($targetType === 'story') { + Story::query()->findOrFail($targetId); + } + $report = Report::query()->create([ 'reporter_id' => $user->id, 'target_type' => $targetType, diff --git a/app/Http/Controllers/Api/StoriesApiController.php b/app/Http/Controllers/Api/StoriesApiController.php index 49b3710b..b513de35 100644 --- a/app/Http/Controllers/Api/StoriesApiController.php +++ b/app/Http/Controllers/Api/StoriesApiController.php @@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Story; use App\Models\StoryTag; -use App\Models\StoryAuthor; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -36,7 +36,7 @@ final class StoriesApiController extends Controller $stories = Cache::remember($cacheKey, 300, fn () => Story::published() - ->with('author', 'tags') + ->with('creator.profile', 'tags') ->orderByDesc('published_at') ->paginate($perPage, ['*'], 'page', $page) ); @@ -60,7 +60,7 @@ final class StoriesApiController extends Controller { $story = Cache::remember('stories:api:' . $slug, 600, fn () => Story::published() - ->with('author', 'tags') + ->with('creator.profile', 'tags') ->where('slug', $slug) ->firstOrFail() ); @@ -76,7 +76,7 @@ final class StoriesApiController extends Controller { $story = Cache::remember('stories:api:featured', 300, fn () => Story::published()->featured() - ->with('author', 'tags') + ->with('creator.profile', 'tags') ->orderByDesc('published_at') ->first() ); @@ -99,8 +99,8 @@ final class StoriesApiController extends Controller $stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () => Story::published() - ->with('author', 'tags') - ->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id)) + ->with('creator.profile', 'tags') + ->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id)) ->orderByDesc('published_at') ->paginate(12, ['*'], 'page', $page) ); @@ -123,21 +123,20 @@ final class StoriesApiController extends Controller */ public function byAuthor(Request $request, string $username): JsonResponse { - $author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first() - ?? StoryAuthor::where('name', $username)->firstOrFail(); + $author = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail(); $page = (int) $request->get('page', 1); $stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () => Story::published() - ->with('author', 'tags') - ->where('author_id', $author->id) + ->with('creator.profile', 'tags') + ->where('creator_id', $author->id) ->orderByDesc('published_at') ->paginate(12, ['*'], 'page', $page) ); return response()->json([ - 'author' => $this->formatAuthor($author), + 'author' => $this->formatCreator($author), 'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)), 'meta' => [ 'current_page' => $stories->currentPage(), @@ -159,7 +158,7 @@ final class StoriesApiController extends Controller 'title' => $story->title, 'excerpt' => $story->excerpt, 'cover_image' => $story->cover_url, - 'author' => $story->author ? $this->formatAuthor($story->author) : null, + 'author' => $story->creator ? $this->formatCreator($story->creator) : null, 'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]), 'views' => $story->views, 'featured' => $story->featured, @@ -175,14 +174,18 @@ final class StoriesApiController extends Controller ]); } - private function formatAuthor(StoryAuthor $author): array + private function formatCreator(User $creator): array { + $avatarHash = $creator->profile?->avatar_hash; + return [ - 'id' => $author->id, - 'name' => $author->name, - 'avatar_url' => $author->avatar_url, - 'bio' => $author->bio, - 'profile_url' => $author->profile_url, + 'id' => $creator->id, + 'name' => $creator->username ?? $creator->name, + 'avatar_url' => $avatarHash + ? \App\Support\AvatarUrl::forUser((int) $creator->id, $avatarHash, 96) + : \App\Support\AvatarUrl::default(), + 'bio' => $creator->profile?->about, + 'profile_url' => '/@' . strtolower((string) ($creator->username ?? $creator->id)), ]; } } diff --git a/app/Http/Controllers/ArtworkDownloadController.php b/app/Http/Controllers/ArtworkDownloadController.php new file mode 100644 index 00000000..4eb370d9 --- /dev/null +++ b/app/Http/Controllers/ArtworkDownloadController.php @@ -0,0 +1,133 @@ + + */ + private const ALLOWED_EXTENSIONS = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'bmp', + 'tiff', + ]; + + public function __invoke(Request $request, int $id): BinaryFileResponse + { + $artwork = Artwork::query()->find($id); + if (! $artwork) { + abort(404); + } + + $hash = strtolower((string) $artwork->hash); + $ext = strtolower(ltrim((string) $artwork->file_ext, '.')); + + if (! $this->isValidHash($hash) || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + abort(404); + } + + $filePath = $this->resolveOriginalPath($hash, $ext); + if (! File::isFile($filePath)) { + Log::warning('Artwork original file missing for download.', [ + 'artwork_id' => $artwork->id, + 'hash' => $hash, + 'ext' => $ext, + 'resolved_path' => $filePath, + ]); + + abort(404); + } + + $this->recordDownload($request, $artwork->id); + $this->incrementDownloadCountIfAvailable($artwork->id); + + $downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext); + + return response()->download($filePath, $downloadName); + } + + private function resolveOriginalPath(string $hash, string $ext): string + { + $firstDir = substr($hash, 0, 2); + $secondDir = substr($hash, 2, 2); + $root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR); + + return $root + . DIRECTORY_SEPARATOR . 'original' + . DIRECTORY_SEPARATOR . $firstDir + . DIRECTORY_SEPARATOR . $secondDir + . DIRECTORY_SEPARATOR . $hash . '.' . $ext; + } + + private function recordDownload(Request $request, int $artworkId): void + { + try { + $ipAddress = $request->ip(); + $ipBinary = $ipAddress ? @inet_pton($ipAddress) : false; + + ArtworkDownload::query()->create([ + 'artwork_id' => $artworkId, + 'user_id' => $request->user()?->id, + 'ip' => $ipBinary !== false ? $ipBinary : null, + 'ip_address' => $ipAddress, + 'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024), + 'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535), + ]); + } catch (\Throwable $exception) { + Log::warning('Failed to record artwork download analytics.', [ + 'artwork_id' => $artworkId, + 'error' => $exception->getMessage(), + ]); + } + } + + private function incrementDownloadCountIfAvailable(int $artworkId): void + { + if (! Schema::hasColumn('artworks', 'download_count')) { + return; + } + + Artwork::query()->whereKey($artworkId)->increment('download_count'); + } + + private function isValidHash(string $hash): bool + { + return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1; + } + + private function buildDownloadFilename(string $fileName, string $ext): string + { + $name = trim($fileName); + $name = str_replace(['/', '\\'], '-', $name); + $name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? ''; + $name = preg_replace('/\s+/', ' ', $name) ?? ''; + $name = trim((string) $name, ". \t\n\r\0\x0B"); + + if ($name === '') { + $name = 'artwork'; + } + + if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) { + $name .= '.' . $ext; + } + + return $name; + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 613bcd9d..45ffdad6 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -28,7 +28,7 @@ class AuthenticatedSessionController extends Controller $request->session()->regenerate(); - return redirect()->intended(route('dashboard', absolute: false)); + return redirect()->intended('/'); } /** diff --git a/app/Http/Controllers/Auth/OAuthController.php b/app/Http/Controllers/Auth/OAuthController.php index 08c6da52..3db8831f 100644 --- a/app/Http/Controllers/Auth/OAuthController.php +++ b/app/Http/Controllers/Auth/OAuthController.php @@ -169,6 +169,7 @@ class OAuthController extends Controller 'is_active' => true, 'onboarding_step' => 'username', 'username_changed_at' => now(), + 'last_username_change_at' => now(), ]); $this->createSocialAccount( diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index bc169bfa..76c9ea65 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -105,6 +105,7 @@ class RegisteredUserController extends Controller 'is_active' => false, 'onboarding_step' => 'email', 'username_changed_at' => now(), + 'last_username_change_at' => now(), ]); } diff --git a/app/Http/Controllers/Auth/SetupUsernameController.php b/app/Http/Controllers/Auth/SetupUsernameController.php index debdba60..a39501f1 100644 --- a/app/Http/Controllers/Auth/SetupUsernameController.php +++ b/app/Http/Controllers/Auth/SetupUsernameController.php @@ -35,7 +35,7 @@ class SetupUsernameController extends Controller ], [ 'username.required' => 'Please choose a username to continue.', 'username.unique' => 'This username is already taken.', - 'username.regex' => 'Use only letters, numbers, underscores, or hyphens.', + 'username.regex' => 'Use only letters, numbers, and underscores.', 'username.min' => 'Username must be at least 3 characters.', 'username.max' => 'Username must be at most 20 characters.', ]); @@ -86,6 +86,7 @@ class SetupUsernameController extends Controller 'username' => strtolower($candidate), 'onboarding_step' => 'complete', 'username_changed_at' => now(), + 'last_username_change_at' => now(), ])->save(); }); diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 00000000..9e284877 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,284 @@ +user(); + + return view('dashboard', [ + 'page_title' => 'Dashboard', + 'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator', + 'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(), + ]); + } + + public function activity(Request $request): JsonResponse + { + $user = $request->user(); + + $notificationItems = $user->notifications() + ->latest() + ->limit(12) + ->get() + ->map(function ($notification): array { + return [ + 'id' => (string) $notification->id, + 'type' => 'notification', + 'message' => $this->notificationMessage((array) $notification->data), + 'reference_id' => (string) ($notification->id ?? ''), + 'created_at' => $notification->created_at?->toIso8601String(), + 'is_unread' => $notification->read_at === null, + 'actor' => null, + ]; + }); + + $followItems = DB::table('user_followers as uf') + ->join('users as follower', 'follower.id', '=', 'uf.follower_id') + ->leftJoin('user_profiles as fp', 'fp.user_id', '=', 'follower.id') + ->where('uf.user_id', $user->id) + ->select([ + 'uf.follower_id as actor_id', + 'follower.username as actor_username', + 'follower.name as actor_name', + 'fp.avatar_hash as actor_avatar_hash', + 'uf.created_at', + ]) + ->orderByDesc('uf.created_at') + ->limit(10) + ->get() + ->map(function ($row): array { + return [ + 'id' => 'follow-' . (string) $row->actor_id . '-' . Carbon::parse((string) $row->created_at)->timestamp, + 'type' => 'new_follower', + 'message' => 'started following you', + 'reference_id' => (string) $row->actor_id, + 'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(), + 'is_unread' => false, + 'actor' => [ + 'id' => (int) $row->actor_id, + 'name' => $row->actor_name, + 'username' => $row->actor_username, + 'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64), + ], + ]; + }); + + $commentItems = DB::table('artwork_comments as c') + ->join('artworks as a', 'a.id', '=', 'c.artwork_id') + ->join('users as commenter', 'commenter.id', '=', 'c.user_id') + ->leftJoin('user_profiles as cp', 'cp.user_id', '=', 'commenter.id') + ->where('a.user_id', $user->id) + ->where('c.user_id', '!=', $user->id) + ->where('c.is_approved', true) + ->whereNull('c.deleted_at') + ->select([ + 'c.id as comment_id', + 'c.created_at', + 'a.id as artwork_id', + 'a.slug as artwork_slug', + 'a.title as artwork_title', + 'commenter.id as actor_id', + 'commenter.username as actor_username', + 'commenter.name as actor_name', + 'cp.avatar_hash as actor_avatar_hash', + ]) + ->orderByDesc('c.created_at') + ->limit(10) + ->get() + ->map(function ($row): array { + return [ + 'id' => 'comment-' . (string) $row->comment_id, + 'type' => 'comment', + 'message' => 'commented on your artwork', + 'reference_id' => (string) $row->artwork_id, + 'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(), + 'is_unread' => false, + 'actor' => [ + 'id' => (int) $row->actor_id, + 'name' => $row->actor_name, + 'username' => $row->actor_username, + 'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64), + ], + 'context' => [ + 'artwork_id' => (int) $row->artwork_id, + 'artwork_title' => $row->artwork_title, + 'artwork_url' => '/art/' . $row->artwork_id . '/' . $row->artwork_slug, + ], + ]; + }); + + $items = collect() + ->concat($notificationItems) + ->concat($followItems) + ->concat($commentItems) + ->sortByDesc(fn (array $item) => (string) ($item['created_at'] ?? '')) + ->take(20) + ->values(); + + return response()->json([ + 'data' => $items, + ]); + } + + public function analytics(Request $request): JsonResponse + { + $user = $request->user(); + + $artworksCount = Artwork::query()->where('user_id', $user->id)->count(); + + $storyAggregate = Story::query() + ->where('creator_id', $user->id) + ->selectRaw('COUNT(*) as total_stories, COALESCE(SUM(views),0) as total_story_views, COALESCE(SUM(likes_count),0) as total_story_likes') + ->first(); + + $stats = $user->statistics; + + $followersCount = (int) ($stats?->followers_count ?? $user->followers()->count()); + $artworkLikes = (int) ($stats?->favorites_received_count ?? 0); + $storyLikes = (int) ($storyAggregate?->total_story_likes ?? 0); + + return response()->json([ + 'data' => [ + 'is_creator' => $artworksCount > 0, + 'total_artworks' => $artworksCount, + 'total_stories' => (int) ($storyAggregate?->total_stories ?? 0), + 'total_story_views' => (int) ($storyAggregate?->total_story_views ?? 0), + 'total_followers' => $followersCount, + 'total_likes' => $artworkLikes + $storyLikes, + ], + ]); + } + + public function trendingArtworks(): JsonResponse + { + $cacheKey = 'dashboard:trending-artworks:v1'; + + $data = Cache::remember($cacheKey, 300, function (): array { + return Artwork::query() + ->select('artworks.*') + ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id') + ->public() + ->with(['user.profile', 'stats']) + ->orderByRaw('COALESCE(s.ranking_score, 0) DESC') + ->orderByRaw('COALESCE(s.heat_score, 0) DESC') + ->orderByRaw('COALESCE(s.favorites, 0) DESC') + ->orderByRaw('COALESCE(s.views, 0) DESC') + ->orderByRaw('COALESCE(s.comments_count, 0) DESC') + ->limit(8) + ->get() + ->map(function (Artwork $artwork): array { + return [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'thumbnail' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url, + 'likes' => (int) ($artwork->stats?->favorites ?? 0), + 'views' => (int) ($artwork->stats?->views ?? 0), + 'comments' => (int) ($artwork->stats?->comments_count ?? 0), + 'creator' => [ + 'id' => (int) $artwork->user_id, + 'username' => $artwork->user?->username, + 'name' => $artwork->user?->name, + 'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null, + ], + ]; + }) + ->values() + ->all(); + }); + + return response()->json(['data' => $data]); + } + + public function recommendedCreators(Request $request): JsonResponse + { + $user = $request->user(); + + $cacheKey = 'dashboard:recommended-creators:' . $user->id . ':v1'; + + $data = Cache::remember($cacheKey, 600, function () use ($user): array { + $followingIds = DB::table('user_followers') + ->where('follower_id', $user->id) + ->pluck('user_id') + ->map(fn ($id) => (int) $id) + ->all(); + + $excludeIds = array_values(array_unique(array_merge([$user->id], $followingIds))); + + return User::query() + ->from('users') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'users.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id') + ->where('users.is_active', true) + ->whereNotIn('users.id', $excludeIds) + ->whereExists(function ($q): void { + $q->select(DB::raw(1)) + ->from('artworks') + ->whereColumn('artworks.user_id', 'users.id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at'); + }) + ->select([ + 'users.id', + 'users.username', + 'users.name', + 'up.avatar_hash', + DB::raw('COALESCE(us.followers_count, 0) as followers_count'), + DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'), + ]) + ->orderByDesc('followers_count') + ->orderByDesc('uploads_count') + ->limit(6) + ->get() + ->map(function ($row): array { + $username = (string) ($row->username ?? ''); + + return [ + 'id' => (int) $row->id, + 'username' => $username, + 'name' => $row->name, + 'url' => $username !== '' ? '/@' . $username : null, + 'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), + 'followers_count' => (int) $row->followers_count, + 'uploads_count' => (int) $row->uploads_count, + ]; + }) + ->values() + ->all(); + }); + + return response()->json(['data' => $data]); + } + + private function notificationMessage(array $payload): string + { + $title = trim((string) ($payload['title'] ?? '')); + if ($title !== '') { + return $title; + } + + $message = trim((string) ($payload['message'] ?? '')); + if ($message !== '') { + return $message; + } + + $type = trim((string) ($payload['type'] ?? 'Notification')); + return $type !== '' ? $type : 'New notification'; + } +} diff --git a/app/Http/Controllers/News/NewsController.php b/app/Http/Controllers/News/NewsController.php new file mode 100644 index 00000000..d7d145b1 --- /dev/null +++ b/app/Http/Controllers/News/NewsController.php @@ -0,0 +1,157 @@ +published() + ->featured() + ->orderByDesc('published_at') + ->first(); + + $query = NewsArticle::with('author', 'category') + ->published() + ->orderByDesc('published_at'); + + if ($featured) { + $query->where('id', '!=', $featured->id); + } + + $articles = $query->paginate($perPage); + $categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get(); + $trending = NewsArticle::published() + ->orderByDesc('views') + ->limit(config('news.trending_limit', 5)) + ->get(['id', 'title', 'slug', 'views', 'published_at']); + + $tags = NewsTag::has('articles')->orderBy('name')->get(); + + return view('news.index', [ + 'featured' => $featured, + 'articles' => $articles, + 'categories' => $categories, + 'trending' => $trending, + 'tags' => $tags, + ]); + } + + // ----------------------------------------------------------------------- + // Category page — /news/category/{slug} + // ----------------------------------------------------------------------- + + public function category(Request $request, string $slug) + { + $category = NewsCategory::where('slug', $slug)->where('is_active', true)->firstOrFail(); + $perPage = config('news.articles_per_page', 12); + + $articles = NewsArticle::with('author', 'category') + ->published() + ->byCategory($category->id) + ->orderByDesc('published_at') + ->paginate($perPage); + + $categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get(); + + return view('news.category', [ + 'category' => $category, + 'articles' => $articles, + 'categories' => $categories, + ]); + } + + // ----------------------------------------------------------------------- + // Tag page — /news/tag/{slug} + // ----------------------------------------------------------------------- + + public function tag(Request $request, string $slug) + { + $tag = NewsTag::where('slug', $slug)->firstOrFail(); + $perPage = config('news.articles_per_page', 12); + + $articles = NewsArticle::with('author', 'category') + ->published() + ->whereHas('tags', fn ($q) => $q->where('news_tags.slug', $slug)) + ->orderByDesc('published_at') + ->paginate($perPage); + + $categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get(); + + return view('news.tag', [ + 'tag' => $tag, + 'articles' => $articles, + 'categories' => $categories, + ]); + } + + // ----------------------------------------------------------------------- + // Article page — /news/{slug} + // ----------------------------------------------------------------------- + + public function show(Request $request, string $slug) + { + $article = NewsArticle::with('author', 'category', 'tags') + ->published() + ->where('slug', $slug) + ->firstOrFail(); + + // Track view (once per session / IP) + $this->trackView($request, $article); + + // Related articles (same category, excluding current) + $related = NewsArticle::with('author') + ->published() + ->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id)) + ->where('id', '!=', $article->id) + ->orderByDesc('published_at') + ->limit(config('news.related_limit', 4)) + ->get(); + + return view('news.show', [ + 'article' => $article, + 'related' => $related, + ]); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function trackView(Request $request, NewsArticle $article): void + { + $ip = $request->ip(); + $userId = Auth::id(); + $session = 'news_view_' . $article->id; + + if ($request->session()->has($session)) { + return; + } + + NewsView::create([ + 'article_id' => $article->id, + 'user_id' => $userId, + 'ip' => $ip, + 'created_at' => now(), + ]); + + $article->incrementViews(); + + $request->session()->put($session, true); + } +} diff --git a/app/Http/Controllers/News/NewsRssController.php b/app/Http/Controllers/News/NewsRssController.php new file mode 100644 index 00000000..8532d463 --- /dev/null +++ b/app/Http/Controllers/News/NewsRssController.php @@ -0,0 +1,74 @@ +published() + ->orderByDesc('published_at') + ->limit(config('news.rss_limit', 25)) + ->get(); + + $xml = $this->buildRss($articles); + + return response($xml, 200, [ + 'Content-Type' => 'application/rss+xml; charset=UTF-8', + ]); + } + + private function buildRss($articles): string + { + $siteUrl = config('app.url'); + $title = e(config('news.rss_title', 'News')); + $description = e(config('news.rss_description', 'Latest news.')); + $now = now()->toRfc2822String(); + + $items = ''; + foreach ($articles as $article) { + $link = e(url('/news/' . $article->slug)); + $pubDate = $article->published_at?->toRfc2822String() ?? $now; + $articleTitle = e($article->title); + $excerpt = e(strip_tags((string) ($article->excerpt ?? ''))); + $category = e((string) ($article->category?->name ?? '')); + $author = e((string) ($article->author?->name ?? '')); + + $items .= << + <![CDATA[{$articleTitle}]]> + {$link} + {$link} + + {$pubDate} + {$author} + {$category} + + +ITEM; + } + + return << + + + {$title} + {$siteUrl}/news + {$description} + en-us + {$now} + +{$items} + +XML; + } +} diff --git a/app/Http/Controllers/StoryController.php b/app/Http/Controllers/StoryController.php new file mode 100644 index 00000000..51dcf560 --- /dev/null +++ b/app/Http/Controllers/StoryController.php @@ -0,0 +1,1350 @@ +with(['creator.profile', 'tags']) + ->orderByDesc('likes_count') + ->orderByDesc('published_at') + ->first(); + }); + + $trendingStories = Cache::remember('stories:feed:trending', 300, function () { + $now = now(); + + return Story::published() + ->with(['creator.profile', 'tags']) + ->latest('published_at') + ->limit(60) + ->get() + ->sortByDesc(function (Story $story) use ($now): int { + $daysOld = (int) ($story->published_at?->diffInDays($now) ?? 30); + $recentBonus = max(0, 30 - $daysOld); + + return ((int) $story->views) + + ((int) $story->likes_count * 3) + + ((int) $story->comments_count * 4) + + min(20, max(1, (int) $story->reading_time)) + + $recentBonus; + }) + ->take(6) + ->values(); + }); + + $latestStories = Story::published() + ->with(['creator.profile', 'tags']) + ->latest('published_at') + ->paginate(12) + ->withQueryString(); + + return view('web.stories.index', [ + 'featured' => $featured, + 'trendingStories' => $trendingStories, + 'latestStories' => $latestStories, + 'categories' => $this->storyCategories(), + 'page_title' => 'Creator Stories - Skinbase', + 'page_meta_description' => 'Long-form creator stories, tutorials, interviews and project breakdowns on Skinbase.', + 'page_canonical' => route('stories.index'), + 'page_robots' => 'index,follow', + ]); + } + + public function show(Request $request, string $slug): View + { + $story = Story::published() + ->with(['creator.profile', 'tags']) + ->where('slug', $slug) + ->firstOrFail(); + + $storyContentHtml = $this->renderStoryContent((string) $story->content); + + StoryView::query()->create([ + 'story_id' => $story->id, + 'user_id' => $request->user()?->id, + 'ip_address' => (string) $request->ip(), + 'created_at' => now(), + ]); + + $story->increment('views'); + + $relatedStories = Story::published() + ->with(['creator.profile', 'tags']) + ->where('id', '!=', $story->id) + ->where(function ($query) use ($story): void { + $query->where('creator_id', $story->creator_id) + ->orWhereHas('tags', function ($tagsQuery) use ($story): void { + $tagsQuery->whereIn('story_tags.id', $story->tags->pluck('id')->all()); + }); + }) + ->latest('published_at') + ->limit(6) + ->get(); + + $relatedArtworks = collect(); + if ($story->creator_id !== null) { + $relatedArtworks = Artwork::query() + ->where('user_id', $story->creator_id) + ->where('is_public', true) + ->where('is_approved', true) + ->latest('published_at') + ->limit(4) + ->get(['id', 'title', 'slug']); + } + + $discussionComments = collect(); + if ($story->creator_id !== null && Schema::hasTable('profile_comments')) { + $discussionComments = DB::table('profile_comments as pc') + ->join('users as u', 'u.id', '=', 'pc.author_user_id') + ->where('pc.profile_user_id', $story->creator_id) + ->where('pc.is_active', true) + ->orderByDesc('pc.created_at') + ->limit(8) + ->get([ + 'pc.id', + 'pc.body', + 'pc.created_at', + 'u.username as author_username', + ]); + } + + return view('web.stories.show', [ + 'story' => $story, + 'safeContent' => $storyContentHtml, + 'relatedStories' => $relatedStories, + 'relatedArtworks' => $relatedArtworks, + 'comments' => $discussionComments, + 'page_title' => $story->title . ' - Skinbase Stories', + 'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160), + 'page_canonical' => route('stories.show', $story->slug), + 'page_robots' => 'index,follow', + ]); + } + + public function create(Request $request): View + { + $tags = StoryTag::query()->orderBy('name')->limit(80)->get(['id', 'name', 'slug']); + + return view('web.stories.editor', [ + 'story' => new Story([ + 'status' => 'draft', + 'story_type' => 'creator_story', + 'content' => '', + ]), + 'mode' => 'create', + 'tags' => $tags, + 'storyTypes' => $this->storyCategories(), + 'page_title' => 'Create Story - Skinbase', + 'page_meta_description' => 'Write and publish a creator story on Skinbase.', + 'page_robots' => 'noindex,nofollow', + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateStoryPayload($request); + $resolved = $this->resolveWorkflowState($request, $validated, true); + $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); + + $baseSlug = Str::slug($validated['title']); + $slug = $baseSlug; + $suffix = 2; + while (Story::query()->where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $suffix; + $suffix++; + } + + $readingTime = $this->estimateReadingTimeFromSerializedContent($serializedContent); + + $story = Story::query()->create([ + 'creator_id' => (int) $request->user()->id, + 'title' => $validated['title'], + 'slug' => $slug, + 'cover_image' => $validated['cover_image'] ?? null, + 'excerpt' => $validated['excerpt'] ?? null, + 'content' => $serializedContent, + 'story_type' => $validated['story_type'], + 'reading_time' => $readingTime, + 'status' => $resolved['status'], + 'published_at' => $resolved['published_at'], + 'scheduled_for' => $resolved['scheduled_for'], + 'meta_title' => $validated['meta_title'] ?? $validated['title'], + 'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160), + 'canonical_url' => $validated['canonical_url'] ?? null, + 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), + 'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? now() : null, + ]); + + $story->tags()->sync($this->resolveTagIds($validated)); + + if ($resolved['status'] === 'published') { + return redirect()->route('stories.show', ['slug' => $story->slug]) + ->with('status', 'Story published.'); + } + + return redirect()->route('creator.stories.edit', ['story' => $story->id]) + ->with('status', $resolved['status'] === 'pending_review' ? 'Story submitted for review.' : 'Draft saved.'); + } + + public function dashboard(Request $request): View + { + $creatorId = (int) $request->user()->id; + + $drafts = Story::query() + ->where('creator_id', $creatorId) + ->whereIn('status', ['draft', 'pending_review', 'rejected']) + ->latest('updated_at') + ->limit(20) + ->get(); + + $published = Story::query() + ->where('creator_id', $creatorId) + ->whereIn('status', ['published', 'scheduled']) + ->latest('published_at') + ->limit(20) + ->get(); + + $archived = Story::query() + ->where('creator_id', $creatorId) + ->where('status', 'archived') + ->latest('updated_at') + ->limit(20) + ->get(); + + return view('web.stories.dashboard', [ + 'drafts' => $drafts, + 'publishedStories' => $published, + 'archivedStories' => $archived, + 'page_title' => 'My Stories - Skinbase', + 'page_meta_description' => 'Manage your drafts, published stories, and archived stories.', + 'page_robots' => 'noindex,nofollow', + ]); + } + + public function edit(Request $request, Story $story): View + { + abort_unless($this->canManageStory($request, $story), 403); + + return view('web.stories.editor', [ + 'story' => $story->load('tags'), + 'mode' => 'edit', + 'tags' => StoryTag::query()->orderBy('name')->limit(120)->get(['id', 'name', 'slug']), + 'storyTypes' => $this->storyCategories(), + 'page_title' => 'Edit Story - Skinbase', + 'page_meta_description' => 'Update your story draft and publishing settings.', + 'page_robots' => 'noindex,nofollow', + ]); + } + + public function update(Request $request, Story $story): RedirectResponse + { + abort_unless($this->canManageStory($request, $story), 403); + + $validated = $this->validateStoryPayload($request); + $resolved = $this->resolveWorkflowState($request, $validated, false); + $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); + + $story->update([ + 'title' => $validated['title'], + 'excerpt' => $validated['excerpt'] ?? null, + 'cover_image' => $validated['cover_image'] ?? null, + 'content' => $serializedContent, + 'story_type' => $validated['story_type'], + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), + 'status' => $resolved['status'], + 'published_at' => $resolved['published_at'], + 'scheduled_for' => $resolved['scheduled_for'], + 'meta_title' => $validated['meta_title'] ?? $validated['title'], + 'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160), + 'canonical_url' => $validated['canonical_url'] ?? null, + 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), + 'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, + ]); + + if ($story->slug === '' || Str::slug($story->title) !== $story->slug) { + $story->update(['slug' => $this->uniqueSlug($story->title, $story->id)]); + } + + $story->tags()->sync($this->resolveTagIds($validated)); + + return back()->with('status', 'Story updated.'); + } + + public function destroy(Request $request, Story $story): RedirectResponse + { + abort_unless($this->canManageStory($request, $story), 403); + + $story->delete(); + + return redirect()->route('creator.stories.index')->with('status', 'Story deleted.'); + } + + public function autosave(Request $request, Story $story): JsonResponse + { + abort_unless($this->canManageStory($request, $story), 403); + + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'content' => ['nullable'], + 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], + 'tags_csv' => ['nullable', 'string', 'max:500'], + ]); + + $nextContent = array_key_exists('content', $validated) + ? $this->normalizeStoryContent($validated['content']) + : (string) $story->content; + + $story->fill([ + 'title' => $validated['title'] ?? $story->title, + 'excerpt' => $validated['excerpt'] ?? $story->excerpt, + 'cover_image' => $validated['cover_image'] ?? $story->cover_image, + 'content' => $nextContent, + 'story_type' => $validated['story_type'] ?? $story->story_type, + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent), + 'status' => in_array($story->status, ['pending_review', 'published', 'scheduled'], true) ? $story->status : 'draft', + ]); + $story->save(); + + if (! empty($validated['tags_csv'])) { + $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); + } + + return response()->json([ + 'ok' => true, + 'saved_at' => now()->toIso8601String(), + 'message' => 'Saved just now', + ]); + } + + public function submitForReview(Request $request, Story $story): RedirectResponse + { + abort_unless($this->canManageStory($request, $story), 403); + + $story->update([ + 'status' => 'pending_review', + 'submitted_for_review_at' => now(), + 'rejected_reason' => null, + ]); + + return back()->with('status', 'Story submitted for review.'); + } + + public function publishNow(Request $request, Story $story): RedirectResponse + { + abort_unless($this->canManageStory($request, $story), 403); + + $story->update([ + 'status' => 'published', + 'published_at' => now(), + 'scheduled_for' => null, + ]); + + $story->creator?->notify(new StoryStatusNotification($story, 'published')); + + return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.'); + } + + public function preview(Request $request, Story $story): View + { + abort_unless($this->canManageStory($request, $story), 403); + + return view('web.stories.preview', [ + 'story' => $story->load(['creator.profile', 'tags']), + 'safeContent' => $this->renderStoryContent((string) $story->content), + 'page_title' => 'Preview: ' . $story->title, + 'page_robots' => 'noindex,nofollow', + ]); + } + + public function analytics(Request $request, Story $story): View + { + abort_unless($this->canManageStory($request, $story), 403); + + $viewsLast7 = StoryView::query() + ->where('story_id', $story->id) + ->where('created_at', '>=', now()->subDays(7)) + ->count(); + + $viewsLast30 = StoryView::query() + ->where('story_id', $story->id) + ->where('created_at', '>=', now()->subDays(30)) + ->count(); + + return view('web.stories.analytics', [ + 'story' => $story, + 'metrics' => [ + 'views' => (int) $story->views, + 'likes' => (int) $story->likes_count, + 'comments' => (int) $story->comments_count, + 'read_time' => (int) $story->reading_time, + 'views_last_7_days' => $viewsLast7, + 'views_last_30_days' => $viewsLast30, + 'estimated_total_read_minutes' => (int) $story->views * max(1, (int) $story->reading_time), + ], + 'page_title' => 'Story Analytics - ' . $story->title, + 'page_robots' => 'noindex,nofollow', + ]); + } + + public function searchArtworks(Request $request): JsonResponse + { + $q = trim((string) $request->query('q', '')); + + $artworks = Artwork::query() + ->where('user_id', (int) $request->user()->id) + ->when($q !== '', function ($query) use ($q): void { + $query->where('title', 'like', '%' . $q . '%'); + }) + ->latest('id') + ->limit(20) + ->get(['id', 'title', 'slug', 'hash', 'thumb_ext']) + ->map(function (Artwork $art): array { + $thumbs = [ + 'xs' => $this->resolveArtworkThumbUrl($art, 'xs'), + 'sm' => $this->resolveArtworkThumbUrl($art, 'sm'), + 'md' => $this->resolveArtworkThumbUrl($art, 'md'), + 'lg' => $this->resolveArtworkThumbUrl($art, 'lg'), + 'xl' => $this->resolveArtworkThumbUrl($art, 'xl'), + ]; + + return [ + 'id' => $art->id, + 'title' => $art->title, + 'url' => route('art.show', ['id' => $art->id, 'slug' => $art->slug]), + 'thumb' => $thumbs['sm'] ?? $thumbs['md'] ?? '', + 'thumbs' => $thumbs, + ]; + }) + ->values(); + + return response()->json(['artworks' => $artworks]); + } + + public function apiCreate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:255'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], + 'content' => ['nullable'], + 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'scheduled_for' => ['nullable', 'date'], + 'tags_csv' => ['nullable', 'string', 'max:500'], + 'meta_title' => ['nullable', 'string', 'max:255'], + 'meta_description' => ['nullable', 'string', 'max:300'], + 'canonical_url' => ['nullable', 'url', 'max:500'], + 'og_image' => ['nullable', 'string', 'max:500'], + ]); + + $workflow = $this->resolveWorkflowState($request, array_merge([ + 'status' => 'draft', + 'story_type' => 'creator_story', + 'title' => 'Untitled Story', + 'content' => ['type' => 'doc', 'content' => [['type' => 'paragraph']]], + ], $validated), true); + + $title = trim((string) ($validated['title'] ?? 'Untitled Story')); + if ($title === '') { + $title = 'Untitled Story'; + } + + $slug = $this->uniqueSlug($title); + $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); + + $story = Story::query()->create([ + 'creator_id' => (int) $request->user()->id, + 'title' => $title, + 'slug' => $slug, + 'cover_image' => $validated['cover_image'] ?? null, + 'excerpt' => $validated['excerpt'] ?? null, + 'content' => $serializedContent, + 'story_type' => $validated['story_type'] ?? 'creator_story', + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), + 'status' => $workflow['status'], + 'published_at' => $workflow['published_at'], + 'scheduled_for' => $workflow['scheduled_for'], + 'meta_title' => $validated['meta_title'] ?? $title, + 'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160), + 'canonical_url' => $validated['canonical_url'] ?? null, + 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), + 'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? now() : null, + ]); + + if (! empty($validated['tags_csv'])) { + $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); + } + + return response()->json([ + 'ok' => true, + 'story_id' => (int) $story->id, + 'status' => $story->status, + 'message' => 'Story created.', + ]); + } + + public function apiUpdate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'story_id' => ['required', 'integer', 'exists:stories,id'], + 'title' => ['nullable', 'string', 'max:255'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], + 'content' => ['nullable'], + 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'scheduled_for' => ['nullable', 'date'], + 'tags_csv' => ['nullable', 'string', 'max:500'], + 'meta_title' => ['nullable', 'string', 'max:255'], + 'meta_description' => ['nullable', 'string', 'max:300'], + 'canonical_url' => ['nullable', 'url', 'max:500'], + 'og_image' => ['nullable', 'string', 'max:500'], + ]); + + $story = Story::query()->findOrFail((int) $validated['story_id']); + abort_unless($this->canManageStory($request, $story), 403); + + $workflow = $this->resolveWorkflowState($request, array_merge([ + 'status' => $story->status, + ], $validated), false); + + $title = trim((string) ($validated['title'] ?? $story->title)); + if ($title === '') { + $title = 'Untitled Story'; + } + + $serializedContent = array_key_exists('content', $validated) + ? $this->normalizeStoryContent($validated['content']) + : (string) $story->content; + + $story->update([ + 'title' => $title, + 'slug' => $story->slug !== '' ? $story->slug : $this->uniqueSlug($title, (int) $story->id), + 'cover_image' => $validated['cover_image'] ?? $story->cover_image, + 'excerpt' => $validated['excerpt'] ?? $story->excerpt, + 'content' => $serializedContent, + 'story_type' => $validated['story_type'] ?? $story->story_type, + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), + 'status' => $workflow['status'], + 'published_at' => $workflow['published_at'] ?? $story->published_at, + 'scheduled_for' => $workflow['scheduled_for'], + 'meta_title' => $validated['meta_title'] ?? $story->meta_title ?? $title, + 'meta_description' => $validated['meta_description'] ?? $story->meta_description, + 'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url, + 'og_image' => $validated['og_image'] ?? $story->og_image, + 'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, + ]); + + if (! empty($validated['tags_csv'])) { + $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); + } + + return response()->json([ + 'ok' => true, + 'story_id' => (int) $story->id, + 'status' => $story->status, + 'message' => 'Story updated.', + ]); + } + + public function apiAutosave(Request $request): JsonResponse + { + $validated = $request->validate([ + 'story_id' => ['nullable', 'integer', 'exists:stories,id'], + 'title' => ['nullable', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'content' => ['nullable'], + 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], + 'tags_csv' => ['nullable', 'string', 'max:500'], + 'meta_title' => ['nullable', 'string', 'max:255'], + 'meta_description' => ['nullable', 'string', 'max:300'], + 'canonical_url' => ['nullable', 'url', 'max:500'], + 'og_image' => ['nullable', 'string', 'max:500'], + 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'scheduled_for' => ['nullable', 'date'], + ]); + + $story = null; + if (! empty($validated['story_id'])) { + $story = Story::query()->findOrFail((int) $validated['story_id']); + abort_unless($this->canManageStory($request, $story), 403); + } + + if (! $story) { + $title = trim((string) ($validated['title'] ?? 'Untitled Story')); + if ($title === '') { + $title = 'Untitled Story'; + } + + $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); + $story = Story::query()->create([ + 'creator_id' => (int) $request->user()->id, + 'title' => $title, + 'slug' => $this->uniqueSlug($title), + 'excerpt' => $validated['excerpt'] ?? null, + 'cover_image' => $validated['cover_image'] ?? null, + 'content' => $serializedContent, + 'story_type' => $validated['story_type'] ?? 'creator_story', + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), + 'status' => 'draft', + 'meta_title' => $validated['meta_title'] ?? $title, + 'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160), + 'canonical_url' => $validated['canonical_url'] ?? null, + 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), + ]); + } else { + $nextContent = array_key_exists('content', $validated) + ? $this->normalizeStoryContent($validated['content']) + : (string) $story->content; + + $nextStatus = $validated['status'] ?? $story->status; + if (! in_array($nextStatus, ['pending_review', 'published', 'scheduled', 'archived', 'rejected'], true)) { + $nextStatus = 'draft'; + } + + $story->fill([ + 'title' => trim((string) ($validated['title'] ?? $story->title)) ?: 'Untitled Story', + 'excerpt' => $validated['excerpt'] ?? $story->excerpt, + 'cover_image' => $validated['cover_image'] ?? $story->cover_image, + 'content' => $nextContent, + 'story_type' => $validated['story_type'] ?? $story->story_type, + 'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent), + 'status' => $nextStatus, + 'meta_title' => $validated['meta_title'] ?? $story->meta_title, + 'meta_description' => $validated['meta_description'] ?? $story->meta_description, + 'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url, + 'og_image' => $validated['og_image'] ?? $story->og_image, + 'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for, + ]); + $story->save(); + } + + if (! empty($validated['tags_csv'])) { + $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); + } + + return response()->json([ + 'ok' => true, + 'story_id' => (int) $story->id, + 'saved_at' => now()->toIso8601String(), + 'message' => 'Saved just now', + ]); + } + + public function apiArtworks(Request $request): JsonResponse + { + return $this->searchArtworks($request); + } + + public function apiUploadImage(Request $request): JsonResponse + { + return $this->uploadImage($request); + } + + public function uploadImage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'image' => ['required', 'image', 'max:10240'], + ]); + + /** @var UploadedFile $file */ + $file = $validated['image']; + $sourcePath = $file->getRealPath() ?: $file->getPathname(); + + if ($sourcePath === '' || ! is_file($sourcePath)) { + return response()->json([ + 'message' => 'Unable to read uploaded image. Please try again.', + ], 422); + } + + $disk = Storage::disk('public'); + $base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid(); + $extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg')); + $originalPath = $base . '/original.' . $extension; + $thumbnailPath = $base . '/thumbnail.webp'; + $mediumPath = $base . '/medium.webp'; + + $stream = fopen($sourcePath, 'rb'); + if ($stream === false) { + return response()->json([ + 'message' => 'Unable to process uploaded image. Please try again.', + ], 422); + } + + try { + $disk->put($originalPath, $stream); + } finally { + fclose($stream); + } + + $storedThumbnails = false; + if (class_exists(ImageManager::class)) { + try { + $manager = extension_loaded('gd') + ? new ImageManager(new GdDriver()) + : new ImageManager(new ImagickDriver()); + + $image = $manager->read($sourcePath); + + $thumb = $image->scaleDown(width: 420); + $disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82))); + + $medium = $image->scaleDown(width: 1200); + $disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85))); + $storedThumbnails = true; + } catch (\Throwable) { + $storedThumbnails = false; + } + } + + if (! $storedThumbnails) { + $disk->copy($originalPath, $thumbnailPath); + $disk->copy($originalPath, $mediumPath); + } + + return response()->json([ + 'thumbnail_url' => $disk->url($thumbnailPath), + 'medium_url' => $disk->url($mediumPath), + 'original_url' => $disk->url($originalPath), + ]); + } + + public function tag(string $tag): View + { + $storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail(); + + $stories = Story::published() + ->with(['creator.profile', 'tags']) + ->whereHas('tags', fn ($query) => $query->where('story_tags.id', $storyTag->id)) + ->latest('published_at') + ->paginate(12) + ->withQueryString(); + + return view('web.stories.tag', [ + 'storyTag' => $storyTag, + 'stories' => $stories, + 'page_title' => '#' . $storyTag->name . ' Stories - Skinbase', + 'page_meta_description' => 'Creator stories tagged with ' . $storyTag->name . '.', + 'page_canonical' => route('stories.tag', $storyTag->slug), + ]); + } + + public function creator(string $username): View + { + $creator = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail(); + + $stories = Story::published() + ->with(['creator.profile', 'tags']) + ->where('creator_id', $creator->id) + ->latest('published_at') + ->paginate(12) + ->withQueryString(); + + return view('web.stories.creator', [ + 'creator' => $creator, + 'stories' => $stories, + 'page_title' => 'Stories by @' . $creator->username . ' - Skinbase', + 'page_meta_description' => 'Read stories published by @' . $creator->username . '.', + 'page_canonical' => route('stories.creator', $creator->username), + ]); + } + + public function category(string $category): View + { + $normalized = strtolower($category); + $valid = $this->storyCategories()->pluck('slug')->all(); + + abort_unless(in_array($normalized, $valid, true), 404); + + $stories = Story::published() + ->with(['creator.profile', 'tags']) + ->where('story_type', $normalized) + ->latest('published_at') + ->paginate(12) + ->withQueryString(); + + return view('web.stories.category', [ + 'category' => $normalized, + 'stories' => $stories, + 'categories' => $this->storyCategories(), + 'page_title' => ucfirst(str_replace('_', ' ', $normalized)) . ' Stories - Skinbase', + 'page_meta_description' => 'Browse ' . str_replace('_', ' ', $normalized) . ' stories on Skinbase.', + 'page_canonical' => route('stories.category', $normalized), + ]); + } + + private function storyCategories(): Collection + { + return collect([ + ['slug' => 'tutorial', 'name' => 'Tutorials'], + ['slug' => 'creator_story', 'name' => 'Creator Stories'], + ['slug' => 'interview', 'name' => 'Interviews'], + ['slug' => 'announcement', 'name' => 'Announcements'], + ['slug' => 'resource', 'name' => 'Resources'], + ['slug' => 'project_breakdown', 'name' => 'Project Breakdowns'], + ]); + } + + private function validateStoryPayload(Request $request): array + { + return $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'cover_image' => ['nullable', 'string', 'max:500'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'story_type' => ['required', Rule::in($this->storyCategories()->pluck('slug')->all())], + 'content' => ['required'], + 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], + 'scheduled_for' => ['nullable', 'date'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['integer', 'exists:story_tags,id'], + 'tags_csv' => ['nullable', 'string', 'max:500'], + 'meta_title' => ['nullable', 'string', 'max:255'], + 'meta_description' => ['nullable', 'string', 'max:300'], + 'canonical_url' => ['nullable', 'url', 'max:500'], + 'og_image' => ['nullable', 'string', 'max:500'], + ]); + } + + private function resolveWorkflowState(Request $request, array $validated, bool $isCreate): array + { + $action = (string) $request->input('submit_action', 'save_draft'); + $status = (string) ($validated['status'] ?? 'draft'); + $scheduledFor = ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : null; + $publishedAt = null; + + if ($action === 'submit_review') { + $status = 'pending_review'; + } elseif ($action === 'publish_now') { + $status = 'published'; + $publishedAt = now(); + } elseif ($status === 'scheduled' && $scheduledFor !== null) { + $publishedAt = $scheduledFor; + } + + if (! $isCreate && $status === 'published' && $publishedAt === null) { + $publishedAt = now(); + } + + return [ + 'status' => $status, + 'published_at' => $publishedAt, + 'scheduled_for' => $status === 'scheduled' ? $scheduledFor : null, + ]; + } + + private function resolveTagIds(array $validated): array + { + $tagIds = collect($validated['tags'] ?? [])->map(fn ($id) => (int) $id)->filter()->values(); + + $csv = (string) ($validated['tags_csv'] ?? ''); + if ($csv !== '') { + $names = collect(explode(',', $csv)) + ->map(fn ($part) => trim($part)) + ->filter() + ->unique() + ->take(12) + ->values(); + + $extraIds = $names->map(function (string $name): int { + $slug = Str::slug($name); + + $tag = StoryTag::query()->firstOrCreate( + ['slug' => $slug], + ['name' => Str::title($name)] + ); + + return (int) $tag->id; + }); + + $tagIds = $tagIds->merge($extraIds)->unique()->values(); + } + + return $tagIds->all(); + } + + private function renderStoryContent(string $raw): string + { + $decoded = json_decode($raw, true); + if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') { + return $this->renderTipTapDocument($decoded); + } + + return $this->sanitizeStoryContent($raw); + } + + private function normalizeStoryContent(mixed $content): string + { + if (is_array($content)) { + if (($content['type'] ?? null) !== 'doc') { + $content = [ + 'type' => 'doc', + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => trim((string) json_encode($content))]]], + ], + ]; + } + + return (string) json_encode($content, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + if (is_string($content)) { + $decoded = json_decode($content, true); + if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') { + return (string) json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + $text = trim(strip_tags($content)); + + return (string) json_encode([ + 'type' => 'doc', + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]], + ], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + return (string) json_encode([ + 'type' => 'doc', + 'content' => [['type' => 'paragraph']], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function estimateReadingTimeFromSerializedContent(string $serializedContent): int + { + $decoded = json_decode($serializedContent, true); + if (is_array($decoded)) { + $text = trim($this->extractTipTapText($decoded)); + if ($text !== '') { + return max(1, (int) ceil(str_word_count($text) / 200)); + } + } + + return max(1, (int) ceil(str_word_count(strip_tags($serializedContent)) / 200)); + } + + private function extractTipTapText(array $node): string + { + $type = (string) ($node['type'] ?? ''); + if ($type === 'text') { + return (string) ($node['text'] ?? ''); + } + + $content = $node['content'] ?? []; + if (! is_array($content)) { + return ''; + } + + $chunks = []; + foreach ($content as $child) { + if (is_array($child)) { + $chunks[] = $this->extractTipTapText($child); + } + } + + $joined = implode(' ', array_filter($chunks, fn ($part) => $part !== '')); + + return in_array($type, ['paragraph', 'heading', 'blockquote', 'codeBlock', 'listItem'], true) + ? $joined . "\n" + : $joined; + } + + private function renderTipTapDocument(array $doc): string + { + $content = $doc['content'] ?? []; + if (! is_array($content)) { + return ''; + } + + $html = ''; + foreach ($content as $node) { + if (is_array($node)) { + $html .= $this->renderTipTapNode($node); + } + } + + return $this->sanitizeStoryContent($html); + } + + private function renderTipTapNode(array $node): string + { + $type = (string) ($node['type'] ?? ''); + $attrs = is_array($node['attrs'] ?? null) ? $node['attrs'] : []; + $content = is_array($node['content'] ?? null) ? $node['content'] : []; + + if ($type === 'text') { + $text = e((string) ($node['text'] ?? '')); + $marks = is_array($node['marks'] ?? null) ? $node['marks'] : []; + + foreach ($marks as $mark) { + $markType = (string) ($mark['type'] ?? ''); + if ($markType === 'bold') { + $text = '' . $text . ''; + } elseif ($markType === 'italic') { + $text = '' . $text . ''; + } elseif ($markType === 'code') { + $text = '' . $text . ''; + } elseif ($markType === 'link') { + $href = (string) (($mark['attrs']['href'] ?? '') ?: ''); + if ($this->isSafeUrl($href)) { + $text = '' . $text . ''; + } + } + } + + return $text; + } + + $inner = ''; + foreach ($content as $child) { + if (is_array($child)) { + $inner .= $this->renderTipTapNode($child); + } + } + + return match ($type) { + 'paragraph' => '

' . $inner . '

', + 'heading' => '' . $inner . '', + 'blockquote' => '
' . $inner . '
', + 'bulletList' => '', + 'orderedList' => '
    ' . $inner . '
', + 'listItem' => '
  • ' . $inner . '
  • ', + 'horizontalRule' => '
    ', + 'codeBlock' => '
    ' . e($this->extractTipTapText($node)) . '
    ', + 'image' => $this->renderImageNode($attrs), + 'artworkEmbed' => $this->renderArtworkEmbedNode($attrs), + 'galleryBlock' => $this->renderGalleryBlockNode($attrs), + 'videoEmbed' => $this->renderVideoEmbedNode($attrs), + 'downloadAsset' => $this->renderDownloadAssetNode($attrs), + default => $inner, + }; + } + + private function renderImageNode(array $attrs): string + { + $src = (string) ($attrs['src'] ?? ''); + if (! $this->isSafeUrl($src)) { + return ''; + } + + return '' . e((string) ($attrs['alt'] ?? 'Story image')) . ''; + } + + private function renderArtworkEmbedNode(array $attrs): string + { + $id = (int) ($attrs['artworkId'] ?? 0); + if ($id <= 0) { + return ''; + } + + $art = Artwork::query()->find($id); + if (! $art) { + return '
    Embedded artwork #' . $id . ' is unavailable.
    '; + } + + return ''; + } + + private function resolveArtworkThumbUrl(Artwork $art, string $size): ?string + { + $sized = $art->thumbUrl($size); + if (is_string($sized) && $sized !== '') { + return $sized; + } + + $fallback = $art->thumb_url; + if (! is_string($fallback) || $fallback === '') { + return null; + } + + return preg_replace('#/(thumb|xs|sm|md|lg|xl)/#', '/' . $size . '/', $fallback) ?: $fallback; + } + + private function renderGalleryBlockNode(array $attrs): string + { + $images = $attrs['images'] ?? []; + if (! is_array($images) || $images === []) { + return ''; + } + + $items = collect($images) + ->filter(fn ($src) => is_string($src) && $this->isSafeUrl($src)) + ->take(8) + ->map(fn (string $src) => 'Gallery image') + ->implode(''); + + if ($items === '') { + return ''; + } + + return '
    ' . $items . '
    '; + } + + private function renderVideoEmbedNode(array $attrs): string + { + $src = (string) ($attrs['src'] ?? ''); + if (! $this->isAllowedEmbedUrl($src)) { + return ''; + } + + $title = e((string) ($attrs['title'] ?? 'Embedded video')); + + return '
    ' + . '' + . '
    '; + } + + private function renderDownloadAssetNode(array $attrs): string + { + $url = (string) ($attrs['url'] ?? ''); + if (! $this->isSafeUrl($url)) { + return ''; + } + + $label = e((string) ($attrs['label'] ?? 'Download asset')); + + return '
    ' + . '' . $label . '' + . '
    '; + } + + private function sanitizeStoryContent(string $raw): string + { + $html = trim($raw); + if ($html === '') { + return ''; + } + + $html = preg_replace('/<(script|style)\\b[^>]*>.*?<\\/\\1>/is', '', $html) ?? ''; + + libxml_use_internal_errors(true); + $document = new DOMDocument('1.0', 'UTF-8'); + $document->loadHTML('
    ' . $html . '
    ', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $root = $document->getElementById('story-sanitize-root'); + if (! $root instanceof DOMElement) { + libxml_clear_errors(); + return strip_tags($html); + } + + $allowedTags = [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i', 'u', + 'blockquote', 'pre', 'code', 'hr', 'a', 'img', 'div', 'span', 'figure', 'figcaption', 'iframe', + ]; + + $this->sanitizeDomNode($root, $allowedTags); + + $clean = ''; + foreach ($root->childNodes as $child) { + $clean .= $document->saveHTML($child); + } + + libxml_clear_errors(); + + return $clean; + } + + private function sanitizeDomNode(DOMNode $node, array $allowedTags): void + { + $children = []; + foreach ($node->childNodes as $child) { + $children[] = $child; + } + + foreach ($children as $child) { + if ($child instanceof DOMElement) { + $tag = strtolower($child->tagName); + + if (! in_array($tag, $allowedTags, true)) { + $this->unwrapNode($child); + continue; + } + + $this->sanitizeElementAttributes($child); + } + + if ($child->parentNode !== null) { + $this->sanitizeDomNode($child, $allowedTags); + } + } + } + + private function sanitizeElementAttributes(DOMElement $element): void + { + $tag = strtolower($element->tagName); + $allowedByTag = [ + 'a' => ['href', 'title', 'target', 'rel'], + 'img' => ['src', 'alt', 'title', 'loading'], + 'iframe' => ['src', 'title', 'allow', 'allowfullscreen', 'frameborder', 'referrerpolicy'], + 'div' => ['class'], + 'span' => ['class'], + 'p' => ['class'], + 'pre' => ['class'], + 'code' => ['class'], + 'figure' => ['class'], + 'figcaption' => ['class'], + 'h1' => ['class'], + 'h2' => ['class'], + 'h3' => ['class'], + 'h4' => ['class'], + 'ul' => ['class'], + 'ol' => ['class'], + 'li' => ['class'], + 'blockquote' => ['class'], + ]; + + $allowed = $allowedByTag[$tag] ?? []; + $toRemove = []; + + foreach ($element->attributes as $attribute) { + $name = strtolower($attribute->name); + $value = trim((string) $attribute->value); + + if (str_starts_with($name, 'on') || $name === 'style' || ! in_array($name, $allowed, true)) { + $toRemove[] = $attribute->name; + continue; + } + + if (($name === 'href' || $name === 'src') && ! $this->isSafeUrl($value)) { + $toRemove[] = $attribute->name; + continue; + } + + if ($tag === 'iframe' && $name === 'src' && ! $this->isAllowedEmbedUrl($value)) { + $toRemove[] = $attribute->name; + } + } + + foreach ($toRemove as $name) { + $element->removeAttribute($name); + } + + if ($tag === 'a' && $element->hasAttribute('href')) { + $element->setAttribute('rel', 'nofollow ugc noopener'); + if ($element->getAttribute('target') === '') { + $element->setAttribute('target', '_blank'); + } + } + + if ($tag === 'img' && ! $element->hasAttribute('loading')) { + $element->setAttribute('loading', 'lazy'); + } + + if ($tag === 'iframe' && ! $element->hasAttribute('src')) { + $this->unwrapNode($element); + } + } + + private function isSafeUrl(string $value): bool + { + if ($value === '') { + return false; + } + + $lower = strtolower($value); + if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) { + return false; + } + + return str_starts_with($lower, 'http://') + || str_starts_with($lower, 'https://') + || str_starts_with($lower, '/') + || str_starts_with($lower, '#'); + } + + private function isAllowedEmbedUrl(string $value): bool + { + if (! $this->isSafeUrl($value)) { + return false; + } + + $host = strtolower((string) (parse_url($value, PHP_URL_HOST) ?? '')); + + return $host === 'www.youtube.com' + || $host === 'youtube.com' + || $host === 'youtu.be' + || $host === 'player.vimeo.com' + || $host === 'vimeo.com'; + } + + private function unwrapNode(DOMNode $node): void + { + $parent = $node->parentNode; + if ($parent === null) { + return; + } + + while ($node->firstChild !== null) { + $parent->insertBefore($node->firstChild, $node); + } + + $parent->removeChild($node); + } + + private function canManageStory(Request $request, Story $story): bool + { + $user = $request->user(); + if ($user === null) { + return false; + } + + return (int) $story->creator_id === (int) $user->id + || $user->isAdmin() + || $user->isModerator(); + } + + private function uniqueSlug(string $title, ?int $ignoreId = null): string + { + $baseSlug = Str::slug($title); + $slug = $baseSlug; + $suffix = 2; + + while (Story::query() + ->when($ignoreId !== null, fn ($q) => $q->where('id', '!=', $ignoreId)) + ->where('slug', $slug) + ->exists()) { + $slug = $baseSlug . '-' . $suffix; + $suffix++; + } + + return $slug; + } +} diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 71febf5b..63154444 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -341,8 +341,23 @@ final class StudioArtworksApiController extends Controller // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs) $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); + $displayFileName = $origFilename; + + $clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName())); + $clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? ''; + $clientName = trim((string) $clientName); + + if ($clientName !== '') { + $clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION)); + if ($clientExt === '' && $origExt !== '') { + $clientName .= '.' . $origExt; + } + + $displayFileName = $clientName; + } + $artwork->update([ - 'file_name' => $origFilename, + 'file_name' => $displayFileName, 'file_path' => '', 'file_size' => $size, 'mime_type' => $origMime, diff --git a/app/Http/Controllers/User/AvatarController.php b/app/Http/Controllers/User/AvatarController.php index 4a3f4f1d..3cd6ea7c 100644 --- a/app/Http/Controllers/User/AvatarController.php +++ b/app/Http/Controllers/User/AvatarController.php @@ -31,11 +31,15 @@ class AvatarController extends Controller $file = $request->file('avatar'); try { - $hash = $this->service->storeFromUploadedFile($user->id, $file); + $hash = $this->service->storeFromUploadedFile( + (int) $user->id, + $file, + (string) $request->input('avatar_position', 'center') + ); return response()->json([ 'success' => true, 'hash' => $hash, - 'url' => AvatarUrl::forUser((int) $user->id, $hash, 128), + 'url' => AvatarUrl::forUser((int) $user->id, $hash, 256), ], 200); } catch (RuntimeException $e) { logger()->warning('Avatar upload validation failed', [ diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 4e069d66..ce3472d0 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -4,9 +4,20 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; use App\Http\Requests\ProfileUpdateRequest; +use App\Http\Requests\Settings\RequestEmailChangeRequest; +use App\Http\Requests\Settings\UpdateAccountSectionRequest; +use App\Http\Requests\Settings\UpdateNotificationsSectionRequest; +use App\Http\Requests\Settings\UpdatePersonalSectionRequest; +use App\Http\Requests\Settings\UpdateProfileSectionRequest; +use App\Http\Requests\Settings\UpdateSecurityPasswordRequest; +use App\Http\Requests\Settings\VerifyEmailChangeRequest; +use App\Mail\EmailChangedSecurityAlertMail; +use App\Mail\EmailChangeVerificationCodeMail; use App\Models\Artwork; use App\Models\ProfileComment; +use App\Models\Story; use App\Models\User; +use App\Services\AvatarService; use App\Services\ArtworkService; use App\Services\FollowService; use App\Services\ThumbnailPresenter; @@ -14,6 +25,7 @@ use App\Services\ThumbnailService; use App\Services\UsernameApprovalService; use App\Services\UserStatsService; use App\Support\AvatarUrl; +use App\Support\CoverUrl; use App\Support\UsernamePolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -24,6 +36,7 @@ use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Schema; use Illuminate\View\View; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; use Illuminate\Validation\Rules\Password as PasswordRule; use Inertia\Inertia; @@ -127,6 +140,16 @@ class ProfileController extends Controller public function editSettings(Request $request) { $user = $request->user(); + $cooldownDays = $this->usernameCooldownDays(); + $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); + $usernameCooldownRemainingDays = 0; + + if ($lastUsernameChangeAt !== null) { + $nextAllowedChangeAt = $lastUsernameChangeAt->copy()->addDays($cooldownDays); + if ($nextAllowedChangeAt->isFuture()) { + $usernameCooldownRemainingDays = now()->diffInDays($nextAllowedChangeAt); + } + } // Parse birth date parts $birthDay = null; @@ -176,9 +199,15 @@ class ProfileController extends Controller // Avatar URL $avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null; $avatarUrl = !empty($avatarHash) - ? AvatarUrl::forUser((int) $user->id, $avatarHash, 128) + ? AvatarUrl::forUser((int) $user->id, $avatarHash, 256) : AvatarUrl::default(); + $emailNotifications = (bool) ($profileData['email_notifications'] ?? $profileData['mlist'] ?? $user->mlist ?? true); + $uploadNotifications = (bool) ($profileData['upload_notifications'] ?? $profileData['friend_upload_notice'] ?? $user->friend_upload_notice ?? true); + $followerNotifications = (bool) ($profileData['follower_notifications'] ?? true); + $commentNotifications = (bool) ($profileData['comment_notifications'] ?? true); + $newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false); + return Inertia::render('Settings/ProfileEdit', [ 'user' => [ 'id' => $user->id, @@ -190,16 +219,23 @@ class ProfileController extends Controller 'signature' => $user->signature ?? null, 'description' => $user->description ?? null, 'gender' => $user->gender ?? null, + 'birthday' => $user->birth ?? null, 'country_code' => $user->country_code ?? null, - 'mlist' => $user->mlist ?? false, - 'friend_upload_notice' => $user->friend_upload_notice ?? false, - 'auto_post_upload' => $user->auto_post_upload ?? false, + 'email_notifications' => $emailNotifications, + 'upload_notifications' => $uploadNotifications, + 'follower_notifications' => $followerNotifications, + 'comment_notifications' => $commentNotifications, + 'newsletter' => $newsletter, + 'last_username_change_at' => $user->last_username_change_at, 'username_changed_at' => $user->username_changed_at, ], 'avatarUrl' => $avatarUrl, 'birthDay' => $birthDay, 'birthMonth' => $birthMonth, 'birthYear' => $birthYear, + 'usernameCooldownDays' => $cooldownDays, + 'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays, + 'usernameCooldownActive' => $usernameCooldownRemainingDays > 0, 'countries' => $countries->values(), 'flash' => [ 'status' => session('status'), @@ -208,6 +244,331 @@ class ProfileController extends Controller ])->rootView('settings'); } + public function updateProfileSection(UpdateProfileSectionRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse + { + $user = $request->user(); + $validated = $request->validated(); + + $user->name = (string) $validated['display_name']; + $user->save(); + + $profileUpdates = [ + 'website' => $validated['website'] ?? null, + 'about' => $validated['bio'] ?? null, + 'signature' => $validated['signature'] ?? null, + 'description' => $validated['description'] ?? null, + ]; + + $avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256); + + if (!empty($validated['remove_avatar'])) { + $avatarService->removeAvatar((int) $user->id); + $avatarUrl = AvatarUrl::default(); + } + + if ($request->hasFile('avatar')) { + $hash = $avatarService->storeFromUploadedFile( + (int) $user->id, + $request->file('avatar'), + (string) ($validated['avatar_position'] ?? 'center') + ); + $avatarUrl = AvatarUrl::forUser((int) $user->id, $hash, 256); + } + + $this->persistProfileUpdates((int) $user->id, $profileUpdates); + + return $this->settingsResponse( + $request, + 'Profile updated successfully.', + ['avatarUrl' => $avatarUrl] + ); + } + + public function updateAccountSection(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse + { + return $this->updateUsername($request); + } + + public function updateUsername(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse + { + $user = $request->user(); + $validated = $request->validated(); + + $incomingUsername = UsernamePolicy::normalize((string) $validated['username']); + $currentUsername = UsernamePolicy::normalize((string) ($user->username ?? '')); + + if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) { + $similar = UsernamePolicy::similarReserved($incomingUsername); + if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) { + $this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [ + 'current_username' => $currentUsername, + ]); + + return $this->usernameValidationError($request, 'This username is too similar to a reserved name and requires manual approval.'); + } + + $cooldownDays = $this->usernameCooldownDays(); + $isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false; + $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); + if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) { + $remainingDays = now()->diffInDays($lastUsernameChangeAt->copy()->addDays($cooldownDays)); + + return $this->usernameValidationError($request, "You can change your username again in {$remainingDays} days."); + } + + $user->username = $incomingUsername; + $user->username_changed_at = now(); + if (Schema::hasColumn('users', 'last_username_change_at')) { + $user->last_username_change_at = now(); + } + + $this->storeUsernameHistory((int) $user->id, $currentUsername); + $this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername); + } + + $user->save(); + + return $this->settingsResponse($request, 'Account updated successfully.'); + } + + public function requestEmailChange(RequestEmailChangeRequest $request): RedirectResponse|JsonResponse + { + if (! Schema::hasTable('email_changes')) { + return response()->json([ + 'errors' => [ + 'new_email' => ['Email change is not available right now.'], + ], + ], 422); + } + + $user = $request->user(); + $validated = $request->validated(); + $newEmail = strtolower((string) $validated['new_email']); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $expiresInMinutes = 10; + + DB::table('email_changes')->where('user_id', (int) $user->id)->delete(); + DB::table('email_changes')->insert([ + 'user_id' => (int) $user->id, + 'new_email' => $newEmail, + 'verification_code' => hash('sha256', $code), + 'expires_at' => now()->addMinutes($expiresInMinutes), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Mail::to($newEmail)->queue(new EmailChangeVerificationCodeMail($code, $expiresInMinutes)); + + return $this->settingsResponse($request, 'Verification code sent to your new email address.'); + } + + public function verifyEmailChange(VerifyEmailChangeRequest $request): RedirectResponse|JsonResponse + { + if (! Schema::hasTable('email_changes')) { + return response()->json([ + 'errors' => [ + 'code' => ['Email change verification is not available right now.'], + ], + ], 422); + } + + $user = $request->user(); + $validated = $request->validated(); + $codeHash = hash('sha256', (string) $validated['code']); + + $change = DB::table('email_changes') + ->where('user_id', (int) $user->id) + ->whereNull('used_at') + ->orderByDesc('id') + ->first(); + + if (! $change) { + return response()->json(['errors' => ['code' => ['No pending email change request found.']]], 422); + } + + if (now()->greaterThan($change->expires_at)) { + DB::table('email_changes')->where('id', $change->id)->delete(); + + return response()->json(['errors' => ['code' => ['Verification code has expired. Please request a new one.']]], 422); + } + + if (! hash_equals((string) $change->verification_code, $codeHash)) { + return response()->json(['errors' => ['code' => ['Verification code is invalid.']]], 422); + } + + $newEmail = strtolower((string) $change->new_email); + $oldEmail = strtolower((string) ($user->email ?? '')); + + DB::transaction(function () use ($user, $change, $newEmail): void { + $lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail(); + $lockedUser->email = $newEmail; + $lockedUser->email_verified_at = now(); + $lockedUser->save(); + + DB::table('email_changes') + ->where('id', (int) $change->id) + ->update([ + 'used_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('email_changes') + ->where('user_id', (int) $user->id) + ->where('id', '!=', (int) $change->id) + ->delete(); + }); + + if ($oldEmail !== '' && $oldEmail !== $newEmail) { + Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail)); + } + + return $this->settingsResponse($request, 'Email updated successfully.', [ + 'email' => $newEmail, + ]); + } + + public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse + { + $validated = $request->validated(); + + $profileUpdates = [ + 'birthdate' => $validated['birthday'] ?? null, + 'country_code' => $validated['country'] ?? null, + ]; + + if (!empty($validated['gender'])) { + $profileUpdates['gender'] = strtoupper((string) $validated['gender']); + } + + $this->persistProfileUpdates((int) $request->user()->id, $profileUpdates); + + return $this->settingsResponse($request, 'Personal details saved successfully.'); + } + + public function updateNotificationsSection(UpdateNotificationsSectionRequest $request): RedirectResponse|JsonResponse + { + $validated = $request->validated(); + $userId = (int) $request->user()->id; + + $profileUpdates = [ + 'email_notifications' => (bool) $validated['email_notifications'], + 'upload_notifications' => (bool) $validated['upload_notifications'], + 'follower_notifications' => (bool) $validated['follower_notifications'], + 'comment_notifications' => (bool) $validated['comment_notifications'], + 'newsletter' => (bool) $validated['newsletter'], + // Legacy compatibility mappings. + 'mlist' => (bool) $validated['newsletter'], + 'friend_upload_notice' => (bool) $validated['upload_notifications'], + ]; + + $this->persistProfileUpdates($userId, $profileUpdates); + + return $this->settingsResponse($request, 'Notification settings saved successfully.'); + } + + public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse + { + $validated = $request->validated(); + $user = $request->user(); + $user->password = Hash::make((string) $validated['new_password']); + $user->save(); + + return $this->settingsResponse($request, 'Password updated successfully.'); + } + + private function settingsResponse(Request $request, string $message, array $payload = []): RedirectResponse|JsonResponse + { + if ($request->expectsJson()) { + return response()->json([ + 'success' => true, + 'message' => $message, + ...$payload, + ]); + } + + return Redirect::back()->with('status', $message); + } + + private function persistProfileUpdates(int $userId, array $updates): void + { + if ($updates === [] || !Schema::hasTable('user_profiles')) { + return; + } + + $filtered = []; + foreach ($updates as $column => $value) { + if (Schema::hasColumn('user_profiles', $column)) { + $filtered[$column] = $value; + } + } + + if ($filtered === []) { + return; + } + + DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered); + } + + private function usernameCooldownDays(): int + { + return max(1, (int) config('usernames.rename_cooldown_days', 30)); + } + + private function lastUsernameChangeAt(User $user): ?\Illuminate\Support\Carbon + { + return $user->last_username_change_at ?? $user->username_changed_at; + } + + private function usernameValidationError(Request $request, string $message): RedirectResponse|JsonResponse + { + $error = ['username' => [$message]]; + if ($request->expectsJson()) { + return response()->json(['errors' => $error], 422); + } + + return Redirect::back()->withErrors($error); + } + + private function storeUsernameHistory(int $userId, string $oldUsername): void + { + if ($oldUsername === '' || ! Schema::hasTable('username_history')) { + return; + } + + $payload = [ + 'user_id' => $userId, + 'old_username' => $oldUsername, + 'created_at' => now(), + ]; + + if (Schema::hasColumn('username_history', 'changed_at')) { + $payload['changed_at'] = now(); + } + + if (Schema::hasColumn('username_history', 'updated_at')) { + $payload['updated_at'] = now(); + } + + DB::table('username_history')->insert($payload); + } + + private function storeUsernameRedirect(int $userId, string $oldUsername, string $newUsername): void + { + if ($oldUsername === '' || ! Schema::hasTable('username_redirects')) { + return; + } + + DB::table('username_redirects')->updateOrInsert( + ['old_username' => $oldUsername], + [ + 'new_username' => $newUsername, + 'user_id' => $userId, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse { $user = $request->user(); @@ -238,10 +599,11 @@ class ProfileController extends Controller return Redirect::back()->withErrors($error); } - $cooldownDays = (int) config('usernames.rename_cooldown_days', 90); + $cooldownDays = $this->usernameCooldownDays(); $isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false; + $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); - if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) { + if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) { $error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]]; if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); @@ -251,26 +613,12 @@ class ProfileController extends Controller $user->username = $incomingUsername; $user->username_changed_at = now(); - - DB::table('username_history')->insert([ - 'user_id' => (int) $user->id, - 'old_username' => $currentUsername, - 'changed_at' => now(), - 'created_at' => now(), - 'updated_at' => now(), - ]); - - if ($currentUsername !== '') { - DB::table('username_redirects')->updateOrInsert( - ['old_username' => $currentUsername], - [ - 'new_username' => $incomingUsername, - 'user_id' => (int) $user->id, - 'updated_at' => now(), - 'created_at' => now(), - ] - ); + if (Schema::hasColumn('users', 'last_username_change_at')) { + $user->last_username_change_at = now(); } + + $this->storeUsernameHistory((int) $user->id, $currentUsername); + $this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername); } } @@ -579,6 +927,37 @@ class ProfileController extends Controller ]); } + $creatorStories = Story::query() + ->published() + ->with(['tags']) + ->where('creator_id', $user->id) + ->latest('published_at') + ->limit(6) + ->get([ + 'id', + 'slug', + 'title', + 'excerpt', + 'cover_image', + 'reading_time', + 'views', + 'likes_count', + 'comments_count', + 'published_at', + ]) + ->map(fn (Story $story) => [ + 'id' => $story->id, + 'slug' => $story->slug, + 'title' => $story->title, + 'excerpt' => $story->excerpt, + 'cover_url' => $story->cover_url, + 'reading_time' => $story->reading_time, + 'views' => (int) $story->views, + 'likes_count' => (int) $story->likes_count, + 'comments_count' => (int) $story->comments_count, + 'published_at' => $story->published_at?->toISOString(), + ]); + // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; @@ -593,15 +972,8 @@ class ProfileController extends Controller $countryName = $countryName ?? strtoupper((string) $profile->country_code); } - // ── Hero background artwork ───────────────────────────────────────── - $heroBgUrl = Artwork::public() - ->published() - ->where('user_id', $user->id) - ->whereNotNull('hash') - ->whereNotNull('thumb_ext') - ->inRandomOrder() - ->limit(1) - ->first()?->thumbUrl('lg'); + // ── Cover image hero (preferred) ──────────────────────────────────── + $heroBgUrl = CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp ?? time()); // ── Increment profile views (async-safe, ignore errors) ────────────── if (! $isOwner) { @@ -645,6 +1017,8 @@ class ProfileController extends Controller 'username' => $user->username, 'name' => $user->name, 'avatar_url' => $avatarUrl, + 'cover_url' => $heroBgUrl, + 'cover_position'=> (int) ($user->cover_position ?? 50), 'created_at' => $user->created_at?->toISOString(), 'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, ], @@ -666,6 +1040,7 @@ class ProfileController extends Controller 'viewerIsFollowing' => $viewerIsFollowing, 'heroBgUrl' => $heroBgUrl, 'profileComments' => $profileComments->values(), + 'creatorStories' => $creatorStories->values(), 'countryName' => $countryName, 'isOwner' => $isOwner, 'auth' => $authData, diff --git a/app/Http/Controllers/User/ProfileCoverController.php b/app/Http/Controllers/User/ProfileCoverController.php new file mode 100644 index 00000000..ac7f15ed --- /dev/null +++ b/app/Http/Controllers/User/ProfileCoverController.php @@ -0,0 +1,252 @@ +manager = extension_loaded('gd') + ? new ImageManager(new GdDriver()) + : new ImageManager(new ImagickDriver()); + } catch (\Throwable) { + $this->manager = null; + } + } + + public function upload(Request $request): JsonResponse + { + $user = $request->user(); + if (! $user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $validated = $request->validate([ + 'cover' => [ + 'required', + 'file', + 'image', + 'max:' . self::MAX_FILE_SIZE_KB, + 'mimes:jpg,jpeg,png,webp', + 'mimetypes:image/jpeg,image/png,image/webp', + ], + ]); + + /** @var UploadedFile $file */ + $file = $validated['cover']; + + try { + $stored = $this->storeCoverFile($file); + + $this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext); + + $user->forceFill([ + 'cover_hash' => $stored['hash'], + 'cover_ext' => $stored['ext'], + 'cover_position' => 50, + ])->save(); + + return response()->json([ + 'success' => true, + 'cover_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()), + 'cover_position' => (int) $user->cover_position, + ]); + } catch (RuntimeException $e) { + return response()->json([ + 'error' => 'Validation failed', + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + logger()->error('Profile cover upload failed', [ + 'user_id' => (int) $user->id, + 'message' => $e->getMessage(), + ]); + + return response()->json(['error' => 'Processing failed'], 500); + } + } + + public function updatePosition(Request $request): JsonResponse + { + $user = $request->user(); + if (! $user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $validated = $request->validate([ + 'position' => ['required', 'integer', 'min:0', 'max:100'], + ]); + + if (! $user->cover_hash || ! $user->cover_ext) { + return response()->json(['error' => 'No cover image to update.'], 422); + } + + $user->forceFill([ + 'cover_position' => (int) $validated['position'], + ])->save(); + + return response()->json([ + 'success' => true, + 'cover_position' => (int) $user->cover_position, + ]); + } + + public function destroy(Request $request): JsonResponse + { + $user = $request->user(); + if (! $user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext); + + $user->forceFill([ + 'cover_hash' => null, + 'cover_ext' => null, + 'cover_position' => 50, + ])->save(); + + return response()->json([ + 'success' => true, + 'cover_url' => null, + 'cover_position' => 50, + ]); + } + + private function storageRoot(): string + { + return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR); + } + + private function coverDirectory(string $hash): string + { + $p1 = substr($hash, 0, 2); + $p2 = substr($hash, 2, 2); + + return $this->storageRoot() + . DIRECTORY_SEPARATOR . 'covers' + . DIRECTORY_SEPARATOR . $p1 + . DIRECTORY_SEPARATOR . $p2; + } + + private function coverPath(string $hash, string $ext): string + { + return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext; + } + + /** + * @return array{hash: string, ext: string} + */ + private function storeCoverFile(UploadedFile $file): array + { + $this->assertImageManager(); + + $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname()); + if ($uploadPath === '' || ! is_readable($uploadPath)) { + throw new RuntimeException('Unable to resolve uploaded image path.'); + } + + $raw = file_get_contents($uploadPath); + if ($raw === false || $raw === '') { + throw new RuntimeException('Unable to read uploaded image.'); + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = strtolower((string) $finfo->buffer($raw)); + if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) { + throw new RuntimeException('Unsupported image mime type.'); + } + + $size = @getimagesizefromstring($raw); + if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) { + throw new RuntimeException('Uploaded file is not a valid image.'); + } + + $width = (int) ($size[0] ?? 0); + $height = (int) ($size[1] ?? 0); + if ($width < self::MIN_UPLOAD_WIDTH || $height < self::MIN_UPLOAD_HEIGHT) { + throw new RuntimeException(sprintf( + 'Image is too small. Minimum required size is %dx%d.', + self::MIN_UPLOAD_WIDTH, + self::MIN_UPLOAD_HEIGHT, + )); + } + + $ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp'); + $image = $this->manager->read($raw); + $processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center'); + $encoded = $this->encodeByExtension($processed, $ext); + + $hash = hash('sha256', $encoded); + $dir = $this->coverDirectory($hash); + if (! File::exists($dir)) { + File::makeDirectory($dir, 0755, true); + } + + File::put($this->coverPath($hash, $ext), $encoded); + + return ['hash' => $hash, 'ext' => $ext]; + } + + private function encodeByExtension($image, string $ext): string + { + return match ($ext) { + 'jpg' => (string) $image->encode(new JpegEncoder(85)), + 'png' => (string) $image->encode(new PngEncoder()), + default => (string) $image->encode(new WebpEncoder(85)), + }; + } + + private function deleteCoverFile(string $hash, string $ext): void + { + $trimHash = trim($hash); + $trimExt = strtolower(trim($ext)); + + if ($trimHash === '' || $trimExt === '') { + return; + } + + $path = $this->coverPath($trimHash, $trimExt); + if (is_file($path)) { + @unlink($path); + } + } + + private function assertImageManager(): void + { + if ($this->manager !== null) { + return; + } + + throw new RuntimeException('Image processing is not available on this environment.'); + } +} diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php index f7cbf2b7..60f9767d 100644 --- a/app/Http/Controllers/Web/ExploreController.php +++ b/app/Http/Controllers/Web/ExploreController.php @@ -32,23 +32,33 @@ final class ExploreController extends Controller /** Meilisearch sort-field arrays per sort alias. */ private const SORT_MAP = [ 'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], + 'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], + 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], + 'latest' => ['created_at:desc'], + // Legacy aliases kept for backward compatibility. 'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'best' => ['awards_received_count:desc', 'favorites_count:desc'], - 'latest' => ['created_at:desc'], ]; private const SORT_TTL = [ 'trending' => 300, + 'fresh' => 120, + 'top-rated'=> 600, + 'latest' => 120, 'new-hot' => 120, 'best' => 600, - 'latest' => 120, ]; private const SORT_OPTIONS = [ - ['value' => 'trending', 'label' => '🔥 Trending'], - ['value' => 'new-hot', 'label' => '🚀 New & Hot'], - ['value' => 'best', 'label' => '⭐ Best'], - ['value' => 'latest', 'label' => '🕐 Latest'], + ['value' => 'trending', 'label' => '🔥 Trending'], + ['value' => 'fresh', 'label' => '🚀 New & Hot'], + ['value' => 'top-rated', 'label' => '⭐ Best'], + ['value' => 'latest', 'label' => '🕐 Latest'], + ]; + + private const SORT_ALIASES = [ + 'new-hot' => 'fresh', + 'best' => 'top-rated', ]; public function __construct( @@ -81,23 +91,25 @@ final class ExploreController extends Controller ? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a)) : collect(); - $contentTypes = $this->contentTypeLinks(); + $mainCategories = $this->mainCategories(); $seo = $this->paginationSeo($request, url('/explore'), $artworks); - return view('web.explore.index', [ - 'artworks' => $artworks, - 'spotlight' => $spotlightItems, - 'contentTypes' => $contentTypes, - 'activeType' => null, - 'current_sort' => $sort, - 'sort_options' => self::SORT_OPTIONS, - 'hero_title' => 'Explore', - 'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.', - 'breadcrumbs' => collect([ - (object) ['name' => 'Explore', 'url' => '/explore'], - ]), - 'page_title' => 'Explore Artworks — Skinbase', + return view('gallery.index', [ + 'gallery_type' => 'browse', + 'mainCategories' => $mainCategories, + 'subcategories' => $mainCategories, + 'contentType' => null, + 'category' => null, + 'artworks' => $artworks, + 'spotlight' => $spotlightItems, + 'current_sort' => $sort, + 'sort_options' => self::SORT_OPTIONS, + 'hero_title' => 'Explore', + 'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.', + 'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/explore']]), + 'page_title' => 'Explore Artworks - Skinbase', 'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.', + 'page_meta_keywords' => 'explore, wallpapers, skins, photography, artworks, skinbase', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], @@ -117,6 +129,11 @@ final class ExploreController extends Controller // "artworks" is the umbrella — search all types $isAll = $type === 'artworks'; + // Canonical URLs for content types are /skins, /wallpapers, /photography, /other. + if (! $isAll) { + return redirect()->to($this->canonicalTypeUrl($request, $type), 301); + } + $sort = $this->resolveSort($request); $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); @@ -142,26 +159,44 @@ final class ExploreController extends Controller ? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a)) : collect(); - $contentTypes = $this->contentTypeLinks(); + $mainCategories = $this->mainCategories(); + $contentType = null; + $subcategories = $mainCategories; + if (! $isAll) { + $contentType = ContentType::where('slug', $type)->first(); + $subcategories = $contentType + ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() + : collect(); + } + + if ($isAll) { + $humanType = 'Artworks'; + } else { + $humanType = $contentType?->name ?? ucfirst($type); + } + $baseUrl = url('/explore/' . $type); $seo = $this->paginationSeo($request, $baseUrl, $artworks); - $humanType = ucfirst($type); - return view('web.explore.index', [ - 'artworks' => $artworks, - 'spotlight' => $spotlightItems, - 'contentTypes' => $contentTypes, - 'activeType' => $type, - 'current_sort' => $sort, - 'sort_options' => self::SORT_OPTIONS, - 'hero_title' => $humanType, - 'hero_description' => "Browse {$humanType} on Skinbase.", - 'breadcrumbs' => collect([ + return view('gallery.index', [ + 'gallery_type' => $isAll ? 'browse' : 'content-type', + 'mainCategories' => $mainCategories, + 'subcategories' => $subcategories, + 'contentType' => $contentType, + 'category' => null, + 'artworks' => $artworks, + 'spotlight' => $spotlightItems, + 'current_sort' => $sort, + 'sort_options' => self::SORT_OPTIONS, + 'hero_title' => $humanType, + 'hero_description' => "Browse {$humanType} on Skinbase.", + 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => $humanType, 'url' => "/explore/{$type}"], ]), - 'page_title' => "{$humanType} — Explore — Skinbase", + 'page_title' => "{$humanType} - Explore - Skinbase", 'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.", + 'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography', 'page_canonical' => $seo['canonical'], 'page_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], @@ -173,6 +208,14 @@ final class ExploreController extends Controller public function byTypeMode(Request $request, string $type, string $mode) { + $type = strtolower($type); + if ($type !== 'artworks') { + $query = $request->query(); + $query['sort'] = $this->normalizeSort((string) $mode); + + return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301); + } + // Rewrite the sort via the URL segment and delegate $request->query->set('sort', $mode); return $this->byType($request, $type); @@ -180,24 +223,49 @@ final class ExploreController extends Controller // ── Helpers ────────────────────────────────────────────────────────── - private function contentTypeLinks(): Collection + private function mainCategories(): Collection { - return collect([ - (object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'], - ...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [ + $categories = ContentType::orderBy('id') + ->get(['name', 'slug']) + ->map(fn ($ct) => (object) [ 'name' => $ct->name, 'slug' => $ct->slug, - 'url' => '/explore/' . strtolower($ct->slug), - ]), + 'url' => '/' . strtolower($ct->slug), + ]); + + return $categories->push((object) [ + 'name' => 'Members', + 'slug' => 'members', + 'url' => '/members', ]); } private function resolveSort(Request $request): string { - $s = (string) $request->query('sort', 'trending'); + $s = $this->normalizeSort((string) $request->query('sort', 'trending')); return array_key_exists($s, self::SORT_MAP) ? $s : 'trending'; } + private function normalizeSort(string $sort): string + { + $sort = strtolower($sort); + return self::SORT_ALIASES[$sort] ?? $sort; + } + + private function canonicalTypeUrl(Request $request, string $type, ?array $query = null): string + { + $query = $query ?? $request->query(); + + if (isset($query['sort'])) { + $query['sort'] = $this->normalizeSort((string) $query['sort']); + if ($query['sort'] === 'trending') { + unset($query['sort']); + } + } + + return url('/' . $type) . ($query ? ('?' . http_build_query($query)) : ''); + } + private function resolvePerPage(Request $request): int { $v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24); diff --git a/app/Http/Controllers/Web/SearchController.php b/app/Http/Controllers/Web/SearchController.php index 3c1ddd68..1b6d39f4 100644 --- a/app/Http/Controllers/Web/SearchController.php +++ b/app/Http/Controllers/Web/SearchController.php @@ -25,22 +25,16 @@ final class SearchController extends Controller 'downloads' => 'downloads:desc', ]; - $artworks = null; - $popular = collect(); - - if ($q !== '') { - $artworks = $this->search->search($q, [ + $artworks = $q !== '' + ? $this->search->search($q, [ 'sort' => ($sortMap[$sort] ?? 'created_at:desc'), - ]); - } else { - $popular = $this->search->popular(16)->getCollection(); - } + ]) + : $this->search->popular(24); return view('search.index', [ 'q' => $q, 'sort' => $sort, - 'artworks' => $artworks ?? collect()->paginate(0), - 'popular' => $popular, + 'artworks' => $artworks, 'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase', 'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.', 'page_robots' => 'noindex,follow', diff --git a/app/Http/Middleware/ConditionalCors.php b/app/Http/Middleware/ConditionalCors.php new file mode 100644 index 00000000..e3265b86 --- /dev/null +++ b/app/Http/Middleware/ConditionalCors.php @@ -0,0 +1,34 @@ +handle($request, $next); + } +} diff --git a/app/Http/Middleware/EnsureCreatorAccess.php b/app/Http/Middleware/EnsureCreatorAccess.php new file mode 100644 index 00000000..dd72c6d1 --- /dev/null +++ b/app/Http/Middleware/EnsureCreatorAccess.php @@ -0,0 +1,29 @@ +user(); + if ($user === null) { + abort(403, 'Authentication required.'); + } + + $role = strtolower((string) ($user->role ?? 'user')); + $isCreatorRole = in_array($role, ['creator', 'user', 'admin', 'moderator', 'mod'], true); + + if (! $isCreatorRole || (property_exists($user, 'is_active') && $user->is_active === false)) { + abort(403, 'Creator access is required.'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/AvatarUploadRequest.php b/app/Http/Requests/AvatarUploadRequest.php index cbc1e2c3..ccdcbe4d 100644 --- a/app/Http/Requests/AvatarUploadRequest.php +++ b/app/Http/Requests/AvatarUploadRequest.php @@ -22,6 +22,7 @@ class AvatarUploadRequest extends FormRequest 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', ], + 'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'], ]; } } diff --git a/app/Http/Requests/Settings/RequestEmailChangeRequest.php b/app/Http/Requests/Settings/RequestEmailChangeRequest.php new file mode 100644 index 00000000..e091d510 --- /dev/null +++ b/app/Http/Requests/Settings/RequestEmailChangeRequest.php @@ -0,0 +1,45 @@ +has('new_email')) { + $this->merge([ + 'new_email' => strtolower(trim((string) $this->input('new_email'))), + ]); + } + } + + public function rules(): array + { + return [ + 'new_email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class, 'email')->ignore((int) $this->user()->id), + function (string $attribute, mixed $value, \Closure $fail): void { + if (strtolower((string) $value) === strtolower((string) $this->user()->email)) { + $fail('Please enter a different email address.'); + } + }, + ], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateAccountSectionRequest.php b/app/Http/Requests/Settings/UpdateAccountSectionRequest.php new file mode 100644 index 00000000..d778d52e --- /dev/null +++ b/app/Http/Requests/Settings/UpdateAccountSectionRequest.php @@ -0,0 +1,32 @@ +has('username')) { + $this->merge([ + 'username' => \App\Support\UsernamePolicy::normalize((string) $this->input('username')), + ]); + } + } + + public function rules(): array + { + return [ + 'username' => ['required', ...UsernameRequest::rulesFor((int) $this->user()->id)], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateNotificationsSectionRequest.php b/app/Http/Requests/Settings/UpdateNotificationsSectionRequest.php new file mode 100644 index 00000000..3bd3ef3e --- /dev/null +++ b/app/Http/Requests/Settings/UpdateNotificationsSectionRequest.php @@ -0,0 +1,26 @@ + ['required', 'boolean'], + 'upload_notifications' => ['required', 'boolean'], + 'follower_notifications' => ['required', 'boolean'], + 'comment_notifications' => ['required', 'boolean'], + 'newsletter' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php b/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php new file mode 100644 index 00000000..0929421e --- /dev/null +++ b/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php @@ -0,0 +1,24 @@ + ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'in:m,f,x,M,F,X'], + 'country' => ['nullable', 'string', 'max:10'], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateProfileSectionRequest.php b/app/Http/Requests/Settings/UpdateProfileSectionRequest.php new file mode 100644 index 00000000..2b684dd2 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateProfileSectionRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:60'], + 'website' => ['nullable', 'url', 'max:255'], + 'bio' => ['nullable', 'string', 'max:200'], + 'signature' => ['nullable', 'string', 'max:1000'], + 'description' => ['nullable', 'string', 'max:1000'], + 'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'], + 'remove_avatar' => ['nullable', 'boolean'], + 'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php b/app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php new file mode 100644 index 00000000..14dc6e37 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php @@ -0,0 +1,24 @@ + ['required', 'current_password'], + 'new_password' => ['required', 'confirmed', Password::min(8)], + ]; + } +} diff --git a/app/Http/Requests/Settings/VerifyEmailChangeRequest.php b/app/Http/Requests/Settings/VerifyEmailChangeRequest.php new file mode 100644 index 00000000..274986a9 --- /dev/null +++ b/app/Http/Requests/Settings/VerifyEmailChangeRequest.php @@ -0,0 +1,31 @@ +has('code')) { + $this->merge([ + 'code' => preg_replace('/\D+/', '', (string) $this->input('code')), + ]); + } + } + + public function rules(): array + { + return [ + 'code' => ['required', 'digits:6'], + ]; + } +} diff --git a/app/Mail/EmailChangeVerificationCodeMail.php b/app/Mail/EmailChangeVerificationCodeMail.php new file mode 100644 index 00000000..8fc71499 --- /dev/null +++ b/app/Mail/EmailChangeVerificationCodeMail.php @@ -0,0 +1,49 @@ +onQueue('mail'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Skinbase email change verification code', + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.email-change-verification-code', + with: [ + 'code' => $this->code, + 'expiresInMinutes' => $this->expiresInMinutes, + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/EmailChangedSecurityAlertMail.php b/app/Mail/EmailChangedSecurityAlertMail.php new file mode 100644 index 00000000..74940e54 --- /dev/null +++ b/app/Mail/EmailChangedSecurityAlertMail.php @@ -0,0 +1,47 @@ +onQueue('mail'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Your Skinbase email address was changed', + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.email-changed-security-alert', + with: [ + 'newEmail' => $this->newEmail, + 'supportEmail' => (string) config('mail.from.address', 'support@skinbase.org'), + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/ArtworkDownload.php b/app/Models/ArtworkDownload.php index c148d12b..0642ea49 100644 --- a/app/Models/ArtworkDownload.php +++ b/app/Models/ArtworkDownload.php @@ -1,4 +1,7 @@ 'boolean', 'edited_at' => 'datetime', + 'spam_score' => 'integer', + 'quality_score' => 'integer', + 'flagged' => 'boolean', + 'moderation_checked' => 'boolean', ]; public function thread(): BelongsTo diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 00000000..216df4cb --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,42 @@ + 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function markAsRead(): void + { + if ($this->read_at === null) { + $this->forceFill(['read_at' => now()])->save(); + } + } +} diff --git a/app/Models/Story.php b/app/Models/Story.php index eaf26370..be94d674 100644 --- a/app/Models/Story.php +++ b/app/Models/Story.php @@ -4,11 +4,16 @@ declare(strict_types=1); namespace App\Models; +use App\Models\StoryLike; +use App\Models\StoryView; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Model; /** - * Story — editorial content replacing the legacy Interviews module. + * Creator Story model. * * @property int $id * @property string $slug @@ -16,10 +21,10 @@ use Illuminate\Database\Eloquent\Model; * @property string|null $excerpt * @property string|null $content * @property string|null $cover_image - * @property int|null $author_id + * @property int|null $creator_id * @property int $views * @property bool $featured - * @property string $status draft|published + * @property string $status draft|pending_review|published|scheduled|archived|rejected * @property \Carbon\Carbon|null $published_at * @property int|null $legacy_interview_id */ @@ -35,38 +40,81 @@ class Story extends Model 'excerpt', 'content', 'cover_image', - 'author_id', + 'creator_id', + 'story_type', + 'reading_time', 'views', + 'likes_count', + 'comments_count', 'featured', 'status', 'published_at', + 'scheduled_for', + 'meta_title', + 'meta_description', + 'canonical_url', + 'og_image', + 'submitted_for_review_at', + 'reviewed_at', + 'reviewed_by_id', + 'rejected_reason', 'legacy_interview_id', ]; protected $casts = [ 'featured' => 'boolean', 'published_at' => 'datetime', + 'scheduled_for' => 'datetime', + 'submitted_for_review_at' => 'datetime', + 'reviewed_at' => 'datetime', 'views' => 'integer', + 'likes_count' => 'integer', + 'comments_count' => 'integer', + 'reading_time' => 'integer', ]; // ── Relations ──────────────────────────────────────────────────────── - public function author() + public function creator(): BelongsTo { - return $this->belongsTo(StoryAuthor::class, 'author_id'); + return $this->belongsTo(User::class, 'creator_id'); } - public function tags() + // Legacy alias used by older views/controllers. + public function author(): BelongsTo { - return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id'); + return $this->creator(); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(StoryTag::class, 'relation_story_tags', 'story_id', 'tag_id'); + } + + public function storyViews(): HasMany + { + return $this->hasMany(StoryView::class, 'story_id'); + } + + public function storyLikes(): HasMany + { + return $this->hasMany(StoryLike::class, 'story_id'); } // ── Scopes ─────────────────────────────────────────────────────────── public function scopePublished($query) { - return $query->where('status', 'published') - ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); + return $query + ->where(function ($q): void { + $q->where('status', 'published') + ->orWhere(function ($scheduled): void { + $scheduled->where('status', 'scheduled') + ->whereNotNull('published_at') + ->where('published_at', '<=', now()); + }); + }) + ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); } public function scopeFeatured($query) @@ -95,6 +143,10 @@ class Story extends Model */ public function getReadingTimeAttribute(): int { + if (! empty($this->attributes['reading_time'])) { + return max(1, (int) $this->attributes['reading_time']); + } + $wordCount = str_word_count(strip_tags((string) $this->content)); return max(1, (int) ceil($wordCount / 200)); diff --git a/app/Models/StoryLike.php b/app/Models/StoryLike.php new file mode 100644 index 00000000..47fbd0d8 --- /dev/null +++ b/app/Models/StoryLike.php @@ -0,0 +1,32 @@ +belongsTo(Story::class, 'story_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/StoryTag.php b/app/Models/StoryTag.php index 352bdf0d..a5413f1e 100644 --- a/app/Models/StoryTag.php +++ b/app/Models/StoryTag.php @@ -5,10 +5,11 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Model; /** - * Story Tag — editorial tag for the Stories system. + * Story tag entity for creator stories. * * @property int $id * @property string $slug @@ -18,7 +19,7 @@ class StoryTag extends Model { use HasFactory; - protected $table = 'stories_tags'; + protected $table = 'story_tags'; protected $fillable = [ 'slug', @@ -27,9 +28,9 @@ class StoryTag extends Model // ── Relations ──────────────────────────────────────────────────────── - public function stories() + public function stories(): BelongsToMany { - return $this->belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id'); + return $this->belongsToMany(Story::class, 'relation_story_tags', 'tag_id', 'story_id'); } // ── Accessors ──────────────────────────────────────────────────────── diff --git a/app/Models/StoryView.php b/app/Models/StoryView.php new file mode 100644 index 00000000..6455a957 --- /dev/null +++ b/app/Models/StoryView.php @@ -0,0 +1,33 @@ +belongsTo(Story::class, 'story_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 03509ecd..a4ce12dd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Models\SocialAccount; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Message; +use App\Models\Notification; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -30,6 +31,7 @@ class User extends Authenticatable protected $fillable = [ 'username', 'username_changed_at', + 'last_username_change_at', 'onboarding_step', 'name', 'email', @@ -38,6 +40,10 @@ class User extends Authenticatable 'verification_send_window_started_at', 'is_active', 'needs_password_reset', + 'cover_hash', + 'cover_ext', + 'cover_position', + 'trust_score', 'password', 'role', 'allow_messages_from', @@ -66,7 +72,10 @@ class User extends Authenticatable 'verification_send_window_started_at' => 'datetime', 'verification_send_count_24h' => 'integer', 'username_changed_at' => 'datetime', + 'last_username_change_at' => 'datetime', 'deleted_at' => 'datetime', + 'cover_position' => 'integer', + 'trust_score' => 'integer', 'password' => 'hashed', 'allow_messages_from' => 'string', ]; @@ -139,6 +148,19 @@ class User extends Authenticatable return $this->hasMany(Message::class, 'sender_id'); } + /** + * Skinbase notifications are keyed by user_id (non-polymorphic table). + */ + public function notifications(): HasMany + { + return $this->hasMany(Notification::class, 'user_id')->latest(); + } + + public function unreadNotifications(): HasMany + { + return $this->notifications()->whereNull('read_at'); + } + /** * Check if this user allows receiving messages from the given user. */ @@ -221,6 +243,11 @@ class User extends Authenticatable return $this->hasMany(Post::class)->orderByDesc('created_at'); } + public function stories(): HasMany + { + return $this->hasMany(Story::class, 'creator_id')->orderByDesc('published_at'); + } + // ─── Meilisearch ────────────────────────────────────────────────────────── /** diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php index 9b0fe0a4..5c0915f4 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -30,12 +30,22 @@ class UserProfile extends Model 'gender', 'website', 'auto_post_upload', + 'email_notifications', + 'upload_notifications', + 'follower_notifications', + 'comment_notifications', + 'newsletter', ]; protected $casts = [ 'birthdate' => 'date', 'avatar_updated_at'=> 'datetime', 'auto_post_upload' => 'boolean', + 'email_notifications' => 'boolean', + 'upload_notifications' => 'boolean', + 'follower_notifications' => 'boolean', + 'comment_notifications' => 'boolean', + 'newsletter' => 'boolean', ]; public $timestamps = true; diff --git a/app/Notifications/StoryStatusNotification.php b/app/Notifications/StoryStatusNotification.php new file mode 100644 index 00000000..30b08965 --- /dev/null +++ b/app/Notifications/StoryStatusNotification.php @@ -0,0 +1,47 @@ +event) { + 'approved' => 'Your story "' . $this->story->title . '" was approved and published.', + 'rejected' => 'Your story "' . $this->story->title . '" was rejected. Update it and resubmit for review.', + 'published' => 'Your story "' . $this->story->title . '" is now published.', + default => 'Story update: "' . $this->story->title . '" status changed.', + }; + + return [ + 'type' => 'story.' . $this->event, + 'story_id' => $this->story->id, + 'title' => $this->story->title, + 'slug' => $this->story->slug, + 'status' => $this->story->status, + 'reason' => $this->reason, + 'message' => $message, + 'url' => route('creator.stories.edit', ['story' => $this->story->id]), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e24dda16..b13002f7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -63,6 +63,8 @@ class AppServiceProvider extends ServiceProvider $this->configureAuthRateLimiters(); $this->configureUploadRateLimiters(); $this->configureMessagingRateLimiters(); + $this->configureDownloadRateLimiter(); + $this->configureSettingsRateLimiters(); $this->configureMailFailureLogging(); ArtworkAward::observe(ArtworkAwardObserver::class); @@ -143,6 +145,20 @@ class AppServiceProvider extends ServiceProvider $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName')); }); + + // Replace the framework HandleCors with our ConditionalCors so the + // CP_ENABLE_CORS / config('cors.paths') toggle takes effect. + try { + $middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class); + $middlewareConfig->replace( + \Illuminate\Http\Middleware\HandleCors::class, + \App\Http\Middleware\ConditionalCors::class + ); + } catch (\Throwable $_) { + // Fallback: push to kernel if replace isn't available in this app instance + $this->app->make(\Illuminate\Contracts\Http\Kernel::class) + ->pushMiddleware(\App\Http\Middleware\ConditionalCors::class); + } } private function configureAuthRateLimiters(): void @@ -244,4 +260,40 @@ class AppServiceProvider extends ServiceProvider ]; }); } + + private function configureDownloadRateLimiter(): void + { + RateLimiter::for('downloads', function (Request $request): array { + $userId = $request->user()?->id; + + // Higher user-based allowance prevents false positives for active users, + // while IP limit still protects guest endpoints from bursts. + return [ + Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')), + Limit::perMinute(120)->by('downloads:ip:' . $request->ip()), + ]; + }); + } + + private function configureSettingsRateLimiters(): void + { + RateLimiter::for('username-check', function (Request $request): Limit { + $key = 'username-check:ip:' . $request->ip(); + + if (method_exists(Limit::class, 'perSecond')) { + return Limit::perSecond(5)->by($key); + } + + return Limit::perMinute(300)->by($key); + }); + + RateLimiter::for('email-change-request', function (Request $request): Limit { + $userId = $request->user()?->id; + $key = $userId !== null + ? 'email-change-request:user:' . $userId + : 'email-change-request:ip:' . $request->ip(); + + return Limit::perHour(1)->by($key); + }); + } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 13b66433..45cf6572 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -30,5 +30,9 @@ class AuthServiceProvider extends ServiceProvider public function boot(): void { $this->registerPolicies(); + + Gate::define('moderate-forum', static function ($user): bool { + return method_exists($user, 'isAdmin') && ($user->isAdmin() || $user->isModerator()); + }); } } diff --git a/app/Services/AvatarService.php b/app/Services/AvatarService.php index 3dcc1610..09d8bedc 100644 --- a/app/Services/AvatarService.php +++ b/app/Services/AvatarService.php @@ -18,6 +18,18 @@ class AvatarService private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + private const ALLOWED_POSITIONS = [ + 'top-left', + 'top', + 'top-right', + 'left', + 'center', + 'right', + 'bottom-left', + 'bottom', + 'bottom-right', + ]; + protected $sizes = [ 'xs' => 32, 'sm' => 64, @@ -50,13 +62,13 @@ class AvatarService } } - public function storeFromUploadedFile(int $userId, UploadedFile $file): string + public function storeFromUploadedFile(int $userId, UploadedFile $file, string $position = 'center'): string { $this->assertImageManagerAvailable(); $this->assertStorageIsAllowed(); $binary = $this->assertSecureImageUpload($file); - return $this->storeFromBinary($userId, $binary); + return $this->storeFromBinary($userId, $binary, $position); } public function storeFromLegacyFile(int $userId, string $path): ?string @@ -76,10 +88,26 @@ class AvatarService return $this->storeFromBinary($userId, $binary); } - private function storeFromBinary(int $userId, string $binary): string + public function removeAvatar(int $userId): void + { + $diskName = (string) config('avatars.disk', 's3'); + Storage::disk($diskName)->deleteDirectory("avatars/{$userId}"); + + UserProfile::query()->updateOrCreate( + ['user_id' => $userId], + [ + 'avatar_hash' => null, + 'avatar_mime' => null, + 'avatar_updated_at' => Carbon::now(), + ] + ); + } + + private function storeFromBinary(int $userId, string $binary, string $position = 'center'): string { $image = $this->readImageFromBinary($binary); $image = $this->normalizeImage($image); + $cropPosition = $this->normalizePosition($position); $diskName = (string) config('avatars.disk', 's3'); $disk = Storage::disk($diskName); @@ -87,7 +115,7 @@ class AvatarService $hashSeed = ''; foreach ($this->sizes as $size) { - $variant = $image->cover($size, $size); + $variant = $image->cover($size, $size, $cropPosition); $encoded = (string) $variant->encode(new WebpEncoder($this->quality)); $disk->put("{$basePath}/{$size}.webp", $encoded, [ 'visibility' => 'public', @@ -110,6 +138,17 @@ class AvatarService return $hash; } + private function normalizePosition(string $position): string + { + $normalized = strtolower(trim($position)); + + if (in_array($normalized, self::ALLOWED_POSITIONS, true)) { + return $normalized; + } + + return 'center'; + } + private function normalizeImage($image) { try { diff --git a/app/Services/ThumbnailService.php b/app/Services/ThumbnailService.php index 31ac50a9..a226f9cc 100644 --- a/app/Services/ThumbnailService.php +++ b/app/Services/ThumbnailService.php @@ -18,21 +18,22 @@ class ThumbnailService } /** - * Canonical size keys (upload-agent spec §8): thumb · sq · md · lg · xl - * 'sm' is kept as a backwards-compatible alias for 'thumb'. + * Canonical size keys: xs · sm · md · lg · xl (+ legacy thumb/sq support). */ - protected const VALID_SIZES = ['thumb', 'sq', 'sm', 'md', 'lg', 'xl']; + protected const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'thumb', 'sq']; - /** Size aliases: legacy 'sm' maps to the 'thumb' CDN directory. */ - protected const SIZE_ALIAS = ['sm' => 'thumb']; + /** Size aliases for backwards compatibility with old callers. */ + protected const SIZE_ALIAS = []; protected const THUMB_SIZES = [ - 'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], + 'xs' => ['height' => 160, 'quality' => 74, 'dir' => 'xs'], + 'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'sm'], 'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true], - 'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb 'md' => ['height' => 1024, 'quality' => 82, 'dir' => 'md'], 'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'], 'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'], + // Legacy compatibility for older paths still expecting /thumb/. + 'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], ]; /** diff --git a/app/Services/Uploads/UploadPipelineService.php b/app/Services/Uploads/UploadPipelineService.php index 5ef1844e..0ba28a82 100644 --- a/app/Services/Uploads/UploadPipelineService.php +++ b/app/Services/Uploads/UploadPipelineService.php @@ -129,8 +129,25 @@ final class UploadPipelineService $height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1; $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); + $downloadFileName = $origFilename; + + if (is_string($originalFileName) && trim($originalFileName) !== '') { + $candidate = basename(str_replace('\\', '/', $originalFileName)); + $candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? ''; + $candidate = trim((string) $candidate); + + if ($candidate !== '') { + $candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION)); + if ($candidateExt === '' && $origExt !== '') { + $candidate .= '.' . $origExt; + } + + $downloadFileName = $candidate; + } + } + Artwork::query()->whereKey($artworkId)->update([ - 'file_name' => $origFilename, + 'file_name' => $downloadFileName, 'file_path' => '', 'file_size' => (int) filesize($originalPath), 'mime_type' => $origMime, diff --git a/app/Support/CoverUrl.php b/app/Support/CoverUrl.php new file mode 100644 index 00000000..e074d236 --- /dev/null +++ b/app/Support/CoverUrl.php @@ -0,0 +1,62 @@ +