Skip to content

Commit f8b3adb

Browse files
Copilotildyria
andauthored
feat(033): implement upload trust level feature
Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
1 parent e5cabf2 commit f8b3adb

40 files changed

Lines changed: 1126 additions & 22 deletions

app/Actions/Photo/Create.php

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ private function handleDuplicate(InitDTO $init_dto): Photo
145145
}
146146
$pipes[] = Duplicate\ThrowSkipDuplicate::class;
147147
$pipes[] = Shared\SetHighlighted::class;
148+
$pipes[] = Shared\SetUploadValidated::class;
148149
$pipes[] = Shared\Save::class;
149150
$pipes[] = Shared\SetParent::class;
150151
$pipes[] = Shared\SaveStatistics::class;
@@ -171,16 +172,8 @@ private function handleStandalone(InitDTO $init_dto): Photo
171172
Shared\HydrateMetadata::class,
172173
Shared\SetHighlighted::class,
173174
Shared\SetOwnership::class,
175+
Shared\SetUploadValidated::class,
174176
Standalone\SetOriginalChecksum::class,
175-
Standalone\FetchSourceImage::class,
176-
Standalone\ExtractGoogleMotionPictures::class,
177-
Standalone\PlacePhoto::class,
178-
Standalone\PlaceGoogleMotionVideo::class,
179-
Standalone\SetChecksum::class,
180-
Standalone\AutoRenamer::class,
181-
Shared\Save::class,
182-
Shared\SetParent::class,
183-
Shared\SaveStatistics::class,
184177
Standalone\CreateOriginalSizeVariant::class,
185178
Standalone\CreateRawSizeVariant::class,
186179
Standalone\CreateSizeVariants::class,
@@ -267,6 +260,7 @@ private function handlePhotoLivePartner(InitDTO $init_dto): Photo
267260
Shared\HydrateMetadata::class,
268261
Shared\SetHighlighted::class,
269262
Shared\SetOwnership::class,
263+
Shared\SetUploadValidated::class,
270264
Standalone\SetOriginalChecksum::class,
271265
Standalone\FetchSourceImage::class,
272266
Standalone\ExtractGoogleMotionPictures::class,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Actions\Photo\Pipes\Shared;
10+
11+
use App\Contracts\PhotoCreate\SharedPipe;
12+
use App\DTO\PhotoCreate\DuplicateDTO;
13+
use App\DTO\PhotoCreate\StandaloneDTO;
14+
use App\Enum\UserUploadTrustLevel;
15+
use App\Models\User;
16+
use App\Repositories\ConfigManager;
17+
18+
/**
19+
* Determines and sets the `is_upload_validated` flag on the photo being created.
20+
*
21+
* Rules (evaluated in order):
22+
* 1. If the intended owner exists and is an admin (`may_administrate`), always validated (true).
23+
* 2. If there is no authenticated owner (guest upload), use the `guest_upload_trust_level` config.
24+
* 3. Otherwise use the owner's `upload_trust_level`.
25+
*
26+
* Trust level mapping:
27+
* - CHECK → not validated (false)
28+
* - MONITOR → validated (true) — behaves as TRUSTED in this iteration
29+
* - TRUSTED → validated (true)
30+
*/
31+
class SetUploadValidated implements SharedPipe
32+
{
33+
public function __construct(
34+
protected readonly ConfigManager $config_manager,
35+
) {
36+
}
37+
38+
public function handle(DuplicateDTO|StandaloneDTO $state, \Closure $next): DuplicateDTO|StandaloneDTO
39+
{
40+
$state->photo->is_upload_validated = $this->resolveIsValidated($state->intended_owner_id);
41+
42+
return $next($state);
43+
}
44+
45+
private function resolveIsValidated(int $intended_owner_id): bool
46+
{
47+
// No authenticated owner → guest upload
48+
if ($intended_owner_id === 0) {
49+
$trust_level = $this->config_manager->getValueAsEnum('guest_upload_trust_level', UserUploadTrustLevel::class)
50+
?? UserUploadTrustLevel::CHECK;
51+
52+
return $trust_level !== UserUploadTrustLevel::CHECK;
53+
}
54+
55+
$owner = User::find($intended_owner_id);
56+
57+
// Owner not found → fail-open for backward compatibility
58+
if ($owner === null) {
59+
return true;
60+
}
61+
62+
// Admin always bypasses trust level (Q-033-03 → A)
63+
if ($owner->may_administrate === true) {
64+
return true;
65+
}
66+
67+
$trust_level = $owner->upload_trust_level;
68+
69+
return $trust_level !== UserUploadTrustLevel::CHECK;
70+
}
71+
}

app/Actions/User/Create.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
namespace App\Actions\User;
1010

