Skip to content

Commit 2a7d4d2

Browse files
committed
feat: uploadable user avatars with moderation
Avatar upload (crop dialog, medialibrary UUID storage), moderation reporting + admin review queue, and per-user upload-block permission. Computed GraphQL fields exposed via User @method accessors; mutations in dedicated resolver classes. Admin dashboard refactored to be data-driven via the navigation composable, with users nested under a section folder so detail routes stay out of the nav tree. nginx /storage static-serve added for dev (.lando) and prod (client/.docker) so medialibrary uploads render. WIP: recreated on fresh master; merged latest master (fallow, record-of-review, login-oauth).
1 parent 33b0f31 commit 2a7d4d2

70 files changed

Lines changed: 4585 additions & 328 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.lando/default.conf.tpl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ server {
3838
try_files /dev/null @backend;
3939
}
4040

41+
# Media-library uploads (avatars) live on the public disk, symlinked at
42+
# public/storage. Serve them straight from disk — Laravel has no /storage
43+
# route, and the generic location / below proxies non-text/html requests
44+
# (like <img> loads) to the vite dev server, which answers with the SPA
45+
# index.html (text/html) so the image never renders.
46+
location /storage/ {
47+
try_files $uri =404;
48+
}
49+
4150
# SPA navigation (Accept: text/html) goes to Laravel so index.blade.php can
4251
# inject runtime globals (__TELEMETRY_CONFIG, __APP_BANNER).
4352
# Everything else (JS modules, HMR, asset files) proxies to the vite dev server.

backend/Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ ARG TARGETARCH
99

1010
ARG PANDOC_VERSION=2.18
1111
ARG COMPOSE_WAIT_VERSION=2.7.3
12-
ARG PHP_EXTS="bcmath pdo_mysql pcntl zip intl gettext mysqli"
12+
ARG PHP_EXTS="bcmath pdo_mysql pcntl zip intl gettext mysqli exif gd"
1313
ARG PHP_PECL_EXTS="redis apcu xhprof"
14-
ARG APT_PACKAGES="openssl ca-certificates bash libzip-dev libaio1t64 libncurses6 libnuma1 libxml2-dev git libfcgi-bin sqlite3 default-mysql-client libicu-dev"
14+
ARG APT_PACKAGES="openssl ca-certificates bash libzip-dev libaio1t64 libncurses6 libnuma1 libxml2-dev git libfcgi-bin sqlite3 default-mysql-client libicu-dev libpng-dev libjpeg62-turbo-dev libwebp-dev libfreetype6-dev"
1515
#ENV COMPOSER_HOME=/tmp/composer
1616
ENV LOG_CHANNEL=stderr
1717

