diff --git a/.env.example b/.env.example index 46e5e1c210..ad37098eb0 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,7 @@ MAIL_FROM_NAME="${APP_NAME}" # Feature Flags FEATURE_GAME_SCREENSHOT_UPLOADS=true +SCREENSHOT_MAX_PENDING_SUBMISSIONS_PER_USER=200 # Providers diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 390376104a..9911f607c9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -120,6 +120,9 @@ public function share(Request $request): array 'services' => [ 'patreon' => ['userId' => config('services.patreon.user_id')], ], + 'screenshots' => [ + 'maxPendingSubmissions' => max(1, (int) config('screenshots.max_pending_submissions_per_user')), + ], ], 'metaKey' => $metaKey, diff --git a/app/Platform/Actions/SubmitPendingGameScreenshotAction.php b/app/Platform/Actions/SubmitPendingGameScreenshotAction.php index ce4647c4ca..fe689a9ad8 100644 --- a/app/Platform/Actions/SubmitPendingGameScreenshotAction.php +++ b/app/Platform/Actions/SubmitPendingGameScreenshotAction.php @@ -29,23 +29,24 @@ public function execute( ScreenshotType $type, User $user, ): GameScreenshot { - $this->validationService->validateFile($file, $game); - - [$width, $height] = getimagesize($file->getRealPath()); - $this->validationService->validateResolution($width, $height, $game); - $hash = $this->validationService->validateHash($file, $game); - - // Enforce a global pending cap to prevent queue abuse. $pendingCount = GameScreenshot::where('captured_by_user_id', $user->id) ->where('status', GameScreenshotStatus::Pending) ->count(); - if ($pendingCount >= 200) { + $maxPendingSubmissions = max(1, (int) config('screenshots.max_pending_submissions_per_user')); + + if ($pendingCount >= $maxPendingSubmissions) { throw ValidationException::withMessages([ 'screenshot' => 'You have reached the maximum number of pending submissions.', ]); } + $this->validationService->validateFile($file, $game); + + [$width, $height] = getimagesize($file->getRealPath()); + $this->validationService->validateResolution($width, $height, $game); + $hash = $this->validationService->validateHash($file, $game); + // Add to the pending collection so no conversions are generated yet. // Conversions are triggered later if a reviewer approves the screenshot. $media = $game diff --git a/app/Policies/GameScreenshotPolicy.php b/app/Policies/GameScreenshotPolicy.php index aae10f1ff0..324ed66aab 100644 --- a/app/Policies/GameScreenshotPolicy.php +++ b/app/Policies/GameScreenshotPolicy.php @@ -50,6 +50,10 @@ public function create(User $user, Game $game): bool return false; } + if (!$user->hasRole(Role::ROOT) && !$user->enable_beta_features) { + return false; + } + if ($game->is_media_restricted) { return false; } diff --git a/config/screenshots.php b/config/screenshots.php new file mode 100644 index 0000000000..d1ec362c3b --- /dev/null +++ b/config/screenshots.php @@ -0,0 +1,7 @@ + (int) env('SCREENSHOT_MAX_PENDING_SUBMISSIONS_PER_USER', 200), +]; diff --git a/resources/js/common/components/SEO/SEO.test.tsx b/resources/js/common/components/SEO/SEO.test.tsx index beb745ee4c..a967853e87 100644 --- a/resources/js/common/components/SEO/SEO.test.tsx +++ b/resources/js/common/components/SEO/SEO.test.tsx @@ -9,6 +9,7 @@ describe('Component: SEO', () => { const defaultPageProps = { config: { app: { url: 'https://example.com' }, + screenshots: { maxPendingSubmissions: 200 }, services: { patreon: { userId: undefined } }, }, }; diff --git a/resources/js/common/models/app-global-props.model.ts b/resources/js/common/models/app-global-props.model.ts index 0ab3b67d41..78958d84e8 100644 --- a/resources/js/common/models/app-global-props.model.ts +++ b/resources/js/common/models/app-global-props.model.ts @@ -27,6 +27,9 @@ export interface AppGlobalProps extends PageProps { app: { url: string; }; + screenshots: { + maxPendingSubmissions: number; + }; services: { patreon: { userId?: string | number }; }; @@ -70,6 +73,7 @@ export const createAppGlobalProps = createFactory(() => ({ app: { url: 'https://retroachievements.org', }, + screenshots: { maxPendingSubmissions: 200 }, services: { patreon: {} }, }, diff --git a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx index 13215317d9..01b99d5c50 100644 --- a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx +++ b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx @@ -10,6 +10,12 @@ import { GameScreenshotUploadDialog } from './GameScreenshotUploadDialog'; // Suppress AggregateError invocations from unmocked fetch calls to the back-end. console.error = vi.fn(); +const baseConfig = { + app: { url: 'https://retroachievements.org' }, + screenshots: { maxPendingSubmissions: 200 }, + services: { patreon: {} }, +}; + describe('Component: GameScreenshotUploadDialog', () => { beforeEach(() => { vi.stubGlobal( @@ -41,6 +47,7 @@ describe('Component: GameScreenshotUploadDialog', () => { , { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -58,6 +65,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -78,6 +86,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -94,6 +103,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -113,6 +123,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: { title: { count: 1, hasResolutionIssues: false } }, @@ -130,6 +141,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -150,6 +162,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -166,6 +179,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -182,6 +196,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -202,6 +217,7 @@ describe('Component: GameScreenshotUploadDialog', () => { render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -229,6 +245,7 @@ describe('Component: GameScreenshotUploadDialog', () => { // ARRANGE render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [{ width: 320, height: 240 }] }), }), @@ -261,6 +278,7 @@ describe('Component: GameScreenshotUploadDialog', () => { render(, { pageProps: { + config: baseConfig, game: createGame({ id: 10, system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, @@ -291,6 +309,7 @@ describe('Component: GameScreenshotUploadDialog', () => { render(, { pageProps: { + config: baseConfig, game: createGame({ system: createSystem({ screenshotResolutions: [] }) }), screenshotUploadConsistency: null, screenshotUploadStatuses: {}, diff --git a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.tsx b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.tsx index 4f70625ed3..c4a831c27f 100644 --- a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.tsx +++ b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.tsx @@ -16,7 +16,6 @@ import { cn } from '@/common/utils/cn'; import { useDeleteGameScreenshotMutation } from '../../hooks/mutations/useDeleteGameScreenshotMutation'; import { ScreenshotSlotConfig } from '../../models'; -import { screenshotSubmissionLimits } from '../../utils/screenshotSubmissionLimits'; import { PendingSubmissionsList } from '../PendingSubmissionsList'; import { ScreenshotSlotStatusIndicator } from '../ScreenshotSlotStatusIndicator'; import { UploadForm } from '../UploadForm'; @@ -37,6 +36,7 @@ export const GameScreenshotUploadDialog: FC = ( onOpenChange, }) => { const { + config, game, screenshotUploadConsistency, screenshotUploadStatuses, @@ -49,6 +49,9 @@ export const GameScreenshotUploadDialog: FC = ( const [submissions, setSubmissions] = useState(screenshotUploadUserSubmissions ?? []); const [currentPendingCount, setCurrentPendingCount] = useState(screenshotUploadPendingCount ?? 0); + const maxPendingSubmissions = config.screenshots.maxPendingSubmissions; + const pendingWarningThreshold = Math.max(1, Math.floor(maxPendingSubmissions * 0.75)); + const deleteMutation = useDeleteGameScreenshotMutation(); const handleCancel = (screenshotId: number) => { @@ -72,8 +75,7 @@ export const GameScreenshotUploadDialog: FC = ( setSubmissions((prev) => [screenshot, ...prev]); }; - const showPendingWarning = - currentPendingCount >= screenshotSubmissionLimits.pendingWarningThreshold; + const showPendingWarning = currentPendingCount >= pendingWarningThreshold; const statuses = screenshotUploadStatuses ?? {}; return ( @@ -89,7 +91,7 @@ export const GameScreenshotUploadDialog: FC = (

