Skip to content
Merged
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
8 changes: 4 additions & 4 deletions extensions/gdpr/src/Data/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Flarum\Gdpr\Data;

use Flarum\User\AvatarUploader;
use Illuminate\Support\Str;

class Assets extends Type
Expand Down Expand Up @@ -52,10 +53,9 @@ public function anonymize(): void
public function delete(): void
{
if ($this->user->avatar_url) {
$filesystem = $this->getDisk('flarum-avatars');
$fileName = $this->getAvatarFileName();

$filesystem->exists($fileName) && $filesystem->delete($fileName);
// Delegate to AvatarUploader so all HiDPI variants (@2x, @3x) are
// removed too — not just the base file.
resolve(AvatarUploader::class)->deleteAllVariants($this->getAvatarFileName());
}
}

Expand Down
10 changes: 9 additions & 1 deletion extensions/gdpr/src/Data/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ public function anonymize(): void
{
$columns = $this->getTableColumns($this->user);

$remove = ['id', 'username', 'password', 'email', 'is_email_confirmed', 'preferences', 'joined_at', 'anonymized', 'discussion_count', 'comment_count'];
// `$remove` here means "skip this column when wiping to null" — i.e. preserve
// the existing value or set it explicitly below. NOT NULL boolean columns
// (e.g. has_avatar_*) are listed here because nulling them would violate
// their constraint; they're set explicitly after the loop instead.
$remove = ['id', 'username', 'password', 'email', 'is_email_confirmed', 'preferences', 'joined_at', 'anonymized', 'discussion_count', 'comment_count', 'has_avatar_2x', 'has_avatar_3x'];

foreach ($columns as $column) {
if (in_array($column, $remove)) {
Expand All @@ -51,6 +55,10 @@ public function anonymize(): void
$this->user->setPreferencesAttribute([]);
$this->user->joined_at = Carbon::now();
$this->user->anonymized = true;
// The avatar files themselves are removed by Data\Assets::anonymize(); reset
// the variant flags here to match.
$this->user->has_avatar_2x = false;
$this->user->has_avatar_3x = false;
$this->user->groups()->sync([]);

$this->user->save();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;

return [
'up' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->boolean('has_avatar_2x')->default(false)->after('avatar_url');
$table->boolean('has_avatar_3x')->default(false)->after('has_avatar_2x');
});
},

'down' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->dropColumn(['has_avatar_2x', 'has_avatar_3x']);
});
}
];
2 changes: 2 additions & 0 deletions framework/core/src/Console/ConsoleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Flarum\Foundation\Console\AssetsPublishCommand;
use Flarum\Foundation\Console\CacheClearCommand;
use Flarum\Foundation\Console\InfoCommand;
use Flarum\User\Console\BackfillAvatarVariantsCommand;
use Flarum\User\Console\ConvertAvatarsToWebpCommand;
use Illuminate\Console\Events\CommandFinished;
use Illuminate\Console\Scheduling\CacheEventMutex;
Expand Down Expand Up @@ -71,6 +72,7 @@ public function register(): void
ToggleExtensionCommand::class,
BisectCommand::class,
ConvertAvatarsToWebpCommand::class,
BackfillAvatarVariantsCommand::class,
// Used internally to create DB dumps before major releases.
// \Flarum\Database\Console\GenerateDumpCommand::class
];
Expand Down
49 changes: 27 additions & 22 deletions framework/core/src/User/AvatarUploader.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ public function uploadPresized(User $user, ImageInterface $image1x, ?ImageInterf

$this->uploadDir->put($path, $encoded);
}

$user->has_avatar_2x = $image2x !== null;
$user->has_avatar_3x = $image3x !== null;
}

public function upload(User $user, ImageInterface $image): void
Expand All @@ -77,6 +80,8 @@ public function upload(User $user, ImageInterface $image): void
$this->removeFileAfterSave($user);
$user->changeAvatarPath($basePath);

$generated = ['' => false, '@2x' => false, '@3x' => false];

foreach (self::SIZES as $suffix => $size) {
// Never upscale — skip this variant if the source is too small.
if ($sourceWidth < $size || $sourceHeight < $size) {
Expand All @@ -89,7 +94,11 @@ public function upload(User $user, ImageInterface $image): void
$path = $avatarBase.$suffix.'.'.$extension;

$this->uploadDir->put($path, $encoded);
$generated[$suffix] = true;
}

$user->has_avatar_2x = $generated['@2x'];
$user->has_avatar_3x = $generated['@3x'];
}