11+
use App\Enum\UserUploadTrustLevel;
1112
use App\Exceptions\ConflictingPropertyException;
1213
use App\Exceptions\InvalidPropertyException;
1314
use App\Exceptions\ModelDBException;
@@ -35,6 +36,7 @@ public function do(
3536
bool $may_administrate = false,
3637
?int $quota_kb = null,
3738
?string $note = null,
39+
?UserUploadTrustLevel $upload_trust_level = null,
3840
): User {
3941
if (User::query()->where('username', '=', $username)->count() !== 0) {
4042
throw new ConflictingPropertyException('Username already exists');
@@ -52,6 +54,9 @@ public function do(
5254
$user->password = Hash::make($password);
5355
$user->quota_kb = $quota_kb;
5456
$user->note = $note;
57+
$user->upload_trust_level = $upload_trust_level
58+
?? $this->config_manager->getValueAsEnum('default_user_trust_level', UserUploadTrustLevel::class)
59+
?? UserUploadTrustLevel::TRUSTED;
5560
$user->save();
5661

5762
return $user;

app/Actions/User/Save.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
namespace App\Actions\User;
1010

11+
use App\Enum\UserUploadTrustLevel;
1112
use App\Exceptions\ConflictingPropertyException;
1213
use App\Exceptions\InvalidPropertyException;
1314
use App\Exceptions\ModelDBException;
@@ -42,6 +43,7 @@ public function do(User $user,
4243
bool $may_administrate = false,
4344
?int $quota_kb = null,
4445
?string $note = null,
46+
?UserUploadTrustLevel $upload_trust_level = null,
4547
): void {
4648
if (User::query()
4749
->where('username', '=', $username)
@@ -62,6 +64,9 @@ public function do(User $user,
6264
$user->may_administrate = $may_administrate;
6365
$user->note = $note;
6466
$user->quota_kb = $quota_kb;
67+
if ($upload_trust_level !== null) {
68+
$user->upload_trust_level = $upload_trust_level;
69+
}
6570
if ($password !== null && $password !== '') {
6671
$user->password = Hash::make($password);
6772
}

app/Contracts/Http/Requests/RequestAttribute.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class RequestAttribute
100100
public const MAY_UPLOAD_ATTRIBUTE = 'may_upload';
101101
public const MAY_EDIT_OWN_SETTINGS_ATTRIBUTE = 'may_edit_own_settings';
102102
public const MAY_ADMINISTRATE = 'may_administrate';
103+
public const UPLOAD_TRUST_LEVEL_ATTRIBUTE = 'upload_trust_level';
103104
public const SHARED_ALBUMS_VISIBILITY_ATTRIBUTE = 'shared_albums_visibility';
104105

105106
/**

app/Enum/UserUploadTrustLevel.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Enum;
10+
11+
/**
12+
* Enum UserUploadTrustLevel.
13+
*
14+
* Per-user trust level controlling whether newly uploaded photos are
15+
* immediately visible to the public or require admin approval first.
16+
*
17+
* - CHECK: Uploads are hidden from the public until an admin approves them.
18+
* - MONITOR: Reserved for future use; currently behaves identically to TRUSTED.
19+
* - TRUSTED: Uploads are immediately publicly visible (subject to album visibility).
20+
*/
21+
enum UserUploadTrustLevel: string
22+
{
23+
case CHECK = 'check';
24+
case MONITOR = 'monitor';
25+
case TRUSTED = 'trusted';
26+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Controllers\Admin;
10+
11+
use App\Http\Requests\Moderation\ApproveModerationRequest;
12+
use App\Http\Requests\Moderation\ListModerationRequest;
13+
use App\Http\Resources\Collections\PaginatedModerationResource;
14+
use App\Models\Photo;
15+
use Illuminate\Http\Response;
16+
use Illuminate\Routing\Controller;
17+
18+
/**
19+
* Controller for the admin moderation panel.
20+
*
21+
* Lists photos awaiting validation (is_upload_validated = false) and
22+
* supports bulk-approval by setting is_upload_validated = true.
23+
*
24+
* Both endpoints are restricted to administrators only.
25+
*/
26+
class ModerationController extends Controller
27+
{
28+
/**
29+
* List all photos that have not yet been validated.
30+
*
31+
* @param ListModerationRequest $request
32+
*
33+
* @return PaginatedModerationResource
34+
*/
35+
public function list(ListModerationRequest $request): PaginatedModerationResource
36+
{
37+
$per_page = min((int) $request->query('per_page', 30), 100);
38+
39+
/** @var \Illuminate\Pagination\LengthAwarePaginator<Photo> $paginated */
40+
$paginated = Photo::where('is_upload_validated', false)
41+
->with(['owner', 'albums', 'size_variants'])
42+
->orderBy('created_at', 'desc')
43+
->paginate($per_page);
44+
45+
return new PaginatedModerationResource($paginated);
46+
}
47+
48+
/**
49+
* Bulk-approve a set of photos by marking them as validated.
50+
*
51+
* @param ApproveModerationRequest $request
52+
*
53+
* @return Response
54+
*/
55+
public function approve(ApproveModerationRequest $request): Response
56+
{
57+
$ids = $request->photoIds();
58+
59+
// Process in chunks to avoid potential query size issues (NFR-033-04)
60+
collect($ids)->chunk(100)->each(function ($chunk): void {
61+
Photo::whereIn('id', $chunk)->update(['is_upload_validated' => true]);
62+
});
63+
64+
return response()->noContent();
65+
}
66+
}

app/Http/Controllers/Admin/UserManagementController.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class UserManagementController extends Controller
4040
public function list(ManagmentListUsersRequest $request, Spaces $spaces): Collection
4141
{
4242
/** @var Collection<int,User> $users */
43-
$users = User::select(['id', 'username', 'may_administrate', 'may_upload', 'may_edit_own_settings', 'quota_kb', 'description', 'note'])->orderBy('id', 'asc')->get();
43+
$users = User::select(['id', 'username', 'may_administrate', 'may_upload', 'may_edit_own_settings', 'quota_kb', 'description', 'note', 'upload_trust_level'])->orderBy('id', 'asc')->get();
4444
$spaces_per_user = $spaces->getFullSpacePerUser();
4545
$zipped = $users->zip($spaces_per_user);
4646

@@ -61,7 +61,8 @@ public function save(SetUserSettingsRequest $request, Save $save): void
6161
may_edit_own_settings: $request->mayEditOwnSettings(),
6262
may_administrate: $request->mayAdministrate(),
6363
quota_kb: $request->quota_kb(),
64-
note: $request->note()
64+
note: $request->note(),
65+
upload_trust_level: $request->uploadTrustLevel(),
6566
);
6667

6768
TaggedRouteCacheUpdated::dispatch(CacheTag::USERS);
@@ -95,7 +96,8 @@ public function create(AddUserRequest $request, Create $create): UserManagementR
9596
may_edit_own_settings: $request->mayEditOwnSettings(),
9697
may_administrate: $request->mayAdministrate(),
9798
quota_kb: $request->quota_kb(),
98-
note: $request->note()
99+
note: $request->note(),
100+
upload_trust_level: $request->uploadTrustLevel(),
99101
);
100102

101103
TaggedRouteCacheUpdated::dispatch(CacheTag::USERS);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Moderation;
10+
11+
use App\Contracts\Http\Requests\RequestAttribute;
12+
use App\Http\Requests\BaseApiRequest;
13+
use App\Models\User;
14+
use App\Rules\RandomIDRule;
15+
use Illuminate\Support\Facades\Auth;
16+
17+
/**
18+
* Authorization guard and validation for the bulk-approve moderation endpoint.
19+
*
20+
* Only administrators may approve photos.
21+
*/
22+
class ApproveModerationRequest extends BaseApiRequest
23+
{
24+
/** @var string[] */
25+
protected array $photo_ids = [];
26+
27+
public function authorize(): bool
28+
{
29+
/** @var User|null */
30+
$user = Auth::user();
31+
32+
return $user?->may_administrate === true;
33+
}
34+
35+
public function rules(): array
36+
{
37+
return [
38+
RequestAttribute::PHOTO_IDS_ATTRIBUTE => 'required|array|min:1|max:500',
39+
RequestAttribute::PHOTO_IDS_ATTRIBUTE . '.*' => ['required', new RandomIDRule(false)],
40+
];
41+
}
42+
43+
protected function processValidatedValues(array $values, array $files): void
44+
{
45+
$this->photo_ids = $values[RequestAttribute::PHOTO_IDS_ATTRIBUTE];
46+
}
47+
48+
/**
49+
* @return string[]
50+
*/
51+
public function photoIds(): array
52+
{
53+
return $this->photo_ids;
54+
}
55+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Moderation;
10+
11+
use App\Http\Requests\AbstractEmptyRequest;
12+
use App\Models\User;
13+
use Illuminate\Support\Facades\Auth;
14+
15+
/**
16+
* Authorization guard for the moderation list endpoint.
17+
*
18+
* Only administrators may access the moderation queue.
19+
*/
20+
class ListModerationRequest extends AbstractEmptyRequest
21+
{
22+
public function authorize(): bool
23+
{
24+
/** @var User|null */
25+
$user = Auth::user();
26+
27+
return $user?->may_administrate === true;
28+
}
29+
}

0 commit comments

Comments
 (0)