diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d9bb00a..85d8a9063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev) + +### Features +- WebGL photo filters ([#5374](https://github.com/pixelfed/pixelfed/pull/5374)) + ### OAuth - Fix oauth oob (urn:ietf:wg:oauth:2.0:oob) support. ([8afbdb03](https://github.com/pixelfed/pixelfed/commit/8afbdb03)) @@ -19,6 +23,9 @@ - Update StatusStatelessTransformer, refactor the caption field to be compliant with the MastoAPI. Fixes #5364 ([79039ba5](https://github.com/pixelfed/pixelfed/commit/79039ba5)) - Update mailgun config, add endpoint and scheme ([271d5114](https://github.com/pixelfed/pixelfed/commit/271d5114)) - Update search and status logic to fix postgres bugs ([8c39ef4](https://github.com/pixelfed/pixelfed/commit/8c39ef4)) +- Update db, fix sqlite migrations ([#5379](https://github.com/pixelfed/pixelfed/pull/5379)) +- Update CatchUnoptimizedMedia command, make 1hr limit opt-in ([99b15b73](https://github.com/pixelfed/pixelfed/commit/99b15b73)) +- Update IG, fix Instagram import. Closes #5411 ([fd434aec](https://github.com/pixelfed/pixelfed/commit/fd434aec)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.12.4 (2024-11-08)](https://github.com/pixelfed/pixelfed/compare/v0.12.4...dev) diff --git a/app/Console/Commands/CatchUnoptimizedMedia.php b/app/Console/Commands/CatchUnoptimizedMedia.php index a62bd8651..a5bb22f98 100644 --- a/app/Console/Commands/CatchUnoptimizedMedia.php +++ b/app/Console/Commands/CatchUnoptimizedMedia.php @@ -40,10 +40,11 @@ class CatchUnoptimizedMedia extends Command */ public function handle() { + $hasLimit = (bool) config('media.image_optimize.catch_unoptimized_media_hour_limit'); Media::whereNull('processed_at') - ->where('created_at', '>', now()->subHours(1)) - ->where('skip_optimize', '!=', true) - ->whereNull('remote_url') + ->when($hasLimit, function($q, $hasLimit) { + $q->where('created_at', '>', now()->subHours(1)); + })->whereNull('remote_url') ->whereNotNull('status_id') ->whereNotNull('media_path') ->whereIn('mime', [ @@ -52,6 +53,7 @@ class CatchUnoptimizedMedia extends Command ]) ->chunk(50, function($medias) { foreach ($medias as $media) { + if ($media->skip_optimize) continue; ImageOptimize::dispatch($media); } }); diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 3a309145c..4cb8b919d 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -3494,7 +3494,7 @@ class ApiV1Controller extends Controller return []; } - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; + $defaultCaption = ""; $content = $request->filled('status') ? strip_tags($request->input('status')) : $defaultCaption; $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false); $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null; diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index bb82521b6..c84dcae1c 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -1292,7 +1292,7 @@ class ApiV1Dot1Controller extends Controller if ($user->last_active_at == null) { return []; } - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; + $defaultCaption = ""; $content = $request->filled('status') ? strip_tags(Purify::clean($request->input('status'))) : $defaultCaption; $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false); $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null; diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index f529b0ebd..3521a61d5 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -55,14 +55,12 @@ class CommentController extends Controller } $reply = DB::transaction(function () use ($comment, $status, $profile, $nsfw) { - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; - $scope = $profile->is_private == true ? 'private' : 'public'; $reply = new Status; $reply->profile_id = $profile->id; $reply->is_nsfw = $nsfw; $reply->caption = Purify::clean($comment); - $reply->rendered = $defaultCaption; + $reply->rendered = ""; $reply->in_reply_to_id = $status->id; $reply->in_reply_to_profile_id = $status->profile_id; $reply->scope = $scope; diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 2069e2693..4e65339c3 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -570,7 +570,7 @@ class ComposeController extends Controller $status->cw_summary = $request->input('spoiler_text'); } - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; + $defaultCaption = ""; $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption; $status->rendered = $defaultCaption; $status->scope = 'draft'; diff --git a/app/Media.php b/app/Media.php index 30a1b33bd..9ecc7b17e 100644 --- a/app/Media.php +++ b/app/Media.php @@ -22,6 +22,7 @@ class Media extends Model protected $casts = [ 'srcset' => 'array', 'deleted_at' => 'datetime', + 'skip_optimize' => 'boolean' ]; public function status() diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index 4f85dabbe..b0191ba7b 100644 --- a/app/Services/ImportService.php +++ b/app/Services/ImportService.php @@ -14,7 +14,7 @@ class ImportService if($userId > 999999) { return; } - if($year < 9 || $year > 23) { + if($year < 9 || $year > (int) now()->addYear()->format('y')) { return; } if($month < 1 || $month > 12) { diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index bda1f4043..526150e4a 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -24,7 +24,6 @@ use App\Status; use App\Util\Media\License; use Cache; use Carbon\Carbon; -use Illuminate\Support\Str; use Illuminate\Validation\Rule; use League\Uri\Exceptions\UriException; use League\Uri\Uri; @@ -33,16 +32,32 @@ use Validator; class Helpers { - public static function validateObject($data) + private const PUBLIC_TIMELINE = 'https://www.w3.org/ns/activitystreams#Public'; + + private const CACHE_TTL = 14440; + + private const URL_CACHE_PREFIX = 'helpers:url:'; + + private const FETCH_CACHE_TTL = 15; + + private const LOCALHOST_DOMAINS = [ + 'localhost', + '127.0.0.1', + '::1', + 'broadcasthost', + 'ip6-localhost', + 'ip6-loopback', + ]; + + /** + * Validate an ActivityPub object + */ + public static function validateObject(array $data): bool { $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone']; - $valid = Validator::make($data, [ - 'type' => [ - 'required', - 'string', - Rule::in($verbs), - ], + return Validator::make($data, [ + 'type' => ['required', 'string', Rule::in($verbs)], 'id' => 'required|string', 'actor' => 'required|string|url', 'object' => 'required', @@ -50,105 +65,88 @@ class Helpers 'object.attributedTo' => 'required_if:type,Create|url', 'published' => 'required_if:type,Create|date', ])->passes(); - - return $valid; } - public static function verifyAttachments($data) + /** + * Validate media attachments + */ + public static function verifyAttachments(array $data): bool { if (! isset($data['object']) || empty($data['object'])) { $data = ['object' => $data]; } $activity = $data['object']; - $mimeTypes = explode(',', config_cache('pixelfed.media_types')); - $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video'] : ['Document', 'Image']; - - // Peertube - // $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video', 'Link'] : ['Document', 'Image']; + $mediaTypes = in_array('video/mp4', $mimeTypes) ? + ['Document', 'Image', 'Video'] : + ['Document', 'Image']; if (! isset($activity['attachment']) || empty($activity['attachment'])) { return false; } - // peertube - // $attachment = is_array($activity['url']) ? - // collect($activity['url']) - // ->filter(function($media) { - // return $media['type'] == 'Link' && $media['mediaType'] == 'video/mp4'; - // }) - // ->take(1) - // ->values() - // ->toArray()[0] : $activity['attachment']; - - $attachment = $activity['attachment']; - - $valid = Validator::make($attachment, [ - '*.type' => [ - 'required', - 'string', - Rule::in($mediaTypes), - ], + return Validator::make($activity['attachment'], [ + '*.type' => ['required', 'string', Rule::in($mediaTypes)], '*.url' => 'required|url', - '*.mediaType' => [ - 'required', - 'string', - Rule::in($mimeTypes), - ], + '*.mediaType' => ['required', 'string', Rule::in($mimeTypes)], '*.name' => 'sometimes|nullable|string', '*.blurhash' => 'sometimes|nullable|string|min:6|max:164', '*.width' => 'sometimes|nullable|integer|min:1|max:5000', '*.height' => 'sometimes|nullable|integer|min:1|max:5000', ])->passes(); - - return $valid; } - public static function normalizeAudience($data, $localOnly = true) + /** + * Normalize ActivityPub audience + */ + public static function normalizeAudience(array $data, bool $localOnly = true): ?array { if (! isset($data['to'])) { - return; + return null; } - $audience = []; - $audience['to'] = []; - $audience['cc'] = []; - $scope = 'private'; + $audience = [ + 'to' => [], + 'cc' => [], + 'scope' => 'private', + ]; if (is_array($data['to']) && ! empty($data['to'])) { foreach ($data['to'] as $to) { - if ($to == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'public'; + if ($to == self::PUBLIC_TIMELINE) { + $audience['scope'] = 'public'; continue; } $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to); - if ($url != false) { - array_push($audience['to'], $url); + if ($url) { + $audience['to'][] = $url; } } } if (is_array($data['cc']) && ! empty($data['cc'])) { foreach ($data['cc'] as $cc) { - if ($cc == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'unlisted'; + if ($cc == self::PUBLIC_TIMELINE) { + $audience['scope'] = 'unlisted'; continue; } $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc); - if ($url != false) { - array_push($audience['cc'], $url); + if ($url) { + $audience['cc'][] = $url; } } } - $audience['scope'] = $scope; return $audience; } - public static function userInAudience($profile, $data) + /** + * Check if user is in audience + */ + public static function userInAudience(Profile $profile, array $data): bool { $audience = self::normalizeAudience($data); $url = $profile->permalink(); @@ -156,96 +154,167 @@ class Helpers return in_array($url, $audience['to']) || in_array($url, $audience['cc']); } - public static function validateUrl($url = null, $disableDNSCheck = false, $forceBanCheck = false) + /** + * Validate URL with various security and format checks + */ + public static function validateUrl(?string $url, bool $disableDNSCheck = false, bool $forceBanCheck = false): string|bool { - if (is_array($url) && ! empty($url)) { - $url = $url[0]; - } - if (! $url || strlen($url) === 0) { + if (! $normalizedUrl = self::normalizeUrl($url)) { return false; } + try { - $uri = Uri::new($url); + $uri = Uri::new($normalizedUrl); - if (! $uri) { - return false; - } - - if ($uri->getScheme() !== 'https') { + if (! self::isValidUri($uri)) { return false; } $host = $uri->getHost(); - - if (! $host || $host === '') { + if (! self::isValidHost($host)) { return false; } - if (! filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + if (! self::passesSecurityChecks($host, $disableDNSCheck, $forceBanCheck)) { return false; } - if (! str_contains($host, '.')) { - return false; - } - - $localhosts = [ - 'localhost', - '127.0.0.1', - '::1', - 'broadcasthost', - 'ip6-localhost', - 'ip6-loopback', - ]; - - if (in_array($host, $localhosts)) { - return false; - } - - if ($disableDNSCheck !== true && app()->environment() === 'production' && (bool) config('security.url.verify_dns')) { - $hash = hash('sha256', $host); - $key = "helpers:url:valid-dns:sha256-{$hash}"; - $domainValidDns = Cache::remember($key, 14440, function () use ($host) { - return DomainService::hasValidDns($host); - }); - if (! $domainValidDns) { - return false; - } - } - - if ($forceBanCheck || $disableDNSCheck !== true && app()->environment() === 'production') { - $bannedInstances = InstanceService::getBannedDomains(); - if (in_array($host, $bannedInstances)) { - return false; - } - } - return $uri->toString(); + } catch (UriException $e) { return false; } } - public static function validateLocalUrl($url) + /** + * Normalize URL input + */ + private static function normalizeUrl(?string $url): ?string + { + if (is_array($url) && ! empty($url)) { + $url = $url[0]; + } + + return (! $url || strlen($url) === 0) ? null : $url; + } + + /** + * Validate basic URI requirements + */ + private static function isValidUri(Uri $uri): bool + { + return $uri && $uri->getScheme() === 'https'; + } + + /** + * Validate host requirements + */ + private static function isValidHost(?string $host): bool + { + if (! $host || $host === '') { + return false; + } + + if (! filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + return false; + } + + if (! str_contains($host, '.')) { + return false; + } + + if (in_array($host, self::LOCALHOST_DOMAINS)) { + return false; + } + + return true; + } + + /** + * Check DNS and banned status if required + */ + private static function passesSecurityChecks(string $host, bool $disableDNSCheck, bool $forceBanCheck): bool + { + if ($disableDNSCheck !== true && self::shouldCheckDNS()) { + if (! self::hasValidDNS($host)) { + return false; + } + } + + if ($forceBanCheck || self::shouldCheckBans()) { + if (self::isHostBanned($host)) { + return false; + } + } + + return true; + } + + /** + * Check if DNS validation is required + */ + private static function shouldCheckDNS(): bool + { + return app()->environment() === 'production' && + (bool) config('security.url.verify_dns'); + } + + /** + * Validate domain DNS records + */ + private static function hasValidDNS(string $host): bool + { + $hash = hash('sha256', $host); + $key = self::URL_CACHE_PREFIX."valid-dns:sha256-{$hash}"; + + return Cache::remember($key, self::CACHE_TTL, function () use ($host) { + return DomainService::hasValidDns($host); + }); + } + + /** + * Check if domain bans should be validated + */ + private static function shouldCheckBans(): bool + { + return app()->environment() === 'production'; + } + + /** + * Check if host is in banned domains list + */ + private static function isHostBanned(string $host): bool + { + $bannedInstances = InstanceService::getBannedDomains(); + + return in_array($host, $bannedInstances); + } + + /** + * Validate local URL + */ + private static function validateLocalUrl(string $url): string|bool { $url = self::validateUrl($url); - if ($url == true) { + if ($url) { $domain = config('pixelfed.domain.app'); - $uri = Uri::new($url); $host = $uri->getHost(); + if (! $host || empty($host)) { return false; } - $url = strtolower($domain) === strtolower($host) ? $url : false; - return $url; + return strtolower($domain) === strtolower($host) ? $url : false; } return false; } - public static function zttpUserAgent() + /** + * Get user agent string + */ + public static function zttpUserAgent(): array { $version = config('pixelfed.version'); $url = config('app.url'); @@ -303,7 +372,7 @@ class Helpers try { $date = Carbon::parse($timestamp); $now = Carbon::now(); - $tenYearsAgo = $now->copy()->subYears(10); + $tenYearsAgo = $now->copy()->subYears(20); $isMoreThanTenYearsOld = $date->lt($tenYearsAgo); $tomorrow = $now->copy()->addDay(); $isMoreThanOneDayFuture = $date->gt($tomorrow); @@ -314,258 +383,230 @@ class Helpers } } - public static function statusFirstOrFetch($url, $replyTo = false) + /** + * Fetch or create a status from URL + */ + public static function statusFirstOrFetch(string $url, bool $replyTo = false): ?Status { - $url = self::validateUrl($url); - if ($url == false) { - return; + if (! $validUrl = self::validateUrl($url)) { + return null; } - $host = parse_url($url, PHP_URL_HOST); - $local = config('pixelfed.domain.app') == $host ? true : false; + if ($status = self::findExistingStatus($url)) { + return $status; + } - if ($local) { + return self::createStatusFromUrl($url, $replyTo); + } + + /** + * Find existing status by URL + */ + private static function findExistingStatus(string $url): ?Status + { + $host = parse_url($url, PHP_URL_HOST); + + if (self::isLocalDomain($host)) { $id = (int) last(explode('/', $url)); - return Status::whereNotIn('scope', ['draft', 'archived'])->findOrFail($id); + return Status::whereNotIn('scope', ['draft', 'archived']) + ->findOrFail($id); } - $cached = Status::whereNotIn('scope', ['draft', 'archived']) - ->whereUri($url) - ->orWhere('object_url', $url) + return Status::whereNotIn('scope', ['draft', 'archived']) + ->where(function ($query) use ($url) { + $query->whereUri($url) + ->orWhere('object_url', $url); + }) ->first(); + } - if ($cached) { - return $cached; - } - + /** + * Create a new status from ActivityPub data + */ + private static function createStatusFromUrl(string $url, bool $replyTo): ?Status + { $res = self::fetchFromUrl($url); - if (! $res || empty($res) || isset($res['error']) || ! isset($res['@context']) || ! isset($res['published'])) { - return; + if (! $res || ! self::isValidStatusData($res)) { + return null; } if (! self::validateTimestamp($res['published'])) { - return; + return null; } - if (config('autospam.live_filters.enabled')) { - $filters = config('autospam.live_filters.filters'); - if (! empty($filters) && isset($res['content']) && ! empty($res['content']) && strlen($filters) > 3) { - $filters = array_map('trim', explode(',', $filters)); - $content = $res['content']; - foreach ($filters as $filter) { - $filter = trim(strtolower($filter)); - if (! $filter || ! strlen($filter)) { - continue; - } - if (str_contains(strtolower($content), $filter)) { - return; - } - } - } + if (! self::passesContentFilters($res)) { + return null; } - if (isset($res['object'])) { - $activity = $res; - } else { - $activity = ['object' => $res]; + $activity = isset($res['object']) ? $res : ['object' => $res]; + + if (! $profile = self::getStatusProfile($activity)) { + return null; } - $scope = 'private'; - - $cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false; - - if (isset($res['to']) == true) { - if (is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) { - $scope = 'public'; - } - if (is_string($res['to']) && $res['to'] == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'public'; - } + if (! self::validateStatusUrls($url, $activity)) { + return null; } - if (isset($res['cc']) == true) { - if (is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) { - $scope = 'unlisted'; - } - if (is_string($res['cc']) && $res['cc'] == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'unlisted'; - } - } - - if (config('costar.enabled') == true) { - $blockedKeywords = config('costar.keyword.block'); - if ($blockedKeywords !== null) { - $keywords = config('costar.keyword.block'); - foreach ($keywords as $kw) { - if (Str::contains($res['content'], $kw) == true) { - return; - } - } - } - - $unlisted = config('costar.domain.unlisted'); - if (in_array(parse_url($url, PHP_URL_HOST), $unlisted) == true) { - $unlisted = true; - $scope = 'unlisted'; - } else { - $unlisted = false; - } - - $cwDomains = config('costar.domain.cw'); - if (in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) { - $cw = true; - } - } - - $id = isset($res['id']) ? self::pluckval($res['id']) : self::pluckval($url); - $idDomain = parse_url($id, PHP_URL_HOST); - $urlDomain = parse_url($url, PHP_URL_HOST); - - if ($idDomain && $urlDomain && strtolower($idDomain) !== strtolower($urlDomain)) { - return; - } - - if (! self::validateUrl($id)) { - return; - } - - if (! isset($activity['object']['attributedTo'])) { - return; - } - - $attributedTo = is_string($activity['object']['attributedTo']) ? - $activity['object']['attributedTo'] : - (is_array($activity['object']['attributedTo']) ? - collect($activity['object']['attributedTo']) - ->filter(function ($o) { - return $o && isset($o['type']) && $o['type'] == 'Person'; - }) - ->pluck('id') - ->first() : null - ); - - if ($attributedTo) { - $actorDomain = parse_url($attributedTo, PHP_URL_HOST); - if (! self::validateUrl($attributedTo) || - $idDomain !== $actorDomain || - $actorDomain !== $urlDomain - ) { - return; - } - } - - if ($idDomain !== $urlDomain) { - return; - } - - $profile = self::profileFirstOrNew($attributedTo); - - if (! $profile) { - return; - } - - if (isset($activity['object']['inReplyTo']) && ! empty($activity['object']['inReplyTo']) || $replyTo == true) { - $reply_to = self::statusFirstOrFetch(self::pluckval($activity['object']['inReplyTo']), false); - if ($reply_to) { - $blocks = UserFilterService::blocks($reply_to->profile_id); - if (in_array($profile->id, $blocks)) { - return; - } - } - $reply_to = optional($reply_to)->id; - } else { - $reply_to = null; - } - $ts = self::pluckval($res['published']); - - if ($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) { - $scope = 'unlisted'; - } - - if (in_array($urlDomain, InstanceService::getNsfwDomains())) { - $cw = true; - } + $reply_to = self::getReplyToId($activity, $profile, $replyTo); + $scope = self::getScope($activity, $url); + $cw = self::getSensitive($activity, $url); if ($res['type'] === 'Question') { - $status = self::storePoll( + return self::storePoll( $profile, $res, $url, - $ts, + $res['published'], $reply_to, $cw, $scope, - $id + $activity['id'] ?? $url ); - - return $status; - } else { - $status = self::storeStatus($url, $profile, $res); } - return $status; + return self::storeStatus($url, $profile, $res); } - public static function storeStatus($url, $profile, $activity) + /** + * Validate status data + */ + private static function isValidStatusData(?array $res): bool { - $originalUrl = $url; - $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($activity['url']); - $url = isset($activity['url']) && is_string($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id); - $idDomain = parse_url($id, PHP_URL_HOST); - $urlDomain = parse_url($url, PHP_URL_HOST); - $originalUrlDomain = parse_url($originalUrl, PHP_URL_HOST); - if (! self::validateUrl($id) || ! self::validateUrl($url)) { - return; + return $res && + ! empty($res) && + ! isset($res['error']) && + isset($res['@context']) && + isset($res['published']); + } + + /** + * Check if content passes filters + */ + private static function passesContentFilters(array $res): bool + { + if (! config('autospam.live_filters.enabled')) { + return true; } - if (strtolower($originalUrlDomain) !== strtolower($idDomain) || - strtolower($originalUrlDomain) !== strtolower($urlDomain)) { - return; + $filters = config('autospam.live_filters.filters'); + if (empty($filters) || ! isset($res['content']) || strlen($filters) <= 3) { + return true; + } + + $filters = array_map('trim', explode(',', $filters)); + $content = strtolower($res['content']); + + foreach ($filters as $filter) { + $filter = trim(strtolower($filter)); + if ($filter && str_contains($content, $filter)) { + return false; + } + } + + return true; + } + + /** + * Get profile for status + */ + private static function getStatusProfile(array $activity): ?Profile + { + if (! isset($activity['object']['attributedTo'])) { + return null; + } + + $attributedTo = self::extractAttributedTo($activity['object']['attributedTo']); + + return $attributedTo ? self::profileFirstOrNew($attributedTo) : null; + } + + /** + * Extract attributed to value + */ + private static function extractAttributedTo(string|array $attributedTo): ?string + { + if (is_string($attributedTo)) { + return $attributedTo; + } + + if (is_array($attributedTo)) { + return collect($attributedTo) + ->filter(fn ($o) => $o && isset($o['type']) && $o['type'] == 'Person') + ->pluck('id') + ->first(); + } + + return null; + } + + /** + * Validate status URLs match + */ + private static function validateStatusUrls(string $url, array $activity): bool + { + $id = isset($activity['id']) ? + self::pluckval($activity['id']) : + self::pluckval($url); + + $idDomain = parse_url($id, PHP_URL_HOST); + $urlDomain = parse_url($url, PHP_URL_HOST); + + return $idDomain && + $urlDomain && + strtolower($idDomain) === strtolower($urlDomain); + } + + /** + * Get reply-to status ID + */ + private static function getReplyToId(array $activity, Profile $profile, bool $replyTo): ?int + { + $inReplyTo = $activity['object']['inReplyTo'] ?? null; + + if (! $inReplyTo && ! $replyTo) { + return null; + } + + $reply = self::statusFirstOrFetch(self::pluckval($inReplyTo), false); + + if (! $reply) { + return null; + } + + $blocks = UserFilterService::blocks($reply->profile_id); + + return in_array($profile->id, $blocks) ? null : $reply->id; + } + + /** + * Store a new regular status + */ + public static function storeStatus(string $url, Profile $profile, array $activity): Status + { + $originalUrl = $url; + $id = self::getStatusId($activity, $url); + $url = self::getStatusUrl($activity, $id); + + if (! self::validateStatusDomains($originalUrl, $id, $url)) { + throw new \Exception('Invalid status domains'); } $reply_to = self::getReplyTo($activity); - $ts = self::pluckval($activity['published']); $scope = self::getScope($activity, $url); + $commentsDisabled = $activity['commentsEnabled'] ?? false; $cw = self::getSensitive($activity, $url); - $pid = is_object($profile) ? $profile->id : (is_array($profile) ? $profile['id'] : null); - $isUnlisted = is_object($profile) ? $profile->unlisted : (is_array($profile) ? $profile['unlisted'] : false); - $commentsDisabled = isset($activity['commentsEnabled']) ? ! boolval($activity['commentsEnabled']) : false; - if (! $pid) { - return; + if ($profile->unlisted) { + $scope = 'unlisted'; } - if ($scope == 'public') { - if ($isUnlisted == true) { - $scope = 'unlisted'; - } - } - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; - $status = Status::updateOrCreate( - [ - 'uri' => $url, - ], [ - 'profile_id' => $pid, - 'url' => $url, - 'object_url' => $id, - 'caption' => isset($activity['content']) ? Purify::clean(strip_tags($activity['content'])) : $defaultCaption, - 'rendered' => $defaultCaption, - 'created_at' => Carbon::parse($ts)->tz('UTC'), - 'in_reply_to_id' => $reply_to, - 'local' => false, - 'is_nsfw' => $cw, - 'scope' => $scope, - 'visibility' => $scope, - 'cw_summary' => ($cw == true && isset($activity['summary']) ? - Purify::clean(strip_tags($activity['summary'])) : null), - 'comments_disabled' => $commentsDisabled, - ] - ); + $status = self::createOrUpdateStatus($url, $profile, $id, $activity, $ts, $reply_to, $cw, $scope, $commentsDisabled); - if ($reply_to == null) { + if ($reply_to === null) { self::importNoteAttachment($activity, $status); } else { if (isset($activity['attachment']) && ! empty($activity['attachment'])) { @@ -578,34 +619,136 @@ class Helpers StatusTagsPipeline::dispatch($activity, $status); } + self::handleStatusPostProcessing($status, $profile->id, $url); + + return $status; + } + + /** + * Get status ID from activity + */ + private static function getStatusId(array $activity, string $url): string + { + return isset($activity['id']) ? + self::pluckval($activity['id']) : + self::pluckval($url); + } + + /** + * Get status URL from activity + */ + private static function getStatusUrl(array $activity, string $id): string + { + return isset($activity['url']) && is_string($activity['url']) ? + self::pluckval($activity['url']) : + self::pluckval($id); + } + + /** + * Validate status domain consistency + */ + private static function validateStatusDomains(string $originalUrl, string $id, string $url): bool + { + if (! self::validateUrl($id) || ! self::validateUrl($url)) { + return false; + } + + $originalDomain = parse_url($originalUrl, PHP_URL_HOST); + $idDomain = parse_url($id, PHP_URL_HOST); + $urlDomain = parse_url($url, PHP_URL_HOST); + + return strtolower($originalDomain) === strtolower($idDomain) && + strtolower($originalDomain) === strtolower($urlDomain); + } + + /** + * Create or update status record + */ + private static function createOrUpdateStatus( + string $url, + Profile $profile, + string $id, + array $activity, + string $ts, + ?int $reply_to, + bool $cw, + string $scope, + bool $commentsDisabled + ): Status { + $caption = isset($activity['content']) ? + Purify::clean($activity['content']) : + ''; + + return Status::updateOrCreate( + ['uri' => $url], + [ + 'profile_id' => $profile->id, + 'url' => $url, + 'object_url' => $id, + 'caption' => strip_tags($caption), + 'rendered' => $caption, + 'created_at' => Carbon::parse($ts)->tz('UTC'), + 'in_reply_to_id' => $reply_to, + 'local' => false, + 'is_nsfw' => $cw, + 'scope' => $scope, + 'visibility' => $scope, + 'cw_summary' => ($cw && isset($activity['summary'])) ? + Purify::clean(strip_tags($activity['summary'])) : + null, + 'comments_disabled' => $commentsDisabled, + ] + ); + } + + /** + * Handle post-creation status processing + */ + private static function handleStatusPostProcessing(Status $status, int $profileId, string $url): void + { if (config('instance.timeline.network.cached') && - $status->in_reply_to_id === null && - $status->reblog_of_id === null && - in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) && - $status->created_at->gt(now()->subHours(config('instance.timeline.network.max_hours_old'))) && - (config('instance.hide_nsfw_on_public_feeds') == true ? $status->is_nsfw == false : true) + self::isEligibleForNetwork($status) ) { - $filteredDomains = collect(InstanceService::getBannedDomains()) - ->merge(InstanceService::getUnlistedDomains()) - ->unique() - ->values() - ->toArray(); + $urlDomain = parse_url($url, PHP_URL_HOST); + $filteredDomains = self::getFilteredDomains(); + if (! in_array($urlDomain, $filteredDomains)) { - if (! $isUnlisted) { - NetworkTimelineService::add($status->id); - } + NetworkTimelineService::add($status->id); } } - AccountStatService::incrementPostCount($pid); + AccountStatService::incrementPostCount($profileId); if ($status->in_reply_to_id === null && in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) ) { - FeedInsertRemotePipeline::dispatch($status->id, $pid)->onQueue('feed'); + FeedInsertRemotePipeline::dispatch($status->id, $profileId) + ->onQueue('feed'); } + } - return $status; + /** + * Check if status is eligible for network timeline + */ + private static function isEligibleForNetwork(Status $status): bool + { + return $status->in_reply_to_id === null && + $status->reblog_of_id === null && + in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) && + $status->created_at->gt(now()->subHours(config('instance.timeline.network.max_hours_old'))) && + (config('instance.hide_nsfw_on_public_feeds') ? ! $status->is_nsfw : true); + } + + /** + * Get filtered domains list + */ + private static function getFilteredDomains(): array + { + return collect(InstanceService::getBannedDomains()) + ->merge(InstanceService::getUnlistedDomains()) + ->unique() + ->values() + ->toArray(); } public static function getSensitive($activity, $url) @@ -689,14 +832,14 @@ class Helpers return $option['replies']['totalItems'] ?? 0; })->toArray(); - $defaultCaption = config_cache('database.default') === 'mysql' ? null : ""; + $defaultCaption = ''; $status = new Status; $status->profile_id = $profile->id; $status->url = isset($res['url']) ? $res['url'] : $url; $status->uri = isset($res['url']) ? $res['url'] : $url; $status->object_url = $id; $status->caption = strip_tags(Purify::clean($res['content'])) ?? $defaultCaption; - $status->rendered = $defaultCaption; + $status->rendered = Purify::clean($res['content'] ?? $defaultCaption); $status->created_at = Carbon::parse($ts)->tz('UTC'); $status->in_reply_to_id = null; $status->local = false; @@ -730,185 +873,430 @@ class Helpers return self::statusFirstOrFetch($url); } - public static function importNoteAttachment($data, Status $status) + /** + * Process and store note attachments + */ + public static function importNoteAttachment(array $data, Status $status): void { - if (self::verifyAttachments($data) == false) { - // \Log::info('importNoteAttachment::failedVerification.', [$data['id']]); + if (! self::verifyAttachments($data)) { $status->viewType(); return; } - $attachments = isset($data['object']) ? $data['object']['attachment'] : $data['attachment']; - // peertube - // if(!$attachments) { - // $obj = isset($data['object']) ? $data['object'] : $data; - // $attachments = is_array($obj['url']) ? $obj['url'] : null; - // } - $user = $status->profile; - $storagePath = MediaPathService::get($user, 2); - $allowed = explode(',', config_cache('pixelfed.media_types')); + + $attachments = self::getAttachments($data); + $profile = $status->profile; + $storagePath = MediaPathService::get($profile, 2); + $allowedTypes = explode(',', config_cache('pixelfed.media_types')); foreach ($attachments as $key => $media) { - $type = $media['mediaType']; - $url = $media['url']; - $valid = self::validateUrl($url); - if (in_array($type, $allowed) == false || $valid == false) { + if (! self::isValidAttachment($media, $allowedTypes)) { continue; } - $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null; - $license = isset($media['license']) ? License::nameToId($media['license']) : null; - $caption = isset($media['name']) ? Purify::clean($media['name']) : null; - $width = isset($media['width']) ? $media['width'] : false; - $height = isset($media['height']) ? $media['height'] : false; - $media = new Media; - $media->blurhash = $blurhash; - $media->remote_media = true; - $media->status_id = $status->id; - $media->profile_id = $status->profile_id; - $media->user_id = null; - $media->media_path = $url; - $media->remote_url = $url; - $media->caption = $caption; - $media->order = $key + 1; - if ($width) { - $media->width = $width; - } - if ($height) { - $media->height = $height; - } - if ($license) { - $media->license = $license; - } - $media->mime = $type; - $media->version = 3; - $media->save(); - - if ((bool) config_cache('pixelfed.cloud_storage') == true) { - MediaStoragePipeline::dispatch($media); - } + $mediaModel = self::createMediaAttachment($media, $status, $key); + self::handleMediaStorage($mediaModel); } $status->viewType(); - } - public static function profileFirstOrNew($url) + /** + * Get attachments from ActivityPub data + */ + private static function getAttachments(array $data): array { - $url = self::validateUrl($url); - if ($url == false) { - return; + return isset($data['object']) ? + $data['object']['attachment'] : + $data['attachment']; + } + + /** + * Validate individual attachment + */ + private static function isValidAttachment(array $media, array $allowedTypes): bool + { + $type = $media['mediaType']; + $url = $media['url']; + + return in_array($type, $allowedTypes) && + self::validateUrl($url); + } + + /** + * Create media attachment record + */ + private static function createMediaAttachment(array $media, Status $status, int $key): Media + { + $mediaModel = new Media; + + self::setBasicMediaAttributes($mediaModel, $media, $status, $key); + self::setOptionalMediaAttributes($mediaModel, $media); + + $mediaModel->save(); + + return $mediaModel; + } + + /** + * Set basic media attributes + */ + private static function setBasicMediaAttributes(Media $media, array $data, Status $status, int $key): void + { + $media->remote_media = true; + $media->status_id = $status->id; + $media->profile_id = $status->profile_id; + $media->user_id = null; + $media->media_path = $data['url']; + $media->remote_url = $data['url']; + $media->mime = $data['mediaType']; + $media->version = 3; + $media->order = $key + 1; + } + + /** + * Set optional media attributes + */ + private static function setOptionalMediaAttributes(Media $media, array $data): void + { + $media->blurhash = $data['blurhash'] ?? null; + $media->caption = isset($data['name']) ? + Purify::clean($data['name']) : + null; + + if (isset($data['width'])) { + $media->width = $data['width']; } - $host = parse_url($url, PHP_URL_HOST); - $local = config('pixelfed.domain.app') == $host ? true : false; - - if ($local == true) { - $id = last(explode('/', $url)); - - return Profile::whereNull('status') - ->whereNull('domain') - ->whereUsername($id) - ->firstOrFail(); + if (isset($data['height'])) { + $media->height = $data['height']; } - if ($profile = Profile::whereRemoteUrl($url)->first()) { - if ($profile->last_fetched_at && $profile->last_fetched_at->lt(now()->subHours(24))) { - return self::profileUpdateOrCreate($url); + if (isset($data['license'])) { + $media->license = License::nameToId($data['license']); + } + } + + /** + * Handle media storage processing + */ + private static function handleMediaStorage(Media $media): void + { + if ((bool) config_cache('pixelfed.cloud_storage')) { + MediaStoragePipeline::dispatch($media); + } + } + + /** + * Validate attachment collection + */ + private static function validateAttachmentCollection(array $attachments, array $mediaTypes, array $mimeTypes): bool + { + return Validator::make($attachments, [ + '*.type' => [ + 'required', + 'string', + Rule::in($mediaTypes), + ], + '*.url' => 'required|url', + '*.mediaType' => [ + 'required', + 'string', + Rule::in($mimeTypes), + ], + '*.name' => 'sometimes|nullable|string', + '*.blurhash' => 'sometimes|nullable|string|min:6|max:164', + '*.width' => 'sometimes|nullable|integer|min:1|max:5000', + '*.height' => 'sometimes|nullable|integer|min:1|max:5000', + ])->passes(); + } + + /** + * Get supported media types + */ + private static function getSupportedMediaTypes(): array + { + $mimeTypes = explode(',', config_cache('pixelfed.media_types')); + + return in_array('video/mp4', $mimeTypes) ? + ['Document', 'Image', 'Video'] : + ['Document', 'Image']; + } + + /** + * Process specific media type attachment + */ + private static function processMediaTypeAttachment(array $media, Status $status, int $order): ?Media + { + if (! self::isValidMediaType($media)) { + return null; + } + + $mediaModel = new Media; + self::setMediaAttributes($mediaModel, $media, $status, $order); + $mediaModel->save(); + + return $mediaModel; + } + + /** + * Validate media type + */ + private static function isValidMediaType(array $media): bool + { + $requiredFields = ['mediaType', 'url']; + + foreach ($requiredFields as $field) { + if (! isset($media[$field]) || empty($media[$field])) { + return false; } + } + return true; + } + + /** + * Set media attributes + */ + private static function setMediaAttributes(Media $media, array $data, Status $status, int $order): void + { + $media->remote_media = true; + $media->status_id = $status->id; + $media->profile_id = $status->profile_id; + $media->user_id = null; + $media->media_path = $data['url']; + $media->remote_url = $data['url']; + $media->mime = $data['mediaType']; + $media->version = 3; + $media->order = $order; + + // Optional attributes + if (isset($data['blurhash'])) { + $media->blurhash = $data['blurhash']; + } + + if (isset($data['name'])) { + $media->caption = Purify::clean($data['name']); + } + + if (isset($data['width'])) { + $media->width = $data['width']; + } + + if (isset($data['height'])) { + $media->height = $data['height']; + } + + if (isset($data['license'])) { + $media->license = License::nameToId($data['license']); + } + } + + /** + * Fetch or create a profile from a URL + */ + public static function profileFirstOrNew(string $url): ?Profile + { + if (! $validatedUrl = self::validateUrl($url)) { + return null; + } + + $host = parse_url($validatedUrl, PHP_URL_HOST); + + if (self::isLocalDomain($host)) { + return self::getLocalProfile($validatedUrl); + } + + return self::getOrFetchRemoteProfile($validatedUrl); + } + + /** + * Check if domain is local + */ + private static function isLocalDomain(string $host): bool + { + return config('pixelfed.domain.app') == $host; + } + + /** + * Get local profile from URL + */ + private static function getLocalProfile(string $url): ?Profile + { + $username = last(explode('/', $url)); + + return Profile::whereNull('status') + ->whereNull('domain') + ->whereUsername($username) + ->firstOrFail(); + } + + /** + * Get existing or fetch new remote profile + */ + private static function getOrFetchRemoteProfile(string $url): ?Profile + { + $profile = Profile::whereRemoteUrl($url)->first(); + + if ($profile && ! self::needsFetch($profile)) { return $profile; } return self::profileUpdateOrCreate($url); } - public static function profileUpdateOrCreate($url, $movedToCheck = false) + /** + * Check if profile needs to be fetched + */ + private static function needsFetch(?Profile $profile): bool + { + return ! $profile?->last_fetched_at || + $profile->last_fetched_at->lt(now()->subHours(24)); + } + + /** + * Update or create a profile from ActivityPub data + */ + public static function profileUpdateOrCreate(string $url, bool $movedToCheck = false): ?Profile { - $movedToPid = null; $res = self::fetchProfileFromUrl($url); - if (! $res || isset($res['id']) == false) { - return; - } - if (! self::validateUrl($res['inbox'])) { - return; - } - if (! self::validateUrl($res['id'])) { - return; + + if (! self::isValidProfileData($res, $url)) { + return null; } - if (ModeratedProfile::whereProfileUrl($res['id'])->whereIsBanned(true)->exists()) { - return; - } - - $urlDomain = parse_url($url, PHP_URL_HOST); $domain = parse_url($res['id'], PHP_URL_HOST); - if (strtolower($urlDomain) !== strtolower($domain)) { - return; + $username = self::extractUsername($res); + + if (! $username || self::isProfileBanned($res['id'])) { + return null; } - if (! isset($res['preferredUsername']) && ! isset($res['nickname'])) { - return; - } - // skip invalid usernames - if (! ctype_alnum($res['preferredUsername'])) { - $tmpUsername = str_replace(['_', '.', '-'], '', $res['preferredUsername']); - if (! ctype_alnum($tmpUsername)) { - return; - } - } - $username = (string) Purify::clean($res['preferredUsername'] ?? $res['nickname']); - if (empty($username)) { - return; - } - $remoteUsername = $username; + $webfinger = "@{$username}@{$domain}"; - - $instance = Instance::updateOrCreate([ - 'domain' => $domain, - ]); - if ($instance->wasRecentlyCreated == true) { - \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low'); - } - - if (! $movedToCheck && isset($res['movedTo']) && Helpers::validateUrl($res['movedTo'])) { - $movedTo = self::profileUpdateOrCreate($res['movedTo'], true); - if ($movedTo) { - $movedToPid = $movedTo->id; - } - } + $instance = self::getOrCreateInstance($domain); + $movedToPid = $movedToCheck ? null : self::handleMovedTo($res); $profile = Profile::updateOrCreate( [ 'domain' => strtolower($domain), 'username' => Purify::clean($webfinger), ], - [ - 'webfinger' => Purify::clean($webfinger), - 'key_id' => $res['publicKey']['id'], - 'remote_url' => $res['id'], - 'name' => isset($res['name']) ? Purify::clean($res['name']) : 'user', - 'bio' => isset($res['summary']) ? Purify::clean($res['summary']) : null, - 'sharedInbox' => isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null, - 'inbox_url' => $res['inbox'], - 'outbox_url' => isset($res['outbox']) ? $res['outbox'] : null, - 'public_key' => $res['publicKey']['publicKeyPem'], - 'indexable' => isset($res['indexable']) && is_bool($res['indexable']) ? $res['indexable'] : false, - 'moved_to_profile_id' => $movedToPid, - ] + self::buildProfileData($res, $webfinger, $movedToPid) ); - if ($profile->last_fetched_at == null || - $profile->last_fetched_at->lt(now()->subMonths(3)) - ) { - RemoteAvatarFetch::dispatch($profile); - } - $profile->last_fetched_at = now(); - $profile->save(); + self::handleProfileAvatar($profile); return $profile; } - public static function profileFetch($url) + /** + * Validate profile data from ActivityPub + */ + private static function isValidProfileData(?array $res, string $url): bool + { + if (! $res || ! isset($res['id']) || ! isset($res['inbox'])) { + return false; + } + + if (! self::validateUrl($res['inbox']) || ! self::validateUrl($res['id'])) { + return false; + } + + $urlDomain = parse_url($url, PHP_URL_HOST); + $domain = parse_url($res['id'], PHP_URL_HOST); + + return strtolower($urlDomain) === strtolower($domain); + } + + /** + * Extract username from profile data + */ + private static function extractUsername(array $res): ?string + { + $username = $res['preferredUsername'] ?? $res['nickname'] ?? null; + + if (! $username || ! ctype_alnum(str_replace(['_', '.', '-'], '', $username))) { + return null; + } + + return Purify::clean($username); + } + + /** + * Check if profile is banned + */ + private static function isProfileBanned(string $profileUrl): bool + { + return ModeratedProfile::whereProfileUrl($profileUrl) + ->whereIsBanned(true) + ->exists(); + } + + /** + * Get or create federation instance + */ + private static function getOrCreateInstance(string $domain): Instance + { + $instance = Instance::updateOrCreate(['domain' => $domain]); + + if ($instance->wasRecentlyCreated) { + \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance) + ->onQueue('low'); + } + + return $instance; + } + + /** + * Handle moved profile references + */ + private static function handleMovedTo(array $res): ?int + { + if (! isset($res['movedTo']) || ! self::validateUrl($res['movedTo'])) { + return null; + } + + $movedTo = self::profileUpdateOrCreate($res['movedTo'], true); + + return $movedTo?->id; + } + + /** + * Build profile data array for database + */ + private static function buildProfileData(array $res, string $webfinger, ?int $movedToPid): array + { + return [ + 'webfinger' => Purify::clean($webfinger), + 'key_id' => $res['publicKey']['id'], + 'remote_url' => $res['id'], + 'name' => isset($res['name']) ? Purify::clean($res['name']) : 'user', + 'bio' => isset($res['summary']) ? Purify::clean($res['summary']) : null, + 'sharedInbox' => $res['endpoints']['sharedInbox'] ?? null, + 'inbox_url' => $res['inbox'], + 'outbox_url' => $res['outbox'] ?? null, + 'public_key' => $res['publicKey']['publicKeyPem'], + 'indexable' => $res['indexable'] ?? false, + 'moved_to_profile_id' => $movedToPid, + ]; + } + + /** + * Handle profile avatar updates + */ + private static function handleProfileAvatar(Profile $profile): void + { + if (! $profile->last_fetched_at || + $profile->last_fetched_at->lt(now()->subMonths(3)) + ) { + RemoteAvatarFetch::dispatch($profile); + } + + $profile->last_fetched_at = now(); + $profile->save(); + } + + public static function profileFetch($url): ?Profile { return self::profileFirstOrNew($url); } diff --git a/config/media.php b/config/media.php index 46c1719db..5e3b32ae6 100644 --- a/config/media.php +++ b/config/media.php @@ -24,6 +24,10 @@ return [ ], ], + 'image_optimize' => [ + 'catch_unoptimized_media_hour_limit' => env('PF_CATCHUNOPTIMIZEDMEDIA', false), + ], + 'hls' => [ /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2024_05_20_062706_update_group_posts_table.php b/database/migrations/2024_05_20_062706_update_group_posts_table.php index 99f272be9..828727395 100644 --- a/database/migrations/2024_05_20_062706_update_group_posts_table.php +++ b/database/migrations/2024_05_20_062706_update_group_posts_table.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration @@ -12,6 +13,9 @@ return new class extends Migration public function up(): void { Schema::table('group_posts', function (Blueprint $table) { + if (DB::getDriverName() === 'sqlite') { + $table->dropUnique(['status_id']); + } $table->dropColumn('status_id'); $table->dropColumn('reply_child_id'); $table->dropColumn('in_reply_to_id'); diff --git a/package-lock.json b/package-lock.json index 7c37a23ab..2c64259da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "vue-loading-overlay": "^3.3.3", "vue-timeago": "^5.1.2", "vue-tribute": "^1.0.7", + "webgl-media-editor": "^0.0.1", "zuck.js": "^1.6.0" }, "devDependencies": { @@ -5841,6 +5842,12 @@ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, + "node_modules/gl-matrix": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", + "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10163,6 +10170,15 @@ "b4a": "^1.6.4" } }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -10240,6 +10256,12 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "dev": true }, + "node_modules/twgl.js": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/twgl.js/-/twgl.js-5.5.4.tgz", + "integrity": "sha512-6kFOmijOpmblTN9CCwOTCxK4lPg7rCyQjLuub6EMOlEp89Ex6yUcsMjsmH7andNPL2NE3XmHdqHeP5gVKKPhxw==", + "license": "MIT" + }, "node_modules/twitter-text": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-2.0.5.tgz", @@ -10727,6 +10749,18 @@ "node": ">= 8" } }, + "node_modules/webgl-media-editor": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/webgl-media-editor/-/webgl-media-editor-0.0.1.tgz", + "integrity": "sha512-TxnuRl3rpWa1Cia/pn+vh+0iz3yDNwzsrnRGJ61YkdZAYuimu2afBivSHv0RK73hKza6Y/YoRCkuEcsFmtxPNw==", + "license": "AGPL-3.0-only", + "dependencies": { + "cropperjs": "^1.6.2", + "gl-matrix": "^3.4.3", + "throttle-debounce": "^5.0.2", + "twgl.js": "^5.5.4" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 7724f040c..691972ced 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vue-loading-overlay": "^3.3.3", "vue-timeago": "^5.1.2", "vue-tribute": "^1.0.7", + "webgl-media-editor": "^0.0.1", "zuck.js": "^1.6.0" }, "collective": { diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 94f6f5e13..1ec98c64f 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -1,6 +1,6 @@ Post Post - Next @@ -342,8 +341,8 @@
-
- +
-
- -
-
-
- -
+ +