1818
RUN apt update && apt install -y ${APT_PACKAGES} \
19+
&& docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype \
1920
&& docker-php-ext-install -j$(nproc) ${PHP_EXTS} \
2021
&& pecl install ${PHP_PECL_EXTS} \
2122
&& docker-php-ext-enable ${PHP_PECL_EXTS} \
22-
&& apt purge libxml2-dev libzip-dev -y \
23+
&& apt purge libxml2-dev libzip-dev libpng-dev libjpeg62-turbo-dev libwebp-dev libfreetype6-dev -y \
2324
&& rm -rf /var/lib/apt/lists/*
2425

2526
RUN curl -Lo /wait https://github.com/ufoscout/docker-compose-wait/releases/download/${COMPOSE_WAIT_VERSION}/wait \
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Fields;
5+
6+
use App\Models\Permission;
7+
use App\Models\User;
8+
9+
class UserAvatarBlockedField
10+
{
11+
/**
12+
* True when the user does not have the UPLOAD_AVATAR permission —
13+
* either because a moderator revoked it, or (shouldn't happen, but
14+
* defensive) because they were never granted it in the first place.
15+
*
16+
* @param array<string, mixed> $_args
17+
*/
18+
public function __invoke(User $user, array $_args): bool
19+
{
20+
return !$user->hasPermissionTo(Permission::UPLOAD_AVATAR);
21+
}
22+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Fields;
5+
6+
use App\Models\User;
7+
8+
class UserAvatarField
9+
{
10+
/**
11+
* Resolve the User.avatar field.
12+
*
13+
* Returns null when the user has not uploaded an avatar so the client
14+
* can fall back to a placeholder.
15+
*
16+
* @param array<string, mixed> $_args
17+
* @return array<string, string>|null
18+
*/
19+
public function __invoke(User $user, array $_args): ?array
20+
{
21+
$media = $user->getAvatarMedia();
22+
if ($media === null) {
23+
return null;
24+
}
25+
26+
return [
27+
'url' => $media->getFullUrl(),
28+
'thumb_url' => $media->getFullUrl('thumb'),
29+
'medium_url' => $media->getFullUrl('medium'),
30+
];
31+
}
32+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Mutations;
5+
6+
use App\Models\AvatarReport;
7+
use App\Models\User;
8+
use GraphQL\Error\Error;
9+
use Illuminate\Support\Facades\Auth;
10+
11+
class ReportUserAvatar
12+
{
13+
/**
14+
* File (or return an existing pending) report against a user's avatar.
15+
*
16+
* @param array{userId: string, reason: ?string} $args
17+
*/
18+
public function __invoke(null $_, array $args): AvatarReport
19+
{
20+
/** @var \App\Models\User|null $reporter */
21+
$reporter = Auth::user();
22+
if ($reporter === null) {
23+
throw new Error('Authentication required.');
24+
}
25+
26+
$reportedUser = User::findOrFail($args['userId']);
27+
28+
if ((string)$reportedUser->id === (string)$reporter->id) {
29+
throw new Error('You cannot report your own avatar.');
30+
}
31+
32+
if ($reportedUser->getAvatarMedia() === null) {
33+
throw new Error('This user does not have an uploaded avatar to report.');
34+
}
35+
36+
// Idempotent: one pending report per (reporter, reported) pair.
37+
$existing = AvatarReport::where('user_id', $reportedUser->id)
38+
->where('reporter_user_id', $reporter->id)
39+
->where('status', AvatarReport::STATUS_PENDING)
40+
->first();
41+
if ($existing !== null) {
42+
return $existing;
43+
}
44+
45+
return AvatarReport::create([
46+
'user_id' => $reportedUser->id,
47+
'reporter_user_id' => $reporter->id,
48+
'reason' => $args['reason'] ?? null,
49+
'status' => AvatarReport::STATUS_PENDING,
50+
]);
51+
}
52+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Mutations;
5+
6+
use App\Models\AvatarReport;
7+
use App\Models\Permission;
8+
use App\Models\User;
9+
use GraphQL\Error\Error;
10+
use Illuminate\Support\Carbon;
11+
use Illuminate\Support\Facades\Auth;
12+
13+
class ResolveAvatarReport
14+
{
15+
/**
16+
* Dismiss a report without removing the avatar.
17+
*
18+
* @param array{id: string, notes: ?string} $args
19+
*/
20+
public function dismiss(null $_, array $args): AvatarReport
21+
{
22+
return $this->resolve($args, AvatarReport::STATUS_DISMISSED, false);
23+
}
24+
25+
/**
26+
* Resolve a report and remove the reported user's avatar.
27+
*
28+
* @param array{id: string, notes: ?string, blockFutureUploads?: ?bool} $args
29+
*/
30+
public function resolveAndRemove(null $_, array $args): AvatarReport
31+
{
32+
return $this->resolve($args, AvatarReport::STATUS_REMOVED, true);
33+
}
34+
35+
/**
36+
* @param array{
37+
* id: string,
38+
* notes: ?string,
39+
* blockFutureUploads?: ?bool
40+
* } $args
41+
*/
42+
private function resolve(array $args, string $status, bool $removeAvatar): AvatarReport
43+
{
44+
/** @var \App\Models\User|null $admin */
45+
$admin = Auth::user();
46+
if ($admin === null) {
47+
throw new Error('Authentication required.');
48+
}
49+
50+
/** @var \App\Models\AvatarReport $report */
51+
$report = AvatarReport::findOrFail($args['id']);
52+
53+
if ($report->status !== AvatarReport::STATUS_PENDING) {
54+
throw new Error('This report has already been resolved.');
55+
}
56+
57+
if ($removeAvatar) {
58+
$report->user->clearMediaCollection(User::AVATAR_COLLECTION);
59+
60+
if (!empty($args['blockFutureUploads'])) {
61+
$report->user->revokePermissionTo(Permission::UPLOAD_AVATAR);
62+
}
63+
64+
// Close out other pending reports against the same user — the
65+
// avatar is gone, so they no longer need moderator attention.
66+
AvatarReport::where('user_id', $report->user_id)
67+
->where('status', AvatarReport::STATUS_PENDING)
68+
->where('id', '!=', $report->id)
69+
->update([
70+
'status' => AvatarReport::STATUS_REMOVED,
71+
'resolved_by_user_id' => $admin->id,
72+
'resolved_at' => Carbon::now(),
73+
'resolution_notes' => 'Closed automatically when avatar was removed.',
74+
]);
75+
}
76+
77+
$report->fill([
78+
'status' => $status,
79+
'resolved_by_user_id' => $admin->id,
80+
'resolved_at' => Carbon::now(),
81+
'resolution_notes' => $args['notes'] ?? null,
82+
])->save();
83+
84+
return $report->refresh();
85+
}
86+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Mutations;
5+
6+
use App\Models\Permission;
7+
use App\Models\User;
8+
9+
class SetUserAvatarUploadBlocked
10+
{
11+
/**
12+
* Grant or revoke the avatar-upload block on a user.
13+
*
14+
* @param array{userId: string, blocked: bool} $args
15+
*/
16+
public function __invoke(null $_, array $args): User
17+
{
18+
/** @var \App\Models\User $user */
19+
$user = User::findOrFail($args['userId']);
20+
21+
// "Blocking" means revoking the default UPLOAD_AVATAR permission;
22+
// "unblocking" grants it back directly on the user.
23+
if ($args['blocked']) {
24+
$user->revokePermissionTo(Permission::UPLOAD_AVATAR);
25+
} else {
26+
$user->givePermissionTo(Permission::UPLOAD_AVATAR);
27+
}
28+
29+
return $user->refresh();
30+
}
31+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Mutations;
5+
6+
use App\Models\Permission;
7+
use App\Models\User;
8+
use GraphQL\Error\Error;
9+
use Illuminate\Support\Facades\Validator;
10+
use Intervention\Image\Drivers\Gd\Driver;
11+
use Intervention\Image\ImageManager;
12+
use Spatie\MediaLibrary\MediaCollections\Exceptions\FileCannotBeAdded;
13+
14+
class UpdateUserAvatar
15+
{
16+
/**
17+
* Max upload size in kilobytes (5 MB).
18+
*/
19+
private const MAX_SIZE_KB = 5 * 1024;
20+
21+
/**
22+
* Upload or replace a user's avatar.
23+
*
24+
* @param array{id: string, avatar: \Illuminate\Http\UploadedFile} $args
25+
*/
26+
public function upload(null $_, array $args): User
27+
{
28+
$user = User::findOrFail($args['id']);
29+
30+
if (!$user->hasPermissionTo(Permission::UPLOAD_AVATAR)) {
31+
throw new Error('This user is not permitted to upload an avatar.');
32+
}
33+
34+
$file = $args['avatar'];
35+
36+
Validator::make(
37+
['avatar' => $file],
38+
['avatar' => [
39+
'required',
40+
'file',
41+
'mimetypes:' . implode(',', User::AVATAR_MIME_TYPES),
42+
'max:' . self::MAX_SIZE_KB,
43+
]]
44+
)->validate();
45+
46+
// Re-encode the image to strip EXIF (which may contain GPS
47+
// coordinates or device info) and anything a crafted file might
48+
// carry beyond pixel data.
49+
$ext = strtolower($file->getClientOriginalExtension());
50+
$cleanPath = $this->stripMetadata($file->getRealPath(), $ext);
51+
52+
try {
53+
$user->addMedia($cleanPath)
54+
->usingFileName("avatar.{$ext}")
55+
->toMediaCollection(User::AVATAR_COLLECTION);
56+
} catch (FileCannotBeAdded $e) {
57+
throw new Error('Unable to store avatar: ' . $e->getMessage());
58+
} finally {
59+
if (file_exists($cleanPath)) {
60+
unlink($cleanPath);
61+
}
62+
}
63+
64+
return $user->refresh();
65+
}
66+
67+
/**
68+
* Decode → re-encode an image using intervention/image, which drops
69+
* EXIF and any ancillary metadata. Returns a path to a temp file the
70+
* caller must clean up.
71+
*/
72+
private function stripMetadata(string $sourcePath, string $extension): string
73+
{
74+
$manager = new ImageManager(new Driver());
75+
$image = $manager->read($sourcePath);
76+
77+
$encoded = match ($extension) {
78+
'jpg', 'jpeg' => $image->toJpeg(90),
79+
'webp' => $image->toWebp(90),
80+
default => $image->toPng(),
81+
};
82+
83+
$tempPath = tempnam(sys_get_temp_dir(), 'avatar_');
84+
file_put_contents($tempPath, (string)$encoded);
85+
86+
return $tempPath;
87+
}
88+
89+
/**
90+
* Remove a user's avatar.
91+
*
92+
* @param array{id: string} $args
93+
*/
94+
public function delete(null $_, array $args): User
95+
{
96+
$user = User::findOrFail($args['id']);
97+
$user->clearMediaCollection(User::AVATAR_COLLECTION);
98+
99+
return $user->refresh();
100+
}
101+
}

0 commit comments

Comments
 (0)