messages implemented
This commit is contained in:
@@ -69,15 +69,21 @@ class ArtworkAwardService
|
||||
|
||||
/**
|
||||
* Remove an award for a user/artwork pair.
|
||||
* Uses model-level delete so the ArtworkAwardObserver fires.
|
||||
*/
|
||||
public function removeAward(Artwork $artwork, User $user): void
|
||||
{
|
||||
ArtworkAward::where('artwork_id', $artwork->id)
|
||||
$award = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
->first();
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
if ($award) {
|
||||
$award->delete(); // fires ArtworkAwardObserver::deleted
|
||||
} else {
|
||||
// Nothing to remove, but still sync stats to be safe.
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,6 +172,73 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
// ── Discover section helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trending: most viewed artworks, weighted toward recent uploads.
|
||||
* Uses views:desc + recency via created_at:desc as tiebreaker.
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh: newest uploads first.
|
||||
*/
|
||||
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Top rated: highest number of favourites/likes.
|
||||
*/
|
||||
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['likes:desc', 'views:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most downloaded: highest download count.
|
||||
*/
|
||||
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['downloads:desc', 'views:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ArtworkStatsService
|
||||
*
|
||||
@@ -14,147 +18,200 @@ use Illuminate\Support\Facades\Redis;
|
||||
*/
|
||||
class ArtworkStatsService
|
||||
{
|
||||
protected string $redisKey = 'artwork_stats:deltas';
|
||||
protected string $redisKey = 'artwork_stats:deltas';
|
||||
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
*/
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment views using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementViews((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
}
|
||||
/**
|
||||
* Increment views using an Artwork model.
|
||||
*/
|
||||
public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementViews((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a set of deltas to the artwork_stats row inside a transaction.
|
||||
* This method is safe to call from jobs or synchronously.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array<string,int> $deltas
|
||||
*/
|
||||
/**
|
||||
* Increment downloads using an Artwork model.
|
||||
*/
|
||||
public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementDownloads((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a set of deltas to the artwork_stats row inside a transaction.
|
||||
* After updating artwork-level stats, forwards view/download counts to
|
||||
* UserStatsService so creator-level counters stay current.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array<string,int> $deltas
|
||||
*/
|
||||
public function applyDelta(int $artworkId, array $deltas): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists. Insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
try {
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists — insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Increment downloads using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementDownloads((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection.
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
// Forward creator-level counters outside the transaction.
|
||||
$this->forwardCreatorStats($artworkId, $deltas);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to apply artwork stats delta', [
|
||||
'artwork_id' => $artworkId,
|
||||
'deltas' => $deltas,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a delta to Redis queue for async processing.
|
||||
*/
|
||||
protected function pushDelta(int $artworkId, string $field, int $value): void
|
||||
{
|
||||
$payload = json_encode([
|
||||
'artwork_id' => $artworkId,
|
||||
'field' => $field,
|
||||
'value' => $value,
|
||||
'ts' => time(),
|
||||
]);
|
||||
/**
|
||||
* After applying artwork-level deltas, forward relevant totals to the
|
||||
* creator's user_statistics row via UserStatsService.
|
||||
* Views skip Meilisearch reindex (high frequency — covered by recompute).
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array<string,int> $deltas
|
||||
*/
|
||||
protected function forwardCreatorStats(int $artworkId, array $deltas): void
|
||||
{
|
||||
$viewDelta = (int) ($deltas['views'] ?? 0);
|
||||
$downloadDelta = (int) ($deltas['downloads'] ?? 0);
|
||||
|
||||
try {
|
||||
Redis::rpush($this->redisKey, $payload);
|
||||
} catch (Throwable $e) {
|
||||
// If Redis is unavailable, fallback to immediate apply to avoid data loss
|
||||
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
|
||||
$this->applyDelta($artworkId, [$field => $value]);
|
||||
}
|
||||
}
|
||||
if ($viewDelta <= 0 && $downloadDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain and apply queued deltas from Redis. Returns number processed.
|
||||
* Designed to be invoked by a queued job or artisan command.
|
||||
*/
|
||||
try {
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if (! $creatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var UserStatsService $svc */
|
||||
$svc = app(UserStatsService::class);
|
||||
|
||||
if ($viewDelta > 0) {
|
||||
// High-frequency: increment counter but skip Meilisearch reindex.
|
||||
$svc->incrementArtworkViewsReceived($creatorId, $viewDelta);
|
||||
}
|
||||
|
||||
if ($downloadDelta > 0) {
|
||||
$svc->incrementDownloadsReceived($creatorId, $downloadDelta);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to forward creator stats from artwork delta', [
|
||||
'artwork_id' => $artworkId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a delta to Redis queue for async processing.
|
||||
*/
|
||||
protected function pushDelta(int $artworkId, string $field, int $value): void
|
||||
{
|
||||
$payload = json_encode([
|
||||
'artwork_id' => $artworkId,
|
||||
'field' => $field,
|
||||
'value' => $value,
|
||||
'ts' => time(),
|
||||
]);
|
||||
|
||||
try {
|
||||
Redis::rpush($this->redisKey, $payload);
|
||||
} catch (Throwable $e) {
|
||||
// If Redis is unavailable, fall back to immediate apply to avoid data loss.
|
||||
Log::warning('Redis unavailable for artwork stats; applying immediately', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->applyDelta($artworkId, [$field => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain and apply queued deltas from Redis. Returns number processed.
|
||||
* Designed to be invoked by a queued job or artisan command.
|
||||
*/
|
||||
public function processPendingFromRedis(int $max = 1000): int
|
||||
{
|
||||
if (! $this->redisAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
$processed = 0;
|
||||
if (! $this->redisAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
while ($processed < $max) {
|
||||
$item = Redis::lpop($this->redisKey);
|
||||
if (! $item) {
|
||||
break;
|
||||
}
|
||||
$processed = 0;
|
||||
|
||||
$decoded = json_decode($item, true);
|
||||
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
|
||||
continue;
|
||||
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
|
||||
$processed++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
|
||||
}
|
||||
try {
|
||||
while ($processed < $max) {
|
||||
$item = Redis::lpop($this->redisKey);
|
||||
if (! $item) {
|
||||
break;
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
$decoded = json_decode($item, true);
|
||||
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
protected function redisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
// Redis facade may throw if not configured
|
||||
$pong = Redis::connection()->ping();
|
||||
return (bool) $pong;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
|
||||
$processed++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
protected function redisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
$pong = Redis::connection()->ping();
|
||||
return (bool) $pong;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
323
app/Services/ContentSanitizer.php
Normal file
323
app/Services/ContentSanitizer.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\LegacySmileyMapper;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
/**
|
||||
* Sanitizes and renders user-submitted content.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Strip any raw HTML tags from input (we don't allow HTML)
|
||||
* 2. Convert legacy <br> / <b> / <i> hints from really old legacy content
|
||||
* 3. Parse subset of Markdown (bold, italic, code, links, line breaks)
|
||||
* 4. Sanitize the rendered HTML: whitelist-only tags, strip attributes
|
||||
* 5. Return safe HTML ready for storage or display
|
||||
*/
|
||||
class ContentSanitizer
|
||||
{
|
||||
/** Maximum number of emoji allowed before triggering a flood error. */
|
||||
public const EMOJI_COUNT_MAX = 50;
|
||||
|
||||
/**
|
||||
* Maximum ratio of emoji-to-total-characters before content is considered
|
||||
* an emoji flood (applies only when emoji count > 5 to avoid false positives
|
||||
* on very short strings like a single reaction comment).
|
||||
*/
|
||||
public const EMOJI_DENSITY_MAX = 0.40;
|
||||
|
||||
// HTML tags we allow in the final rendered output
|
||||
private const ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'em', 'code', 'pre',
|
||||
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
|
||||
];
|
||||
|
||||
// Allowed attributes per tag
|
||||
private const ALLOWED_ATTRS = [
|
||||
'a' => ['href', 'title', 'rel', 'target'],
|
||||
];
|
||||
|
||||
private static ?MarkdownConverter $converter = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert raw user input (legacy or new) to sanitized HTML.
|
||||
*
|
||||
* @param string|null $raw
|
||||
* @return string Safe HTML
|
||||
*/
|
||||
public static function render(?string $raw): string
|
||||
{
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 1. Convert legacy HTML fragments to Markdown-friendly text
|
||||
$text = static::legacyHtmlToMarkdown($raw);
|
||||
|
||||
// 2. Parse Markdown → HTML
|
||||
$html = static::parseMarkdown($text);
|
||||
|
||||
// 3. Sanitize HTML (strip disallowed tags / attrs)
|
||||
$html = static::sanitizeHtml($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ALL HTML from input, returning plain text with newlines preserved.
|
||||
*/
|
||||
public static function stripToPlain(?string $html): string
|
||||
{
|
||||
if ($html === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert <br> and <p> to line breaks before stripping
|
||||
$text = preg_replace(['/<br\s*\/?>/i', '/<\/p>/i'], "\n", $html);
|
||||
$text = strip_tags($text ?? '');
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a Markdown-lite string does not contain disallowed patterns.
|
||||
* Returns an array of validation errors (empty = OK).
|
||||
*/
|
||||
public static function validate(string $raw): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (mb_strlen($raw) > 10_000) {
|
||||
$errors[] = 'Content exceeds maximum length of 10,000 characters.';
|
||||
}
|
||||
|
||||
// Detect raw HTML tags (we forbid them)
|
||||
if (preg_match('/<[a-z][^>]*>/i', $raw)) {
|
||||
$errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.';
|
||||
}
|
||||
|
||||
// Count emoji to prevent absolute spam
|
||||
$emojiCount = static::countEmoji($raw);
|
||||
if ($emojiCount > self::EMOJI_COUNT_MAX) {
|
||||
$errors[] = 'Too many emoji. Please limit emoji usage.';
|
||||
}
|
||||
|
||||
// Reject emoji-flood content: density guard catches e.g. 15 emoji in a
|
||||
// 20-char string even when the absolute count is below EMOJI_COUNT_MAX.
|
||||
if ($emojiCount > 5) {
|
||||
$totalChars = mb_strlen($raw);
|
||||
if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) {
|
||||
$errors[] = 'Content is mostly emoji. Please add some text.';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse consecutive runs of the same emoji in $text.
|
||||
*
|
||||
* Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is
|
||||
* consistent between new submissions and migrated legacy content.
|
||||
*
|
||||
* Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) → "🍺 🍺 🍺 🍺 🍺 ×7"
|
||||
*
|
||||
* @param int $maxRun Keep at most this many consecutive identical emoji.
|
||||
*/
|
||||
public static function collapseFlood(string $text, int $maxRun = 5): string
|
||||
{
|
||||
return LegacySmileyMapper::collapseFlood($text, $maxRun);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert legacy HTML-style formatting to Markdown equivalents.
|
||||
* This runs BEFORE Markdown parsing to handle old content gracefully.
|
||||
*/
|
||||
private static function legacyHtmlToMarkdown(string $html): string
|
||||
{
|
||||
$replacements = [
|
||||
// Bold
|
||||
'/<b>(.*?)<\/b>/is' => '**$1**',
|
||||
'/<strong>(.*?)<\/strong>/is' => '**$1**',
|
||||
// Italic
|
||||
'/<i>(.*?)<\/i>/is' => '*$1*',
|
||||
'/<em>(.*?)<\/em>/is' => '*$1*',
|
||||
// Line breaks → actual newlines
|
||||
'/<br\s*\/?>/i' => "\n",
|
||||
// Paragraphs
|
||||
'/<p>(.*?)<\/p>/is' => "$1\n\n",
|
||||
// Strip remaining tags
|
||||
'/<[^>]+>/' => '',
|
||||
];
|
||||
|
||||
$result = $html;
|
||||
foreach ($replacements as $pattern => $replacement) {
|
||||
$result = preg_replace($pattern, $replacement, $result) ?? $result;
|
||||
}
|
||||
|
||||
// Decode HTML entities (e.g. & → &)
|
||||
$result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Markdown-lite subset to HTML.
|
||||
*/
|
||||
private static function parseMarkdown(string $text): string
|
||||
{
|
||||
$converter = static::getConverter();
|
||||
$result = $converter->convert($text);
|
||||
|
||||
return (string) $result->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist-based HTML sanitizer.
|
||||
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
|
||||
*/
|
||||
private static function sanitizeHtml(string $html): string
|
||||
{
|
||||
// Parse with DOMDocument
|
||||
$doc = new \DOMDocument('1.0', 'UTF-8');
|
||||
// Suppress warnings from malformed fragments
|
||||
libxml_use_internal_errors(true);
|
||||
$doc->loadHTML(
|
||||
'<html><body>' . $html . '</body></html>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
|
||||
static::cleanNode($doc->getElementsByTagName('body')->item(0));
|
||||
|
||||
// Serialize back, removing the wrapping html/body
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
$inner = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$inner .= $doc->saveHTML($child);
|
||||
}
|
||||
|
||||
// Fix self-closing <a></a> etc.
|
||||
return trim($inner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clean a DOMNode — strip forbidden tags/attributes.
|
||||
*/
|
||||
private static function cleanNode(\DOMNode $node): void
|
||||
{
|
||||
$toRemove = [];
|
||||
$toUnwrap = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tag = strtolower($child->nodeName);
|
||||
|
||||
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
// Replace element with its text content
|
||||
$toUnwrap[] = $child;
|
||||
} else {
|
||||
// Strip disallowed attributes
|
||||
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
|
||||
$attrsToRemove = [];
|
||||
foreach ($child->attributes as $attr) {
|
||||
if (! in_array($attr->nodeName, $allowedAttrs, true)) {
|
||||
$attrsToRemove[] = $attr->nodeName;
|
||||
}
|
||||
}
|
||||
foreach ($attrsToRemove as $attrName) {
|
||||
$child->removeAttribute($attrName);
|
||||
}
|
||||
|
||||
// Force external links to be safe
|
||||
if ($tag === 'a') {
|
||||
$href = $child->getAttribute('href');
|
||||
if ($href && ! static::isSafeUrl($href)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
$child->setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Recurse
|
||||
static::cleanNode($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap forbidden elements (replace with their children)
|
||||
foreach ($toUnwrap as $el) {
|
||||
while ($el->firstChild) {
|
||||
$node->insertBefore($el->firstChild, $el);
|
||||
}
|
||||
$node->removeChild($el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Very conservative URL whitelist.
|
||||
*/
|
||||
private static function isSafeUrl(string $url): bool
|
||||
{
|
||||
$lower = strtolower(trim($url));
|
||||
|
||||
// Allow relative paths and anchors
|
||||
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only allow http(s)
|
||||
return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count Unicode emoji in a string (basic heuristic).
|
||||
*/
|
||||
private static function countEmoji(string $text): int
|
||||
{
|
||||
// Match common emoji ranges
|
||||
preg_match_all(
|
||||
'/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u',
|
||||
$text,
|
||||
$matches
|
||||
);
|
||||
|
||||
return count($matches[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load and cache the Markdown converter.
|
||||
*/
|
||||
private static function getConverter(): MarkdownConverter
|
||||
{
|
||||
if (static::$converter === null) {
|
||||
$env = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
'max_nesting_level' => 10,
|
||||
]);
|
||||
$env->addExtension(new CommonMarkCoreExtension());
|
||||
$env->addExtension(new AutolinkExtension());
|
||||
$env->addExtension(new StrikethroughExtension());
|
||||
|
||||
static::$converter = new MarkdownConverter($env);
|
||||
}
|
||||
|
||||
return static::$converter;
|
||||
}
|
||||
}
|
||||
144
app/Services/FollowService.php
Normal file
144
app/Services/FollowService.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* FollowService
|
||||
*
|
||||
* Manages follow / unfollow operations on the user_followers table.
|
||||
* Convention:
|
||||
* follower_id = the user doing the following
|
||||
* user_id = the user being followed
|
||||
*
|
||||
* Counters in user_statistics are kept in sync atomically inside a transaction.
|
||||
*/
|
||||
final class FollowService
|
||||
{
|
||||
/**
|
||||
* Follow $targetId on behalf of $actorId.
|
||||
*
|
||||
* @return bool true if a new follow was created, false if already following
|
||||
*
|
||||
* @throws \InvalidArgumentException if self-follow attempted
|
||||
*/
|
||||
public function follow(int $actorId, int $targetId): bool
|
||||
{
|
||||
if ($actorId === $targetId) {
|
||||
throw new \InvalidArgumentException('Cannot follow yourself.');
|
||||
}
|
||||
|
||||
$inserted = false;
|
||||
|
||||
DB::transaction(function () use ($actorId, $targetId, &$inserted) {
|
||||
$rows = DB::table('user_followers')->insertOrIgnore([
|
||||
'user_id' => $targetId,
|
||||
'follower_id' => $actorId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($rows === 0) {
|
||||
// Already following – nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
$inserted = true;
|
||||
|
||||
// Increment following_count for actor, followers_count for target
|
||||
$this->incrementCounter($actorId, 'following_count');
|
||||
$this->incrementCounter($targetId, 'followers_count');
|
||||
});
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollow $targetId on behalf of $actorId.
|
||||
*
|
||||
* @return bool true if a follow row was removed, false if wasn't following
|
||||
*/
|
||||
public function unfollow(int $actorId, int $targetId): bool
|
||||
{
|
||||
if ($actorId === $targetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleted = false;
|
||||
|
||||
DB::transaction(function () use ($actorId, $targetId, &$deleted) {
|
||||
$rows = DB::table('user_followers')
|
||||
->where('user_id', $targetId)
|
||||
->where('follower_id', $actorId)
|
||||
->delete();
|
||||
|
||||
if ($rows === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = true;
|
||||
|
||||
$this->decrementCounter($actorId, 'following_count');
|
||||
$this->decrementCounter($targetId, 'followers_count');
|
||||
});
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle follow state. Returns the new following state.
|
||||
*/
|
||||
public function toggle(int $actorId, int $targetId): bool
|
||||
{
|
||||
if ($this->isFollowing($actorId, $targetId)) {
|
||||
$this->unfollow($actorId, $targetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->follow($actorId, $targetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isFollowing(int $actorId, int $targetId): bool
|
||||
{
|
||||
return DB::table('user_followers')
|
||||
->where('user_id', $targetId)
|
||||
->where('follower_id', $actorId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current followers_count for a user (from cached column, not live count).
|
||||
*/
|
||||
public function followersCount(int $userId): int
|
||||
{
|
||||
return (int) DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->value('followers_count');
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function incrementCounter(int $userId, string $column): void
|
||||
{
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $userId],
|
||||
[
|
||||
$column => DB::raw("COALESCE({$column}, 0) + 1"),
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(), // ignored on update
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function decrementCounter(int $userId, string $column): void
|
||||
{
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->where($column, '>', 0)
|
||||
->update([
|
||||
$column => DB::raw("{$column} - 1"),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
167
app/Services/LegacySmileyMapper.php
Normal file
167
app/Services/LegacySmileyMapper.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Centralized mapping from legacy GIF smiley codes to Unicode emoji.
|
||||
*
|
||||
* Usage:
|
||||
* $result = LegacySmileyMapper::convert($text);
|
||||
* $map = LegacySmileyMapper::getMap();
|
||||
*/
|
||||
class LegacySmileyMapper
|
||||
{
|
||||
/**
|
||||
* The canonical smiley-code → emoji map.
|
||||
* Keys must be unique; variants are listed via aliases.
|
||||
*/
|
||||
private static array $map = [
|
||||
// Core
|
||||
':beer' => '🍺',
|
||||
':clap' => '👏',
|
||||
':coffee' => '☕',
|
||||
':cry' => '😢',
|
||||
':lol' => '😂',
|
||||
':love' => '❤️',
|
||||
':HB' => '🎂',
|
||||
':wow' => '😮',
|
||||
// Extended legacy codes
|
||||
':smile' => '😊',
|
||||
':grin' => '😁',
|
||||
':wink' => '😉',
|
||||
':tongue' => '😛',
|
||||
':cool' => '😎',
|
||||
':angry' => '😠',
|
||||
':sad' => '😞',
|
||||
':laugh' => '😆',
|
||||
':hug' => '🤗',
|
||||
':thumb' => '👍',
|
||||
':thumbs' => '👍',
|
||||
':thumbsup' => '👍',
|
||||
':fire' => '🔥',
|
||||
':star' => '⭐',
|
||||
':heart' => '❤️',
|
||||
':broken' => '💔',
|
||||
':music' => '🎵',
|
||||
':note' => '🎶',
|
||||
':art' => '🎨',
|
||||
':camera' => '📷',
|
||||
':gift' => '🎁',
|
||||
':cake' => '🎂',
|
||||
':wave' => '👋',
|
||||
':ok' => '👌',
|
||||
':pray' => '🙏',
|
||||
':think' => '🤔',
|
||||
':eyes' => '👀',
|
||||
':rainbow' => '🌈',
|
||||
':sun' => '☀️',
|
||||
':moon' => '🌙',
|
||||
':party' => '🎉',
|
||||
':bomb' => '💣',
|
||||
':skull' => '💀',
|
||||
':alien' => '👽',
|
||||
':robot' => '🤖',
|
||||
':poop' => '💩',
|
||||
':money' => '💰',
|
||||
':bulb' => '💡',
|
||||
':check' => '✅',
|
||||
':x' => '❌',
|
||||
':warning' => '⚠️',
|
||||
':question' => '❓',
|
||||
':exclamation' => '❗',
|
||||
':100' => '💯',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert all legacy smiley codes in $text to Unicode emoji.
|
||||
* Only replaces codes that are surrounded by whitespace or start/end of string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function convert(string $text): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
foreach (static::$map as $code => $emoji) {
|
||||
// Use word-boundary-style: the code must be followed by whitespace,
|
||||
// end of string, or punctuation — not part of a word.
|
||||
$escaped = preg_quote($code, '/');
|
||||
$text = preg_replace(
|
||||
'/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um',
|
||||
$emoji,
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all codes that are present in the given text (for reporting).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function detect(string $text): array
|
||||
{
|
||||
$found = [];
|
||||
foreach (array_keys(static::$map) as $code) {
|
||||
$escaped = preg_quote($code, '/');
|
||||
if (preg_match('/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um', $text)) {
|
||||
$found[] = $code;
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse consecutive runs of the same emoji that exceed $maxRun repetitions.
|
||||
*
|
||||
* Transforms e.g. "🍺 🍺 🍺 🍺 🍺 🍺 🍺 🍺" (8×) → "🍺 🍺 🍺 🍺 🍺 ×8"
|
||||
* so that spam/flood content is stored compactly and rendered readably.
|
||||
*
|
||||
* Both whitespace-separated ("🍺 🍺 🍺") and run-together ("🍺🍺🍺") forms
|
||||
* are collapsed. Only emoji from the common Unicode blocks are affected;
|
||||
* regular text is never touched.
|
||||
*
|
||||
* @param int $maxRun Maximum number of identical emoji to keep (default 5).
|
||||
*/
|
||||
public static function collapseFlood(string $text, int $maxRun = 5): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$limit = max(1, $maxRun);
|
||||
|
||||
// Match one emoji "unit" (codepoint from common ranges + optional variation
|
||||
// selector U+FE0E / U+FE0F), followed by $limit or more repetitions of
|
||||
// (optional horizontal whitespace + the same unit).
|
||||
// The \1 backreference works byte-for-byte in UTF-8, so it correctly
|
||||
// matches the same multi-byte sequence each time.
|
||||
$pattern = '/([\x{1F000}-\x{1FFFF}\x{2600}-\x{27EF}][\x{FE0E}\x{FE0F}]?)'
|
||||
. '([ \t]*\1){' . $limit . ',}/u';
|
||||
|
||||
return preg_replace_callback(
|
||||
$pattern,
|
||||
static function (array $m) use ($limit): string {
|
||||
$unit = $m[1];
|
||||
// substr_count is byte-safe and correct for multi-byte sequences.
|
||||
$count = substr_count($m[0], $unit);
|
||||
return str_repeat($unit . ' ', $limit - 1) . $unit . ' ×' . $count;
|
||||
},
|
||||
$text
|
||||
) ?? $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full mapping array.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getMap(): array
|
||||
{
|
||||
return static::$map;
|
||||
}
|
||||
}
|
||||
68
app/Services/Messaging/MessageNotificationService.php
Normal file
68
app/Services/Messaging/MessageNotificationService.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MessageNotificationService
|
||||
{
|
||||
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
|
||||
{
|
||||
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipientIds = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('left_at')
|
||||
->where('user_id', '!=', $sender->id)
|
||||
->where('is_muted', false)
|
||||
->where('is_archived', false)
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if (empty($recipientIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipientRows = User::query()
|
||||
->whereIn('id', $recipientIds)
|
||||
->get()
|
||||
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($recipientRows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$preview = Str::limit((string) $message->body, 120, '…');
|
||||
$now = now();
|
||||
|
||||
$rows = array_map(static fn (int $recipientId) => [
|
||||
'user_id' => $recipientId,
|
||||
'type' => 'message',
|
||||
'data' => json_encode([
|
||||
'conversation_id' => $conversation->id,
|
||||
'sender_id' => $sender->id,
|
||||
'sender_name' => $sender->username,
|
||||
'preview' => $preview,
|
||||
'message_id' => $message->id,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'read_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
], $recipientRows);
|
||||
|
||||
DB::table('notifications')->insert($rows);
|
||||
}
|
||||
}
|
||||
50
app/Services/Messaging/MessageSearchIndexer.php
Normal file
50
app/Services/Messaging/MessageSearchIndexer.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Jobs\DeleteMessageFromIndexJob;
|
||||
use App\Jobs\IndexMessageJob;
|
||||
use App\Models\Message;
|
||||
|
||||
class MessageSearchIndexer
|
||||
{
|
||||
public function indexMessage(Message $message): void
|
||||
{
|
||||
IndexMessageJob::dispatch($message->id);
|
||||
}
|
||||
|
||||
public function updateMessage(Message $message): void
|
||||
{
|
||||
IndexMessageJob::dispatch($message->id);
|
||||
}
|
||||
|
||||
public function deleteMessage(Message $message): void
|
||||
{
|
||||
DeleteMessageFromIndexJob::dispatch($message->id);
|
||||
}
|
||||
|
||||
public function rebuildConversation(int $conversationId): void
|
||||
{
|
||||
Message::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('deleted_at')
|
||||
->select('id')
|
||||
->chunkById(200, function ($messages): void {
|
||||
foreach ($messages as $message) {
|
||||
IndexMessageJob::dispatch((int) $message->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function rebuildAll(): void
|
||||
{
|
||||
Message::query()
|
||||
->whereNull('deleted_at')
|
||||
->select('id')
|
||||
->chunkById(500, function ($messages): void {
|
||||
foreach ($messages as $message) {
|
||||
IndexMessageJob::dispatch((int) $message->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
290
app/Services/UserStatsService.php
Normal file
290
app/Services/UserStatsService.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexUserJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* UserStatsService – single source of truth for user_statistics counters.
|
||||
*
|
||||
* All counter updates MUST go through this service.
|
||||
* No direct increments in controllers or jobs.
|
||||
*
|
||||
* Design:
|
||||
* - Atomic SQL increments (no read-modify-write races).
|
||||
* - Negative counters are prevented at the SQL level (WHERE col > 0).
|
||||
* - ensureRow() upserts the row before any counter touch.
|
||||
* - recomputeUser() rebuilds all columns from authoritative tables.
|
||||
*/
|
||||
final class UserStatsService
|
||||
{
|
||||
// ─── Row management ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Guarantee a user_statistics row exists for the given user.
|
||||
* Safe to call before every increment.
|
||||
*/
|
||||
public function ensureRow(int $userId): void
|
||||
{
|
||||
DB::table('user_statistics')->insertOrIgnore([
|
||||
'user_id' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Increment helpers ────────────────────────────────────────────────────
|
||||
|
||||
public function incrementUploads(int $userId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($userId);
|
||||
$this->inc($userId, 'uploads_count', $by);
|
||||
$this->touchActive($userId);
|
||||
$this->reindex($userId);
|
||||
}
|
||||
|
||||
public function decrementUploads(int $userId, int $by = 1): void
|
||||
{
|
||||
$this->dec($userId, 'uploads_count', $by);
|
||||
$this->reindex($userId);
|
||||
}
|
||||
|
||||
public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'downloads_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'artwork_views_received_count', $by);
|
||||
// Views are high-frequency – do NOT reindex on every view.
|
||||
}
|
||||
|
||||
public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'awards_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->dec($creatorUserId, 'awards_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'favorites_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->dec($creatorUserId, 'favorites_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'comments_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->dec($creatorUserId, 'comments_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($creatorUserId);
|
||||
$this->inc($creatorUserId, 'reactions_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void
|
||||
{
|
||||
$this->dec($creatorUserId, 'reactions_received_count', $by);
|
||||
$this->reindex($creatorUserId);
|
||||
}
|
||||
|
||||
public function incrementProfileViews(int $userId, int $by = 1): void
|
||||
{
|
||||
$this->ensureRow($userId);
|
||||
$this->inc($userId, 'profile_views_count', $by);
|
||||
}
|
||||
|
||||
// ─── Timestamp helpers ────────────────────────────────────────────────────
|
||||
|
||||
public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void
|
||||
{
|
||||
$this->ensureRow($userId);
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
'last_upload_at' => ($timestamp ?? now())->toDateTimeString(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void
|
||||
{
|
||||
$this->ensureRow($userId);
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
'last_active_at' => ($timestamp ?? now())->toDateTimeString(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Recompute ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recompute all counters for a single user from authoritative tables.
|
||||
* Returns the computed values (array) without writing when $dryRun=true.
|
||||
*
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function recomputeUser(int $userId, bool $dryRun = false): array
|
||||
{
|
||||
$computed = [
|
||||
'uploads_count' => (int) DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->count(),
|
||||
|
||||
'downloads_received_count' => (int) DB::table('artwork_downloads as d')
|
||||
->join('artworks as a', 'a.id', '=', 'd.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->count(),
|
||||
|
||||
'artwork_views_received_count' => (int) DB::table('artwork_stats as s')
|
||||
->join('artworks as a', 'a.id', '=', 's.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->sum('s.views'),
|
||||
|
||||
'awards_received_count' => (int) DB::table('artwork_awards as aw')
|
||||
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->count(),
|
||||
|
||||
'favorites_received_count' => (int) DB::table('artwork_favourites as f')
|
||||
->join('artworks as a', 'a.id', '=', 'f.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->count(),
|
||||
|
||||
'comments_received_count' => (int) DB::table('artwork_comments as c')
|
||||
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->whereNull('c.deleted_at')
|
||||
->count(),
|
||||
|
||||
'reactions_received_count' => (int) DB::table('artwork_reactions as r')
|
||||
->join('artworks as a', 'a.id', '=', 'r.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->count(),
|
||||
|
||||
'followers_count' => (int) DB::table('user_followers')
|
||||
->where('user_id', $userId)
|
||||
->count(),
|
||||
|
||||
'following_count' => (int) DB::table('user_followers')
|
||||
->where('follower_id', $userId)
|
||||
->count(),
|
||||
|
||||
'last_upload_at' => DB::table('artworks')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->max('created_at'),
|
||||
];
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->ensureRow($userId);
|
||||
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update(array_merge($computed, ['updated_at' => now()]));
|
||||
|
||||
$this->reindex($userId);
|
||||
}
|
||||
|
||||
return $computed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute stats for all users in chunks.
|
||||
*
|
||||
* @param int $chunk Users per chunk.
|
||||
*/
|
||||
public function recomputeAll(int $chunk = 1000): void
|
||||
{
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunk($chunk, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
$this->recomputeUser($user->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function inc(int $userId, string $column, int $by = 1): void
|
||||
{
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function dec(int $userId, string $column, int $by = 1): void
|
||||
{
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->where($column, '>', 0)
|
||||
->update([
|
||||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function touchActive(int $userId): void
|
||||
{
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
'last_active_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Meilisearch reindex for the user.
|
||||
* Uses IndexUserJob to avoid blocking the request.
|
||||
*/
|
||||
private function reindex(int $userId): void
|
||||
{
|
||||
IndexUserJob::dispatch($userId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user