-
- +
+
@@ -427,7 +398,7 @@
- +
@@ -780,7 +751,7 @@
- +

{{video.title ? video.title.slice(0,70) : 'Untitled'}}

{{video.description ? video.description.slice(0,90) : 'No description'}}

@@ -839,13 +810,6 @@
- -
-
- -

Applying filters...

-
-
@@ -875,13 +839,17 @@ import 'cropperjs/dist/cropper.css'; import Autocomplete from '@trevoreyre/autocomplete-vue' import '@trevoreyre/autocomplete-vue/dist/style.css' import VueTribute from 'vue-tribute' +import { MediaEditor, MediaEditorPreview, MediaEditorFilterMenu } from 'webgl-media-editor/vue2' +import { filterEffects } from './filters'; export default { components: { VueCropper, Autocomplete, - VueTribute + VueTribute, + MediaEditorPreview, + MediaEditorFilterMenu }, data() { @@ -892,10 +860,9 @@ export default { composeText: '', composeTextLength: 0, nsfw: false, - filters: [], - currentFilter: false, ids: [], media: [], + files: [], carouselCursor: 0, uploading: false, uploadProgress: 100, @@ -923,7 +890,6 @@ export default { }, namedPages: [ - 'filteringMedia', 'cropPhoto', 'tagPeople', 'addLocation', @@ -1044,13 +1010,26 @@ export default { collectionsPage: 1, collectionsCanLoadMore: false, spoilerText: undefined, - isFilteringMedia: false, - filteringMediaTimeout: undefined, - filteringRemainingCount: 0, isPosting: false, } }, + created() { + this.editor = new MediaEditor({ + effects: filterEffects, + onEdit: (index, {effect, intensity, crop}) => { + if (index >= this.files.length) return + const file = this.files[index] + + this.$set(file, 'editState', { effect, intensity, crop }) + }, + onRenderPreview: (sourceIndex, previewUrl) => { + const media = this.media[sourceIndex] + if (media) media.preview_url = previewUrl + }, + }) + }, + computed: { spoilerTextLength: function() { return this.spoilerText ? this.spoilerText.length : 0; @@ -1058,7 +1037,6 @@ export default { }, beforeMount() { - this.filters = window.App.util.filters.sort(); axios.get('/api/compose/v0/settings') .then(res => { this.composeSettings = res.data; @@ -1075,8 +1053,12 @@ export default { }); }, - mounted() { - this.mediaWatcher(); + destroyed() { + this.files.forEach(fileInfo => { + URL.revokeObjectURL(fileInfo.url); + }) + this.files.length = this.media.length = 0 + this.editor = undefined }, methods: { @@ -1156,39 +1138,55 @@ export default { this.mode = 'text'; }, - mediaWatcher() { - let self = this; - $(document).on('change', '#pf-dz', function(e) { - self.mediaUpload(); - }); - }, + onInputFile(event) { + const input = event.target + const files = Array.from(input.files) + input.value = null; - mediaUpload() { let self = this; - self.uploading = true; - let io = document.querySelector('#pf-dz'); - if(!io.files.length) { - self.uploading = false; - } - Array.prototype.forEach.call(io.files, function(io, i) { - if(self.media && self.media.length + i >= self.config.uploader.album_limit) { + + files.forEach((file, i) => { + if(self.media && self.media.length >= self.config.uploader.album_limit) { swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error'); - self.uploading = false; self.page = 2; return; } - let type = io.type; let acceptedMimes = self.config.uploader.media_types.split(','); - let validated = $.inArray(type, acceptedMimes); + let validated = $.inArray(file.type, acceptedMimes); if(validated == -1) { swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error'); - self.uploading = false; self.page = 2; return; } + const type = file.type.replace(/\/.*/, '') + const url = URL.createObjectURL(file) + const preview_url = type === 'image' ? url : '/storage/no-preview.png' + + this.files.push({ file, editState: undefined }) + this.media.push({ url, preview_url, type }) + }) + + if (this.media.length) { + this.page = 3 + } else { + this.page = 2 + } + }, + + async mediaUpload() { + this.uploading = true; + + const uploadPromises = this.files.map(async (fileInfo, i) => { + let file = fileInfo.file + const media = this.media[i] + + if (media.type === 'image' && fileInfo.editState) { + file = await this.editor.toBlob(i) + } + let form = new FormData(); - form.append('file', io); + form.append('file', file); let xhrConfig = { onUploadProgress: function(e) { @@ -1197,12 +1195,13 @@ export default { } }; - axios.post('/api/compose/v0/media/upload', form, xhrConfig) + const self = this + + await axios.post('/api/compose/v0/media/upload', form, xhrConfig) .then(function(e) { self.uploadProgress = 100; self.ids.push(e.data.id); - self.media.push(e.data); - self.uploading = false; + Object.assign(media, e.data) setTimeout(function() { // if(type === 'video/mp4') { // self.pageTitle = 'Edit Video Details'; @@ -1216,131 +1215,100 @@ export default { }).catch(function(e) { switch(e.response.status) { case 403: - self.uploading = false; - io.value = null; swal('Account size limit reached', 'Contact your admin for assistance.', 'error'); self.page = 2; break; case 413: - self.uploading = false; - io.value = null; - swal('File is too large', 'The file you uploaded has the size of ' + self.formatBytes(io.size) + '. Unfortunately, only images up to ' + self.formatBytes(self.config.uploader.max_photo_size * 1024) + ' are supported.\nPlease resize the file and try again.', 'error'); + swal('File is too large', 'The file you uploaded has the size of ' + self.formatBytes(file.size) + '. Unfortunately, only images up to ' + self.formatBytes(self.config.uploader.max_photo_size * 1024) + ' are supported.\nPlease resize the file and try again.', 'error'); self.page = 2; break; case 451: - self.uploading = false; - io.value = null; swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error'); self.page = 2; break; case 429: - self.uploading = false; - io.value = null; swal('Limit Reached', 'You can upload up to 250 photos or videos per day and you\'ve reached that limit. Please try again later.', 'error'); self.page = 2; break; case 500: - self.uploading = false; - io.value = null; swal('Error', e.response.data.message, 'error'); self.page = 2; break; default: - self.uploading = false; - io.value = null; swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error'); self.page = 2; break; } + + throw e }); - io.value = null; - self.uploadProgress = 0; }); + + await Promise.all(uploadPromises).finally(() => { + this.uploadProgress = 0; + this.uploading = false; + }); }, - toggleFilter(e, filter) { - this.media[this.carouselCursor].filter_class = filter; - this.currentFilter = filter; - }, - - deleteMedia() { + async deleteMedia() { if(window.confirm('Are you sure you want to delete this media?') == false) { return; } let id = this.media[this.carouselCursor].id; - axios.delete('/api/compose/v0/media/delete', { - params: { - id: id - } - }).then(res => { - this.ids.splice(this.carouselCursor, 1); - this.media.splice(this.carouselCursor, 1); - if(this.media.length == 0) { - this.ids = []; - this.media = []; - this.carouselCursor = 0; - } else { - this.carouselCursor = 0; - } - }).catch(err => { - swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error'); - }); + if (id) { + try { + await axios.delete('/api/compose/v0/media/delete', { + params: { + id: id + } + }) + } + catch(err) { + swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error'); + return + } + } + this.ids.splice(this.carouselCursor, 1); + this.media.splice(this.carouselCursor, 1); + + URL.revokeObjectURL(this.files[this.carouselCursor]?.url) + this.files.splice(this.carouselCursor, 1) + + if(this.media.length == 0) { + this.ids = []; + this.media = []; + this.carouselCursor = 0; + } else { + this.carouselCursor = 0; + } }, mediaReorder(dir) { - const m = this.media; - const cur = this.carouselCursor; - const pla = m[cur]; - let res = []; - let cursor = 0; + const prevIndex = this.carouselCursor + const newIndex = prevIndex + (dir === 'prev' ? -1 : 1) - if(dir == 'prev') { - if(cur == 0) { - for (let i = cursor; i < m.length - 1; i++) { - res[i] = m[i+1]; - } - res[m.length - 1] = pla; - cursor = 0; - } else { - res = this.handleSwap(m, cur, cur - 1); - cursor = cur - 1; - } - } else { - if(cur == m.length - 1) { - res = m; - let lastItem = res.pop(); - res.unshift(lastItem); - cursor = m.length - 1; - } else { - res = this.handleSwap(m, cur, cur + 1); - cursor = cur + 1; - } - } - this.$nextTick(() => { - this.media = res; - this.carouselCursor = cursor; - }) + if (newIndex < 0 || newIndex >= this.media.length) return + + const [removedFile] = this.files.splice(prevIndex, 1) + const [removedMedia] = this.media.splice(prevIndex, 1) + const [removedId] = this.ids.splice(prevIndex, 1) + + this.files.splice(newIndex, 0, removedFile) + this.media.splice(newIndex, 0, removedMedia) + this.ids.splice(newIndex, 0, removedId) + this.carouselCursor = newIndex }, - handleSwap(arr, index1, index2) { - if (index1 >= 0 && index1 < arr.length && index2 >= 0 && index2 < arr.length) { - const temp = arr[index1]; - arr[index1] = arr[index2]; - arr[index2] = temp; - return arr; - } - }, - - compose() { + async compose() { let state = this.composeState; - if(this.uploadProgress != 100 || this.ids.length == 0) { + if(this.files.length == 0) { return; } @@ -1353,11 +1321,14 @@ export default { switch(state) { case 'publish': this.isPosting = true; - let count = this.media.filter(m => m.filter_class && !m.hasOwnProperty('is_filtered')).length; - if(count) { - this.applyFilterToMedia(); - return; + + try { + await this.mediaUpload().finally(() => this.isPosting = false) + } catch { + this.isPosting = false; + return } + if(this.composeSettings.media_descriptions === true) { let count = this.media.filter(m => { return !m.hasOwnProperty('alt') || m.alt.length < 2; @@ -1420,6 +1391,8 @@ export default { this.defineErrorMessage(err); break; } + }).finally(() => { + this.isPosting = false; }); return; break; @@ -1488,10 +1461,6 @@ export default { switch(this.mode) { case 'photo': switch(this.page) { - case 'filteringMedia': - this.page = 2; - break; - case 'addText': this.page = 1; break; @@ -1526,10 +1495,6 @@ export default { case 'video': switch(this.page) { - case 'filteringMedia': - this.page = 2; - break; - case 'licensePicker': this.page = 'video-2'; break; @@ -1550,10 +1515,6 @@ export default { this.page = 1; break; - case 'filteringMedia': - this.page = 2; - break; - case 'textOptions': this.page = 'addText'; break; @@ -1593,31 +1554,14 @@ export default { this.page = 2; break; - case 'filteringMedia': - break; - case 'cropPhoto': - this.pageLoading = true; - let self = this; - this.$refs.cropper.getCroppedCanvas({ - maxWidth: 4096, - maxHeight: 4096, - fillColor: '#fff', - imageSmoothingEnabled: false, - imageSmoothingQuality: 'high', - }).toBlob(function(blob) { - self.mediaCropped = true; - let data = new FormData(); - data.append('file', blob); - data.append('id', self.ids[self.carouselCursor]); - let url = '/api/compose/v0/media/update'; - axios.post(url, data).then(res => { - self.media[self.carouselCursor].url = res.data.url; - self.pageLoading = false; - self.page = 2; - }).catch(err => { - }); - }); + const { editState } = this.files[this.carouselCursor] + const croppedState = { + ...editState, + crop: this.$refs.cropper.getData() + } + this.editor.setEditState(this.carouselCursor, croppedState) + this.page = 2; break; case 2: @@ -1764,111 +1708,6 @@ export default { }); }, - applyFilterToMedia() { - // this is where the magic happens - let count = this.media.filter(m => m.filter_class).length; - if(count) { - this.page = 'filteringMedia'; - this.filteringRemainingCount = count; - this.$nextTick(() => { - this.isFilteringMedia = true; - Promise.all(this.media.map(media => { - return this.applyFilterToMediaSave(media); - })).catch(err => { - console.error(err); - swal('Oops!', 'An error occurred while applying filters to your media. Please refresh the page and try again. If the problem persist, please try a different web browser.', 'error'); - }); - }) - } else { - this.page = 3; - } - }, - - async applyFilterToMediaSave(media) { - if(!media.filter_class) { - return; - } - - // Load image - const image = document.createElement('img'); - image.src = media.url; - await new Promise((resolve, reject) => { - image.addEventListener('load', () => resolve()); - image.addEventListener('error', () => { - reject(new Error('Failed to load image')); - }); - }); - - // Create canvas - let canvas; - let usingOffscreenCanvas = false; - if('OffscreenCanvas' in window) { - canvas = new OffscreenCanvas(image.width, image.height); - usingOffscreenCanvas = true; - } else { - canvas = document.createElement('canvas'); - canvas.width = image.width; - canvas.height = image.height; - } - - // Draw image with filter to canvas - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - if (!('filter' in ctx)) { - throw new Error('Canvas filter not supported'); - } - ctx.filter = App.util.filterCss[media.filter_class]; - ctx.drawImage(image, 0, 0, image.width, image.height); - ctx.save(); - - // Convert canvas to blob - let blob; - if(usingOffscreenCanvas) { - blob = await canvas.convertToBlob({ - type: media.mime, - quality: 1, - }); - } else { - blob = await new Promise((resolve, reject) => { - canvas.toBlob(blob => { - if(blob) { - resolve(blob); - } else { - reject( - new Error('Failed to convert canvas to blob'), - ); - } - }, media.mime, 1); - }); - } - - // Upload blob / Update media - const data = new FormData(); - data.append('file', blob); - data.append('id', media.id); - await axios.post('/api/compose/v0/media/update', data); - media.is_filtered = true; - this.updateFilteringMedia(); - }, - - updateFilteringMedia() { - this.filteringRemainingCount--; - this.filteringMediaTimeout = setTimeout(() => this.filteringMediaTimeoutJob(), 500); - }, - - filteringMediaTimeoutJob() { - if(this.filteringRemainingCount === 0) { - this.isFilteringMedia = false; - clearTimeout(this.filteringMediaTimeout); - setTimeout(() => this.compose(), 500); - } else { - clearTimeout(this.filteringMediaTimeout); - this.filteringMediaTimeout = setTimeout(() => this.filteringMediaTimeoutJob(), 1000); - } - }, - tagSearch(input) { if (input.length < 1) { return []; } let self = this; @@ -2059,6 +1898,11 @@ export default { this.collectionsCanLoadMore = true; }); } + }, + watch: { + files(value) { + this.editor.setSources(value.map(f => f.file)) + }, } } @@ -2111,5 +1955,34 @@ export default { } } } + .media-editor { + background-color: transparent; + border: none !important; + box-shadow: none !important; + font-size: 12px; + + --height-menu-row: 5rem; + --gap-preview: 0rem; + --height-menu-row-scroll: 10rem; + + --color-bg-button: transparent; /*var(--light);*/ + --color-bg-preview: transparent; /*var(--light-gray);*/ + --color-bg-button-hover: var(--light-gray); + --color-bg-acc: var(--card-bg); + + --color-fnt-default: var(--body-color); + --color-fnt-acc: var(--text-lighter); + + --color-scrollbar-thumb: var(--light-gray); + --color-scrollbar-both: var(--light-gray) transparent; + + --color-slider-thumb: var(--text-lighter); + --color-slider-progress: var(--light-gray); + --color-slider-track: var(--light); + + --color-crop-outline: var(--light-gray); + --color-crop-dashed: #ffffffde; + --color-crop-overlay: #00000082; + } } diff --git a/resources/assets/js/components/filters.js b/resources/assets/js/components/filters.js new file mode 100644 index 000000000..59579c809 --- /dev/null +++ b/resources/assets/js/components/filters.js @@ -0,0 +1,290 @@ +export const filterEffects = [ + { + name: '1984', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'hue_rotate', angle: -30 }, + { type: 'adjust_color', brightness: 0, contrast: 0, saturation: 0.4 }, + ], + }, + { + name: 'Azen', + ops: [ + { type: 'sepia', intensity: 0.2 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0, saturation: 0.4 }, + ], + }, + { + name: 'Astairo', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.1, saturation: 0.3 }, + ], + }, + { + name: 'Grasbee', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0, contrast: 0.2, saturation: 0.8 }, + ], + }, + { + name: 'Bookrun', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.25, saturation: -0.1 }, + { type: 'hue_rotate', angle: -2 }, + ], + }, + { + name: 'Borough', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0 }, + { type: 'hue_rotate', angle: 5 }, + ], + }, + { + name: 'Farms', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.35 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Hairsadone', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0 }, + { type: 'hue_rotate', angle: 5 }, + ], + }, + { + name: 'Cleana', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.15, saturation: -0.1 }, + { type: 'hue_rotate', angle: -2 }, + ], + }, + { + name: 'Catpatch', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0, contrast: .5, saturation: 0.1 }, + ], + }, + { + name: 'Earlyworm', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.15, saturation: -0.1 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Plaid', + ops: [{ type: 'adjust_color', brightness: 0.1, contrast: 0.1, saturation: 0 }], + }, + { + name: 'Kyo', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.15, saturation: 0.35 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Yefe', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.5, saturation: 0.4 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Godess', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 0.35 }, + ], + }, + { + name: 'Yards', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.2, saturation: 0.05 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Quill', + ops: [{ type: 'adjust_color', brightness: 0.25, contrast: -0.15, saturation: -1 }], + }, + { + name: 'Juno', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0.15, saturation: 0.8 }, + ], + }, + { + name: 'Rankine', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Mark', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.3, contrast: 0.2, saturation: 0.25 }, + ], + }, + { + name: 'Chill', + ops: [{ type: 'adjust_color', brightness: 0, contrast: 0.5, saturation: 0.1 }], + }, + { + name: 'Van', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 1 }, + ], + }, + { + name: 'Apache', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 0.75 }, + ], + }, + { + name: 'May', + ops: [{ type: 'adjust_color', brightness: 0.15, contrast: 0.1, saturation: 0.1 }], + }, + { + name: 'Ceres', + ops: [ + { type: 'adjust_color', brightness: 0.4, contrast: -0.05, saturation: -1 }, + { type: 'sepia', intensity: 0.35 }, + ], + }, + { + name: 'Knoxville', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Felicity', + ops: [{ type: 'adjust_color', brightness: 0.25, contrast: 0.1, saturation: 0.1 }], + }, + { + name: 'Sandblast', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0, saturation: 0 }, + ], + }, + { + name: 'Daisy', + ops: [ + { type: 'sepia', intensity: 0.75 }, + { type: 'adjust_color', brightness: 0.25, contrast: -0.25, saturation: 0.4 }, + ], + }, + { + name: 'Elevate', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.25, saturation: -0.1 }, + ], + }, + { + name: 'Nevada', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Futura', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.2 }, + ], + }, + { + name: 'Sleepy', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.2 }, + ], + }, + { + name: 'Steward', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.1, saturation: 0.25 }, + ], + }, + { + name: 'Savoy', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.2, contrast: -0.1, saturation: 0.4 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Blaze', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.05, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Apricot', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.1, saturation: 0 }, + ], + }, + { + name: 'Gloming', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0.2, saturation: 0.3 }, + ], + }, + { + name: 'Walter', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.25, contrast: -0.2, saturation: 0.4 }, + ], + }, + { + name: 'Poplar', + ops: [ + { type: 'adjust_color', brightness: 0.2, contrast: -0.15, saturation: -0.95 }, + { type: 'sepia', intensity: 0.5 }, + ], + }, + { + name: 'Xenon', + ops: [ + { type: 'sepia', intensity: 0.45 }, + { type: 'adjust_color', brightness: 0.75, contrast: 0.25, saturation: 0.3 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + ] diff --git a/resources/lang/pt/web.php b/resources/lang/pt/web.php index 7b7064b69..bd8f275ef 100644 --- a/resources/lang/pt/web.php +++ b/resources/lang/pt/web.php @@ -6,32 +6,32 @@ return [ 'comment' => 'Comentar', 'commented' => 'Comentado', 'comments' => 'Comentários', - 'like' => 'Curtir', - 'liked' => 'Curtiu', - 'likes' => 'Curtidas', - 'share' => 'Compartilhar', - 'shared' => 'Compartilhado', - 'shares' => 'Compartilhamentos', - 'unshare' => 'Desfazer compartilhamento', - 'bookmark' => 'Favoritar', + 'like' => 'Gosto', + 'liked' => 'Gostei', + 'likes' => 'Gostos', + 'share' => 'Partilhar', + 'shared' => 'Partilhado', + 'shares' => 'Partilhas', + 'unshare' => 'Despartilhar', + 'bookmark' => 'Favorito', 'cancel' => 'Cancelar', 'copyLink' => 'Copiar link', - 'delete' => 'Apagar', + 'delete' => 'Eliminar', 'error' => 'Erro', - 'errorMsg' => 'Algo deu errado. Por favor, tente novamente mais tarde.', - 'oops' => 'Opa!', + 'errorMsg' => 'Algo correu mal. Por favor, tente novamente mais tarde.', + 'oops' => 'Oops!', 'other' => 'Outro', - 'readMore' => 'Leia mais', + 'readMore' => 'Ler mais', 'success' => 'Sucesso', 'proceed' => 'Continuar', - 'next' => 'Próximo', + 'next' => 'Seguinte', 'close' => 'Fechar', 'clickHere' => 'clique aqui', 'sensitive' => 'Sensível', 'sensitiveContent' => 'Conteúdo sensível', - 'sensitiveContentWarning' => 'Esta publicação pode conter conteúdo inapropriado', + 'sensitiveContentWarning' => 'Este post pode conter conteúdo sensível', ], 'site' => [ @@ -40,27 +40,27 @@ return [ ], 'navmenu' => [ - 'search' => 'Pesquisar', - 'admin' => 'Painel do Administrador', + 'search' => 'Pesquisa', + 'admin' => 'Painel de Administração', // Timelines - 'homeFeed' => 'Página inicial', + 'homeFeed' => 'Inicio', 'localFeed' => 'Feed local', 'globalFeed' => 'Feed global', // Core features - 'discover' => 'Explorar', - 'directMessages' => 'Mensagens privadas', + 'discover' => 'Descobrir', + 'directMessages' => 'Mensagens Diretas', 'notifications' => 'Notificações', 'groups' => 'Grupos', 'stories' => 'Stories', // Self links 'profile' => 'Perfil', - 'drive' => 'Drive', - 'settings' => 'Configurações', + 'drive' => 'Disco', + 'settings' => 'Definições', 'compose' => 'Criar novo', - 'logout' => 'Sair', + 'logout' => 'Terminar Sessão', // Nav footer 'about' => 'Sobre', @@ -70,139 +70,139 @@ return [ 'terms' => 'Termos', // Temporary links - 'backToPreviousDesign' => 'Voltar ao design anterior' + 'backToPreviousDesign' => 'Voltar ao design antigo' ], 'directMessages' => [ - 'inbox' => 'Caixa de entrada', + 'inbox' => 'Caixa de Entrada', 'sent' => 'Enviadas', - 'requests' => 'Solicitações' + 'requests' => 'Pedidos' ], 'notifications' => [ - 'liked' => 'curtiu seu', - 'commented' => 'comentou em seu', + 'liked' => 'gostou do seu', + 'commented' => 'comentou no seu', 'reacted' => 'reagiu ao seu', - 'shared' => 'compartilhou seu', - 'tagged' => 'marcou você em um', + 'shared' => 'Partilhou o seu', + 'tagged' => 'marcou você numa publicação', - 'updatedA' => 'atualizou um(a)', + 'updatedA' => 'atualizou', 'sentA' => 'enviou um', 'followed' => 'seguiu', 'mentioned' => 'mencionou', 'you' => 'você', - 'yourApplication' => 'Sua inscrição para participar', + 'yourApplication' => 'A sua candidatura para se juntar', 'applicationApproved' => 'foi aprovado!', - 'applicationRejected' => 'foi rejeitado. Você pode se inscrever novamente para participar em 6 meses.', + 'applicationRejected' => 'foi rejeitado. Você pode inscrever-se novamente em 6 meses.', - 'dm' => 'mensagem direta', - 'groupPost' => 'postagem do grupo', + 'dm' => 'dm', + 'groupPost' => 'publicação de grupo', 'modlog' => 'histórico de moderação', 'post' => 'publicação', - 'story' => 'história', - 'noneFound' => 'Sem notificação', + 'story' => 'story', + 'noneFound' => 'Nenhuma notificação encontrada', ], 'post' => [ - 'shareToFollowers' => 'Compartilhar com os seguidores', - 'shareToOther' => 'Compartilhar com outros', - 'noLikes' => 'Ainda sem curtidas', - 'uploading' => 'Enviando', + 'shareToFollowers' => 'Partilhar com os seguidores', + 'shareToOther' => 'Partilhar com outros', + 'noLikes' => 'Ainda sem gostos', + 'uploading' => 'A enviar', ], 'profile' => [ 'posts' => 'Publicações', 'followers' => 'Seguidores', - 'following' => 'Seguindo', - 'admin' => 'Administrador', + 'following' => 'A seguir', + 'admin' => 'Admin', 'collections' => 'Coleções', 'follow' => 'Seguir', 'unfollow' => 'Deixar de seguir', 'editProfile' => 'Editar Perfil', - 'followRequested' => 'Solicitação de seguir enviada', - 'joined' => 'Entrou', + 'followRequested' => 'Pedido para seguir enviado', + 'joined' => 'Juntou-se', 'emptyCollections' => 'Não conseguimos encontrar nenhuma coleção', - 'emptyPosts' => 'Não encontramos nenhuma publicação', + 'emptyPosts' => 'Não conseguimos encontrar nenhuma publicação', ], 'menu' => [ 'viewPost' => 'Ver publicação', - 'viewProfile' => 'Ver Perfil', + 'viewProfile' => 'Ver perfil', 'moderationTools' => 'Ferramentas de moderação', 'report' => 'Denunciar', - 'archive' => 'Arquivo', - 'unarchive' => 'Desarquivar', + 'archive' => 'Arquivar', + 'unarchive' => 'Retirar do arquivo', 'embed' => 'Incorporar', - 'selectOneOption' => 'Selecione uma das opções a seguir', - 'unlistFromTimelines' => 'Retirar das linhas do tempo', + 'selectOneOption' => 'Selecione uma das seguintes opções', + 'unlistFromTimelines' => 'Remover das cronologias', 'addCW' => 'Adicionar aviso de conteúdo', 'removeCW' => 'Remover aviso de conteúdo', - 'markAsSpammer' => 'Marcar como Spammer', - 'markAsSpammerText' => 'Retirar das linhas do tempo + adicionar aviso de conteúdo às publicações antigas e futuras', - 'spam' => 'Lixo Eletrônico', - 'sensitive' => 'Conteúdo sensível', - 'abusive' => 'Abusivo ou Prejudicial', + 'markAsSpammer' => 'Marcar como spammer', + 'markAsSpammerText' => 'Remover das cronologias e adicionar um aviso de conteúdo às publicações existentes e futuras', + 'spam' => 'Spam', + 'sensitive' => 'Conteúdo Sensível', + 'abusive' => 'Abusivo ou prejudicial', 'underageAccount' => 'Conta de menor de idade', - 'copyrightInfringement' => 'Violação de direitos autorais', - 'impersonation' => 'Roubo de identidade', - 'scamOrFraud' => 'Golpe ou Fraude', + 'copyrightInfringement' => 'Violação de direitos de autor', + 'impersonation' => 'Roubo de Identidade', + 'scamOrFraud' => 'Esquema ou fraude', 'confirmReport' => 'Confirmar denúncia', - 'confirmReportText' => 'Você realmente quer denunciar esta publicação?', + 'confirmReportText' => 'Tem a certeza que deseja denunciar esta mensagem?', 'reportSent' => 'Denúncia enviada!', - 'reportSentText' => 'Nós recebemos sua denúncia com sucesso.', - 'reportSentError' => 'Houve um problema ao denunciar esta publicação.', + 'reportSentText' => 'Recebemos com sucesso a sua denúncia.', + 'reportSentError' => 'Ocorreu um erro ao denunciar este conteúdo.', - 'modAddCWConfirm' => 'Você realmente quer adicionar um aviso de conteúdo a esta publicação?', - 'modCWSuccess' => 'Aviso de conteúdo sensível adicionado com sucesso', - 'modRemoveCWConfirm' => 'Você realmente quer remover o aviso de conteúdo desta publicação?', - 'modRemoveCWSuccess' => 'Aviso de conteúdo sensível removido com sucesso', - 'modUnlistConfirm' => 'Você realmente quer definir esta publicação como não listada?', - 'modUnlistSuccess' => 'A publicação foi definida como não listada com sucesso', - 'modMarkAsSpammerConfirm' => 'Você realmente quer denunciar este usuário por spam? Todas as suas publicações anteriores e futuras serão marcadas com um aviso de conteúdo e removidas das linhas do tempo.', - 'modMarkAsSpammerSuccess' => 'Perfil denunciado com sucesso', + 'modAddCWConfirm' => 'Tem a certeza que pretende adicionar um aviso de conteúdo à publicação?', + 'modCWSuccess' => 'Adicionou com sucesso um aviso de conteúdo', + 'modRemoveCWConfirm' => 'Tem a certeza que pretende remover o aviso de conteúdo desta publicação?', + 'modRemoveCWSuccess' => 'Removeu com sucesso o aviso de conteúdo', + 'modUnlistConfirm' => 'Tem a certeza que pretende deslistar este post?', + 'modUnlistSuccess' => 'Deslistou com sucesso este post', + 'modMarkAsSpammerConfirm' => 'Tem a certeza que deseja marcar este utilizador como spammer? Todos os posts existentes e futuros serão deslistados da timeline e o alerta de conteúdo será aplicado.', + 'modMarkAsSpammerSuccess' => 'Marcou com sucesso esta conta como spammer', - 'toFollowers' => 'para seguidores', + 'toFollowers' => 'para Seguidores', - 'showCaption' => 'Mostrar legenda', - 'showLikes' => 'Mostrar curtidas', + 'showCaption' => 'Mostar legenda', + 'showLikes' => 'Mostrar Gostos', 'compactMode' => 'Modo compacto', - 'embedConfirmText' => 'Ao usar de forma “embed”, você concorda com nossas', + 'embedConfirmText' => 'Ao utilizar este conteúdo, aceita os nossos', - 'deletePostConfirm' => 'Você tem certeza que deseja excluir esta publicação?', - 'archivePostConfirm' => 'Tem certeza que deseja arquivar esta publicação?', - 'unarchivePostConfirm' => 'Tem certeza que deseja desarquivar esta publicação?', + 'deletePostConfirm' => 'Tem a certeza que pretende apagar esta publicação?', + 'archivePostConfirm' => 'Tem a certeza que pretende arquivar esta publicação?', + 'unarchivePostConfirm' => 'Tem a certeza que pretende desarquivar este post?', ], 'story' => [ - 'add' => 'Adicionar Story' + 'add' => 'Adicionar Storie' ], 'timeline' => [ - 'peopleYouMayKnow' => 'Pessoas que você talvez conheça', + 'peopleYouMayKnow' => 'Pessoas que talvez conheça', 'onboarding' => [ - 'welcome' => 'Boas vindas', - 'thisIsYourHomeFeed' => 'Esse é seu feed principal, onde as publicações das contas que você segue aparecem cronologicamente.', - 'letUsHelpYouFind' => 'Deixe-nos te ajudar a encontrar pessoas legais para seguir', - 'refreshFeed' => 'Atualizar meu feed', + 'welcome' => 'Bem-vindo', + 'thisIsYourHomeFeed' => 'Este é o seu feed pessoal, com publicações em ordem cronológica das contas que segue.', + 'letUsHelpYouFind' => 'Deixe-nos ajudar a encontrar algumas pessoas interessantes para seguir', + 'refreshFeed' => 'Atualizar o meu feed', ], ], 'hashtags' => [ - 'emptyFeed' => 'Não encontramos nenhuma publicação com esta hashtag' + 'emptyFeed' => 'Não conseguimos encontrar publicações com essa hashtag' ], 'report' => [ 'report' => 'Denunciar', - 'selectReason' => 'Selecione um motivo', + 'selectReason' => 'Selecione uma razão', 'reported' => 'Denunciado', - 'sendingReport' => 'Enviando denúncia', - 'thanksMsg' => 'Agradecemos a denúncia; pessoas como você nos ajudam a manter a comunidade segura!', - 'contactAdminMsg' => 'Se quiser contatar um administrador por causa desta publicação ou denúncia', + 'sendingReport' => 'A enviar denúncia', + 'thanksMsg' => 'Obrigado pela denúncia, pessoas como você ajudam a manter a nossa comunidade segura!', + 'contactAdminMsg' => 'Se quiser entrar em contato com um administrador acerca desta publicação ou denúncia', ], ];