/**
Expand All @@ -115,6 +124,8 @@ public function remove(User $user): void
$this->removeFileAfterSave($user);

$user->changeAvatarPath(null);
$user->has_avatar_2x = false;
$user->has_avatar_3x = false;
}

/**
Expand All @@ -134,37 +145,31 @@ public function deleteAllVariants(string $basePath): void
}

/**
* Return the srcset string for a given base path, including only variants that exist on disk.
* Returns null if only the base file exists (no HiDPI variants).
* Return the srcset string for the user's locally-stored avatar, including only
* variants that the user record reports as present (`has_avatar_2x` /
* `has_avatar_3x`). Returns null if the avatar is external, missing, or has no
* HiDPI variants — this avoids any filesystem `exists()` calls in the read path.
*/
public function srcsetFor(string $basePath): ?string
public function srcsetFor(User $user): ?string
{
// External URLs (e.g. from OAuth provider) — no local variants.
if (str_contains($basePath, '://')) {
return null;
}

// Collect which variant paths exist on disk first, without calling url().
$existing = [];

foreach (self::SIZES as $suffix => $size) {
$path = $this->variantPath($basePath, $suffix);
$basePath = $user->getRawOriginal('avatar_url');

if ($this->uploadDir->exists($path)) {
$existing[$path] = $size / 100;
}
if (! $basePath || str_contains($basePath, '://')) {
return null;
}

// Only meaningful if we have more than just the 1× base.
if (count($existing) <= 1) {
if (! $user->has_avatar_2x && ! $user->has_avatar_3x) {
return null;
}

$entries = [];
$entries = [$this->uploadDir->url($basePath).' 1x'];

if ($user->has_avatar_2x) {
$entries[] = $this->uploadDir->url($this->variantPath($basePath, '@2x')).' 2x';
}

foreach ($existing as $path => $multiplier) {
$url = $this->uploadDir->url($path);
$entries[] = "$url {$multiplier}x";
if ($user->has_avatar_3x) {
$entries[] = $this->uploadDir->url($this->variantPath($basePath, '@3x')).' 3x';
}

return implode(', ', $entries);
Expand Down
136 changes: 136 additions & 0 deletions framework/core/src/User/Console/BackfillAvatarVariantsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\User\Console;

use Flarum\Console\AbstractCommand;
use Flarum\User\User;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputOption;

/**
* Populate the `has_avatar_2x` / `has_avatar_3x` columns for users whose
* avatars predate variant tracking (uploaded before the columns existed, or
* uploaded by a future code path that didn't set them).
*
* The columns default to `false`, which is *correct* in that the srcset will
* fall back to the 1× URL — but it loses HiDPI rendering for avatars that do
* have variant files on disk. Operators can run this command once after upgrade
* to restore HiDPI for those users. On remote-storage installs the per-row cost
* is two `exists()` round-trips, so chunked execution is recommended.
*/
class BackfillAvatarVariantsCommand extends AbstractCommand
{
private Filesystem $uploadDir;

public function __construct(Factory $filesystemFactory)
{
$this->uploadDir = $filesystemFactory->disk('flarum-avatars');
parent::__construct();
}

protected function configure(): void
{
$this
->setName('avatars:backfill-variants')
->setDescription('Detect and record which HiDPI variants (@2x, @3x) exist for each locally-stored avatar.')
->addOption(
'chunk',
null,
InputOption::VALUE_REQUIRED,
'Number of users to process per database chunk',
'100'
)
->addOption(
'force',
null,
InputOption::VALUE_NONE,
'Re-check users even if both flags are already set'
);
}

protected function fire(): int
{
$chunkSize = max(1, (int) $this->input->getOption('chunk'));
$force = (bool) $this->input->getOption('force');

$query = User::whereNotNull('avatar_url')
->where('avatar_url', 'not like', '%://%');

if (! $force) {
$query->where(function ($q) {
$q->where('has_avatar_2x', false)->orWhere('has_avatar_3x', false);
});
}

$total = $query->count();

if ($total === 0) {
$this->info('No avatars to check.');

return Command::SUCCESS;
}

$this->info("Checking variant files for $total avatar(s)...");

$progress = new ProgressBar($this->output, $total);
$progress->start();

$updated = 0;
$unchanged = 0;
$missing = 0;

$query->chunkById($chunkSize, function ($users) use ($progress, &$updated, &$unchanged, &$missing) {
foreach ($users as $user) {
$basePath = $user->getRawOriginal('avatar_url');

// The base file must exist for variants to be meaningful — a user
// whose 1× is missing is broken regardless of variant flags.
if (! $this->uploadDir->exists($basePath)) {
$missing++;
$progress->advance();
continue;
}

$has2x = $this->uploadDir->exists($this->variantPath($basePath, '@2x'));
$has3x = $this->uploadDir->exists($this->variantPath($basePath, '@3x'));

if ($user->has_avatar_2x === $has2x && $user->has_avatar_3x === $has3x) {
$unchanged++;
} else {
User::where('id', $user->id)->update([
'has_avatar_2x' => $has2x,
'has_avatar_3x' => $has3x,
]);
$updated++;
}

$progress->advance();
}
});

$progress->finish();
$this->output->writeln('');
$this->info("Done. Updated: $updated, Unchanged: $unchanged, Base file missing: $missing.");

return Command::SUCCESS;
}

private function variantPath(string $basePath, string $suffix): string
{
$dot = strrpos($basePath, '.');

return $dot !== false
? substr($basePath, 0, $dot).$suffix.substr($basePath, $dot)
: $basePath.$suffix;
}
}
6 changes: 5 additions & 1 deletion framework/core/src/User/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
* @property bool $is_email_confirmed
* @property string $password
* @property string|null $avatar_url
* @property bool $has_avatar_2x
* @property bool $has_avatar_3x
* @property array $preferences
* @property \Carbon\Carbon|null $joined_at
* @property \Carbon\Carbon|null $last_seen_at
Expand Down Expand Up @@ -77,6 +79,8 @@ class User extends AbstractModel
protected $casts = [
'id' => 'integer',
'is_email_confirmed' => 'boolean',
'has_avatar_2x' => 'boolean',
'has_avatar_3x' => 'boolean',
'joined_at' => 'datetime',
'last_seen_at' => 'datetime',
'marked_all_as_read_at' => 'datetime',
Expand Down Expand Up @@ -295,7 +299,7 @@ public function getAvatarSrcsetAttribute(): ?string
$value = $this->getRawOriginal('avatar_url');

if ($value && ! str_contains($value, '://')) {
return resolve(AvatarUploader::class)->srcsetFor($value);
return resolve(AvatarUploader::class)->srcsetFor($this);
}

return static::$avatarDriver->avatarSrcset($this);
Expand Down
Loading
Loading