{t('You have {{count}} of {{max}} pending submissions.', { count: currentPendingCount, - max: screenshotSubmissionLimits.maxPendingSubmissions, + max: maxPendingSubmissions, })}

) : null} diff --git a/resources/js/features/games/components/GameSidebarFullWidthButtons/GameSidebarFullWidthButtons.test.tsx b/resources/js/features/games/components/GameSidebarFullWidthButtons/GameSidebarFullWidthButtons.test.tsx index d12dfb7bdf..b9de77e21f 100644 --- a/resources/js/features/games/components/GameSidebarFullWidthButtons/GameSidebarFullWidthButtons.test.tsx +++ b/resources/js/features/games/components/GameSidebarFullWidthButtons/GameSidebarFullWidthButtons.test.tsx @@ -104,6 +104,11 @@ describe('Component: GameSidebarFullWidthButtons', () => { auth: { user: createAuthenticatedUser({ roles: [] }) }, backingGame: createGame(), can: { createGameScreenshot: true }, + config: { + app: { url: 'https://retroachievements.org' }, + screenshots: { maxPendingSubmissions: 200 }, + services: { patreon: {} }, + }, game, ziggy: createZiggyProps({ device: 'desktop' }), }, diff --git a/resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarContributeLinks.test.tsx b/resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarContributeLinks.test.tsx index 9199feb67d..bb027eb9ce 100644 --- a/resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarContributeLinks.test.tsx +++ b/resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarContributeLinks.test.tsx @@ -194,6 +194,11 @@ describe('Component: SidebarContributeLinks', () => { auth: { user: createAuthenticatedUser({ roles: ['developer'] }) }, backingGame: createGame(), can: {}, + config: { + app: { url: 'https://retroachievements.org' }, + screenshots: { maxPendingSubmissions: 200 }, + services: { patreon: {} }, + }, game: createGame({ gameAchievementSets: [] }), isOnWantToDevList: false, isSubscribedToAchievementComments: false, diff --git a/resources/js/features/games/utils/screenshotSubmissionLimits.ts b/resources/js/features/games/utils/screenshotSubmissionLimits.ts deleted file mode 100644 index 2bdcd5decc..0000000000 --- a/resources/js/features/games/utils/screenshotSubmissionLimits.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const screenshotSubmissionLimits = { - maxPendingSubmissions: 200, - pendingWarningThreshold: 150, -}; diff --git a/resources/js/features/home/components/+sidebar/TopLinks/TopLinks.test.tsx b/resources/js/features/home/components/+sidebar/TopLinks/TopLinks.test.tsx index eee14b70a3..0574c70580 100644 --- a/resources/js/features/home/components/+sidebar/TopLinks/TopLinks.test.tsx +++ b/resources/js/features/home/components/+sidebar/TopLinks/TopLinks.test.tsx @@ -60,8 +60,9 @@ describe('Component: TopLinks', () => { render(, { pageProps: { config: { - services: { patreon: { userId: 5407777 } }, app: { url: 'https://retroachievements.org' }, + screenshots: { maxPendingSubmissions: 200 }, + services: { patreon: { userId: 5407777 } }, }, }, }); @@ -77,7 +78,11 @@ describe('Component: TopLinks', () => { // ARRANGE render(, { pageProps: { - config: { services: { patreon: {} }, app: { url: 'https://retroachievements.org' } }, + config: { + app: { url: 'https://retroachievements.org' }, + screenshots: { maxPendingSubmissions: 200 }, + services: { patreon: {} }, + }, }, }); diff --git a/tests/Feature/Platform/Actions/SubmitPendingGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/SubmitPendingGameScreenshotActionTest.php new file mode 100644 index 0000000000..44711d0eeb --- /dev/null +++ b/tests/Feature/Platform/Actions/SubmitPendingGameScreenshotActionTest.php @@ -0,0 +1,44 @@ +set('screenshots.max_pending_submissions_per_user', 3); + + $game = Game::factory()->create(['system_id' => System::factory()]); + $user = User::factory()->create(); + + GameScreenshot::factory()->count(3)->for($game)->pending()->create([ + 'captured_by_user_id' => $user->id, + ]); + + // ACT + $attempt = fn () => (new SubmitPendingGameScreenshotAction())->execute( + $game, + UploadedFile::fake()->image('next.png', 256, 224), + ScreenshotType::Ingame, + $user, + ); + + // ASSERT + expect($attempt)->toThrow(ValidationException::class); +}); diff --git a/tests/Feature/Platform/Controllers/GameControllerShowTest.php b/tests/Feature/Platform/Controllers/GameControllerShowTest.php index 88b33690f8..1d7e9d3e75 100644 --- a/tests/Feature/Platform/Controllers/GameControllerShowTest.php +++ b/tests/Feature/Platform/Controllers/GameControllerShowTest.php @@ -8,6 +8,7 @@ use App\Community\Enums\TicketState; use App\Community\Enums\UserGameListType; use App\Enums\GameHashCompatibility; +use App\Enums\UserPreference; use App\Models\Achievement; use App\Models\AchievementAuthor; use App\Models\AchievementGroup; @@ -3030,6 +3031,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3054,6 +3056,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(7), // !! + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3079,6 +3082,7 @@ function createLeaderboardWithEntries( 'points' => 0, // !! 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3103,6 +3107,32 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, + ]); + + // ACT + $response = actingAs($user)->get(route('game.show', ['game' => $game])); + + // ASSERT + $response->assertInertia(fn (Assert $page) => $page + ->where('can.createGameScreenshot', false) + ->missing('screenshotUploadStatuses') + ->missing('screenshotUploadPendingCount') + ->missing('screenshotUploadUserSubmissions') + ); + }); + + it('given an otherwise-eligible user has not opted into beta features, does not include screenshot upload props', function () { + // ARRANGE + config()->set('feature.game_screenshot_uploads', true); + + $system = System::factory()->create(); + $game = createGameWithAchievements($system, 'Test Game'); + $user = User::factory()->create([ + 'points_hardcore' => 250, + 'email_verified_at' => now(), + 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 0, // !! not set ]); // ACT @@ -3127,6 +3157,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); AchievementSetClaim::factory()->create([ @@ -3159,6 +3190,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); AchievementSetClaim::factory()->create([ @@ -3195,6 +3227,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3222,6 +3255,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3247,6 +3281,7 @@ function createLeaderboardWithEntries( 'email_verified_at' => now(), 'created_at' => now()->subDays(45), 'unranked_at' => now(), // !! + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3273,6 +3308,7 @@ function createLeaderboardWithEntries( 'email_verified_at' => now(), 'created_at' => now()->subDays(45), 'unranked_at' => now(), // !! + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); $user->assignRole($role); @@ -3302,6 +3338,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ... primary approved screenshots should be counted ... @@ -3339,6 +3376,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); GameScreenshot::factory()->for($game)->title()->primary()->create(['width' => 320, 'height' => 240]); @@ -3364,6 +3402,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ACT @@ -3386,6 +3425,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); GameScreenshot::factory()->for($game)->title()->primary()->create(['width' => 256, 'height' => 224]); @@ -3416,6 +3456,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); GameScreenshot::factory()->for($game)->title()->primary()->create(['width' => 256, 'height' => 224]); @@ -3444,6 +3485,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); GameScreenshot::factory()->for($game)->title()->primary()->create(['width' => 256, 'height' => 224]); @@ -3472,6 +3514,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); GameScreenshot::factory()->for($game)->title()->primary()->create(['width' => 320, 'height' => 240]); @@ -3498,6 +3541,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ... the user's pending screenshots on any game should be counted ... @@ -3530,6 +3574,7 @@ function createLeaderboardWithEntries( 'points_hardcore' => 250, 'email_verified_at' => now(), 'created_at' => now()->subDays(45), + 'preferences_bitfield' => 1 << UserPreference::User_EnableBetaFeatures, ]); // ... the user's pending screenshots on this game should be returned ...