Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ MAIL_FROM_NAME="${APP_NAME}"
# Feature Flags

FEATURE_GAME_SCREENSHOT_UPLOADS=true
SCREENSHOT_MAX_PENDING_SUBMISSIONS_PER_USER=200

# Providers

Expand Down
3 changes: 3 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 9 additions & 8 deletions app/Platform/Actions/SubmitPendingGameScreenshotAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/Policies/GameScreenshotPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions config/screenshots.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

return [
'max_pending_submissions_per_user' => (int) env('SCREENSHOT_MAX_PENDING_SUBMISSIONS_PER_USER', 200),
];
1 change: 1 addition & 0 deletions resources/js/common/components/SEO/SEO.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('Component: SEO', () => {
const defaultPageProps = {
config: {
app: { url: 'https://example.com' },
screenshots: { maxPendingSubmissions: 200 },
services: { patreon: { userId: undefined } },
},
};
Expand Down
4 changes: 4 additions & 0 deletions resources/js/common/models/app-global-props.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface AppGlobalProps extends PageProps {
app: {
url: string;
};
screenshots: {
maxPendingSubmissions: number;
};
services: {
patreon: { userId?: string | number };
};
Expand Down Expand Up @@ -70,6 +73,7 @@ export const createAppGlobalProps = createFactory<AppGlobalProps>(() => ({
app: {
url: 'https://retroachievements.org',
},
screenshots: { maxPendingSubmissions: 200 },
services: { patreon: {} },
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -41,6 +47,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />,
{
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -58,6 +65,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -78,6 +86,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={false} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -94,6 +103,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -113,6 +123,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: { title: { count: 1, hasResolutionIssues: false } },
Expand All @@ -130,6 +141,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -150,6 +162,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -166,6 +179,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -182,6 +196,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand All @@ -202,6 +217,7 @@ describe('Component: GameScreenshotUploadDialog', () => {

render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand Down Expand Up @@ -229,6 +245,7 @@ describe('Component: GameScreenshotUploadDialog', () => {
// ARRANGE
render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({
system: createSystem({ screenshotResolutions: [{ width: 320, height: 240 }] }),
}),
Expand Down Expand Up @@ -261,6 +278,7 @@ describe('Component: GameScreenshotUploadDialog', () => {

render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ id: 10, system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand Down Expand Up @@ -291,6 +309,7 @@ describe('Component: GameScreenshotUploadDialog', () => {

render(<GameScreenshotUploadDialog isOpen={true} onOpenChange={vi.fn()} />, {
pageProps: {
config: baseConfig,
game: createGame({ system: createSystem({ screenshotResolutions: [] }) }),
screenshotUploadConsistency: null,
screenshotUploadStatuses: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +36,7 @@ export const GameScreenshotUploadDialog: FC<GameScreenshotUploadDialogProps> = (
onOpenChange,
}) => {
const {
config,
game,
screenshotUploadConsistency,
screenshotUploadStatuses,
Expand All @@ -49,6 +49,9 @@ export const GameScreenshotUploadDialog: FC<GameScreenshotUploadDialogProps> = (
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) => {
Expand All @@ -72,8 +75,7 @@ export const GameScreenshotUploadDialog: FC<GameScreenshotUploadDialogProps> = (
setSubmissions((prev) => [screenshot, ...prev]);
};

const showPendingWarning =
currentPendingCount >= screenshotSubmissionLimits.pendingWarningThreshold;
const showPendingWarning = currentPendingCount >= pendingWarningThreshold;
const statuses = screenshotUploadStatuses ?? {};

return (
Expand All @@ -89,7 +91,7 @@ export const GameScreenshotUploadDialog: FC<GameScreenshotUploadDialogProps> = (
<p className="rounded-lg border border-yellow-800 bg-yellow-950/30 px-3 py-2 text-xs text-yellow-300">
{t('You have {{count}} of {{max}} pending submissions.', {
count: currentPendingCount,
max: screenshotSubmissionLimits.maxPendingSubmissions,
max: maxPendingSubmissions,
})}
</p>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ describe('Component: TopLinks', () => {
render(<TopLinks />, {
pageProps: {
config: {
services: { patreon: { userId: 5407777 } },
app: { url: 'https://retroachievements.org' },
screenshots: { maxPendingSubmissions: 200 },
services: { patreon: { userId: 5407777 } },
},
},
});
Expand All @@ -77,7 +78,11 @@ describe('Component: TopLinks', () => {
// ARRANGE
render(<TopLinks />, {
pageProps: {
config: { services: { patreon: {} }, app: { url: 'https://retroachievements.org' } },
config: {
app: { url: 'https://retroachievements.org' },
screenshots: { maxPendingSubmissions: 200 },
services: { patreon: {} },
},
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

use App\Models\Game;
use App\Models\GameScreenshot;
use App\Models\System;
use App\Models\User;
use App\Platform\Actions\SubmitPendingGameScreenshotAction;
use App\Platform\Enums\ScreenshotType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;

uses(RefreshDatabase::class);

beforeEach(function () {
Storage::fake('s3');
Storage::fake('media');
});

it('enforces the configurable pending submission cap', function () {
// ARRANGE
config()->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);
});
Loading
Loading