diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbad8716..d40b7478c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ - Add Password change email notification ([de1cca4f](https://github.com/pixelfed/pixelfed/commit/de1cca4f)) - Add shared inbox ([4733ca9f](https://github.com/pixelfed/pixelfed/commit/4733ca9f)) - Add federated photo filters ([0a5a0e86](https://github.com/pixelfed/pixelfed/commit/0a5a0e86)) +- Add AccountInterstitial model and controller ([8766ccfe](https://github.com/pixelfed/pixelfed/commit/8766ccfe)) +- Add Blurhash encoder ([fad102bf](https://github.com/pixelfed/pixelfed/commit/fad102bf)) ### Updated - Updated PostComponent, fix remote urls ([42716ccc](https://github.com/pixelfed/pixelfed/commit/42716ccc)) @@ -106,6 +108,20 @@ - Updated federation config, make sharedInbox enabled by default. ([6e3522c0](https://github.com/pixelfed/pixelfed/commit/6e3522c0)) - Updated PostComponent, change timestamp format. ([e51665f6](https://github.com/pixelfed/pixelfed/commit/e51665f6)) - Updated PostComponent, use proper username context for reply mentions. Fixes ([#2421](https://github.com/pixelfed/pixelfed/issues/2421)). ([dac06088](https://github.com/pixelfed/pixelfed/commit/dac06088)) +- Updated Navbar, added profile avatar. ([19abf1b4](https://github.com/pixelfed/pixelfed/commit/19abf1b4)) +- Updated package.json, add blurhash. ([cc1b081a](https://github.com/pixelfed/pixelfed/commit/cc1b081a)) +- Updated Status model, fix thumb nsfw caching. ([327ef138](https://github.com/pixelfed/pixelfed/commit/327ef138)) +- Updated User model, add interstitial relation. ([bd321a72](https://github.com/pixelfed/pixelfed/commit/bd321a72)) +- Updated StatusStatelessTransformer, add missing attributes. ([4d22426d](https://github.com/pixelfed/pixelfed/commit/4d22426d)) +- Updated media pipeline, add blurhash support. ([473e0495](https://github.com/pixelfed/pixelfed/commit/473e0495)) +- Updated DeleteAccountPipeline, add AccountInterstitial and DirectMessage purging. ([b3078f27](https://github.com/pixelfed/pixelfed/commit/b3078f27)) +- Updated ComposeModal.vue component, reuse sharedData. ([e28d022f](https://github.com/pixelfed/pixelfed/commit/e28d022f)) +- Updated ApiController, return status object after deletion. ([0718711d](https://github.com/pixelfed/pixelfed/commit/0718711d)) +- Updated InternalApiController, add interstitial logic. ([20681bcf](https://github.com/pixelfed/pixelfed/commit/20681bcf)) +- Updated PublicApiController, improve stateless object caching. ([342e7a50](https://github.com/pixelfed/pixelfed/commit/342e7a50)) +- Updated StatusController, add interstitial logic. ([003caf7e](https://github.com/pixelfed/pixelfed/commit/003caf7e)) +- Updated middleware, add AccountInterstitial support. ([19d6e7df](https://github.com/pixelfed/pixelfed/commit/19d6e7df)) +- Updated BaseApiController, add favourites method. ([76353ca9](https://github.com/pixelfed/pixelfed/commit/76353ca9)) ## [v0.10.9 (2020-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.10.8...v0.10.9) ### Added diff --git a/app/AccountInterstitial.php b/app/AccountInterstitial.php new file mode 100644 index 000000000..d2626ab58 --- /dev/null +++ b/app/AccountInterstitial.php @@ -0,0 +1,30 @@ +belongsTo(User::class); + } + + public function status() + { + if($this->item_type != 'App\Status') { + return; + } + return $this->hasOne(Status::class, 'id', 'item_id'); + } +} diff --git a/app/Http/Controllers/AccountInterstitialController.php b/app/Http/Controllers/AccountInterstitialController.php new file mode 100644 index 000000000..96c102e00 --- /dev/null +++ b/app/Http/Controllers/AccountInterstitialController.php @@ -0,0 +1,76 @@ +middleware('auth'); + } + + public function get(Request $request) + { + $interstitial = $request->user() + ->interstitials() + ->whereNull('read_at') + ->first(); + if(!$interstitial) { + $user = $request->user(); + $user->has_interstitial = false; + $user->save(); + return redirect('/'); + } + $meta = json_decode($interstitial->meta); + $view = $interstitial->view; + return view($view, compact('interstitial', 'meta')); + } + + public function read(Request $request) + { + $this->validate($request, [ + 'id' => 'required', + 'type' => 'required|in:post.cw,post.removed,post.unlist', + 'action' => 'required|in:appeal,confirm', + 'appeal_message' => 'nullable|max:500' + ]); + + $redirect = '/'; + + $id = decrypt($request->input('id')); + $action = $request->input('action'); + $user = $request->user(); + + $ai = AccountInterstitial::whereUserId($user->id) + ->whereType($request->input('type')) + ->findOrFail($id); + + if($action == 'appeal') { + $ai->appeal_requested_at = now(); + $ai->appeal_message = $request->input('appeal_message'); + } + + $ai->read_at = now(); + $ai->save(); + + $more = AccountInterstitial::whereUserId($user->id) + ->whereNull('read_at') + ->exists(); + + if(!$more) { + $user->has_interstitial = false; + $user->save(); + } + + if(in_array($ai->type, ['post.cw', 'post.unlist'])) { + $redirect = Status::findOrFail($ai->item_id)->url(); + } + + return redirect($redirect); + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index f6b693430..f044ec9fa 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\{ + AccountInterstitial, Contact, Hashtag, Newsroom, @@ -85,6 +86,67 @@ class AdminController extends Controller return view('admin.reports.show', compact('report')); } + public function appeals(Request $request) + { + $appeals = AccountInterstitial::whereNotNull('appeal_requested_at') + ->whereNull('appeal_handled_at') + ->latest() + ->paginate(6); + return view('admin.reports.appeals', compact('appeals')); + } + + public function showAppeal(Request $request, $id) + { + $appeal = AccountInterstitial::whereNotNull('appeal_requested_at') + ->whereNull('appeal_handled_at') + ->findOrFail($id); + $meta = json_decode($appeal->meta); + return view('admin.reports.show_appeal', compact('appeal', 'meta')); + } + + public function updateAppeal(Request $request, $id) + { + $this->validate($request, [ + 'action' => 'required|in:dismiss,approve' + ]); + + $action = $request->input('action'); + $appeal = AccountInterstitial::whereNotNull('appeal_requested_at') + ->whereNull('appeal_handled_at') + ->findOrFail($id); + + if($action == 'dismiss') { + $appeal->appeal_handled_at = now(); + $appeal->save(); + + return redirect('/i/admin/reports/appeals'); + } + + switch ($appeal->type) { + case 'post.cw': + $status = $appeal->status; + $status->is_nsfw = false; + $status->save(); + break; + + case 'post.unlist': + $status = $appeal->status; + $status->scope = 'public'; + $status->visibility = 'public'; + $status->save(); + break; + + default: + # code... + break; + } + + $appeal->appeal_handled_at = now(); + $appeal->save(); + + return redirect('/i/admin/reports/appeals'); + } + public function profiles(Request $request) { $this->validate($request, [ diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 7bf0646e2..0243939b7 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1761,6 +1761,7 @@ class ApiV1Controller extends Controller NewStatusPipeline::dispatch($status); Cache::forget('user:account:id:'.$user->id); + Cache::forget('_api:statuses:recent_9:'.$user->profile_id); Cache::forget('profile:status_count:'.$user->profile_id); Cache::forget($user->storageUsedKey()); @@ -1783,10 +1784,15 @@ class ApiV1Controller extends Controller $status = Status::whereProfileId($request->user()->profile->id) ->findOrFail($id); + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + Cache::forget('profile:status_count:'.$status->profile_id); StatusDelete::dispatch($status); - return response()->json(['Status successfully deleted.']); + $res = $this->fractal->createData($resource)->toArray(); + $res['text'] = $res['content']; + unset($res['content']); + return response()->json($res); } /** diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 6c14d07a2..6a4497587 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -11,8 +11,9 @@ use Auth, Cache, Storage, URL; use Carbon\Carbon; use App\{ Avatar, - Notification, + Like, Media, + Notification, Profile, Status }; @@ -21,7 +22,8 @@ use App\Transformer\Api\{ NotificationTransformer, MediaTransformer, MediaDraftTransformer, - StatusTransformer + StatusTransformer, + StatusStatelessTransformer }; use League\Fractal; use App\Util\Media\Filter; @@ -338,4 +340,29 @@ class BaseApiController extends Controller $res = $this->fractal->createData($resource)->toArray(); return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } + + public function accountLikes(Request $request) + { + $user = $request->user(); + abort_if(!$request->user(), 403); + + $limit = 10; + $page = (int) $request->input('page', 1); + + if($page > 20) { + return []; + } + + $favourites = $user->profile->likes() + ->latest() + ->simplePaginate($limit) + ->pluck('status_id'); + + $statuses = Status::find($favourites)->reverse(); + + $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } } diff --git a/app/Http/Controllers/InternalApiController.php b/app/Http/Controllers/InternalApiController.php index 6fe9a5bdf..e3fc8c757 100644 --- a/app/Http/Controllers/InternalApiController.php +++ b/app/Http/Controllers/InternalApiController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use App\{ + AccountInterstitial, DirectMessage, DiscoverCategory, Hashtag, @@ -213,6 +214,35 @@ class InternalApiController extends Controller ]) ->accessLevel('admin') ->save(); + + + if($status->uri == null) { + $media = $status->media; + $ai = new AccountInterstitial; + $ai->user_id = $status->profile->user_id; + $ai->type = 'post.cw'; + $ai->view = 'account.moderation.post.cw'; + $ai->item_type = 'App\Status'; + $ai->item_id = $status->id; + $ai->has_media = (bool) $media->count(); + $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + $ai->meta = json_encode([ + 'caption' => $status->caption, + 'created_at' => $status->created_at, + 'type' => $status->type, + 'url' => $status->url(), + 'is_nsfw' => $status->is_nsfw, + 'scope' => $status->scope, + 'reblog' => $status->reblog_of_id, + 'likes_count' => $status->likes_count, + 'reblogs_count' => $status->reblogs_count, + ]); + $ai->save(); + + $u = $status->profile->user; + $u->has_interstitial = true; + $u->save(); + } break; case 'remcw': @@ -231,6 +261,14 @@ class InternalApiController extends Controller ]) ->accessLevel('admin') ->save(); + if($status->uri == null) { + $ai = AccountInterstitial::whereUserId($status->profile->user_id) + ->whereType('post.cw') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->first(); + $ai->delete(); + } break; case 'unlist': @@ -250,6 +288,34 @@ class InternalApiController extends Controller ]) ->accessLevel('admin') ->save(); + + if($status->uri == null) { + $media = $status->media; + $ai = new AccountInterstitial; + $ai->user_id = $status->profile->user_id; + $ai->type = 'post.unlist'; + $ai->view = 'account.moderation.post.unlist'; + $ai->item_type = 'App\Status'; + $ai->item_id = $status->id; + $ai->has_media = (bool) $media->count(); + $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + $ai->meta = json_encode([ + 'caption' => $status->caption, + 'created_at' => $status->created_at, + 'type' => $status->type, + 'url' => $status->url(), + 'is_nsfw' => $status->is_nsfw, + 'scope' => $status->scope, + 'reblog' => $status->reblog_of_id, + 'likes_count' => $status->likes_count, + 'reblogs_count' => $status->reblogs_count, + ]); + $ai->save(); + + $u = $status->profile->user; + $u->has_interstitial = true; + $u->save(); + } break; } return ['msg' => 200]; @@ -364,6 +430,7 @@ class InternalApiController extends Controller NewStatusPipeline::dispatch($status); Cache::forget('user:account:id:'.$profile->user_id); + Cache::forget('_api:statuses:recent_9:'.$profile->id); Cache::forget('profile:status_count:'.$profile->id); Cache::forget($user->storageUsedKey()); return $status->url(); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 7d0b4405c..6bd42d177 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -66,7 +66,9 @@ class ProfileController extends Controller 'list' => $settings->show_profile_followers ] ]; - return view('profile.show', compact('profile', 'settings')); + $ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show'; + + return view($ui, compact('profile', 'settings')); } else { $key = 'profile:settings:' . $user->id; $ttl = now()->addHours(6); @@ -103,7 +105,8 @@ class ProfileController extends Controller 'list' => $settings->show_profile_followers ] ]; - return view('profile.show', compact('profile', 'settings')); + $ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show'; + return view($ui, compact('profile', 'settings')); } } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 8dab9b65f..34757efa6 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -20,7 +20,8 @@ use League\Fractal; use App\Transformer\Api\{ AccountTransformer, RelationshipTransformer, - StatusTransformer + StatusTransformer, + StatusStatelessTransformer }; use App\Services\{ AccountService, @@ -86,6 +87,24 @@ class PublicApiController extends Controller $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $status = Status::whereProfileId($profile->id)->findOrFail($postid); $this->scopeCheck($profile, $status); + if(!Auth::check()) { + $res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) { + $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); + $res = [ + 'status' => $this->fractal->createData($item)->toArray(), + 'user' => [], + 'likes' => [], + 'shares' => [], + 'reactions' => [ + 'liked' => false, + 'shared' => false, + 'bookmarked' => false, + ], + ]; + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }); + return $res; + } $item = new Fractal\Resource\Item($status, new StatusTransformer()); $res = [ 'status' => $this->fractal->createData($item)->toArray(), @@ -419,7 +438,6 @@ class PublicApiController extends Controller } - public function networkTimelineApi(Request $request) { return response()->json([]); @@ -543,6 +561,50 @@ class PublicApiController extends Controller } } + $tag = in_array('private', $visibility) ? 'private' : 'public'; + if($min_id == 1 && $limit == 9 && $tag == 'public') { + $limit = 9; + $scope = ['photo', 'photo:album', 'video', 'video:album']; + $key = '_api:statuses:recent_9:'.$profile->id; + $res = Cache::remember($key, now()->addHours(24), function() use($profile, $scope, $visibility, $limit) { + $dir = '>'; + $id = 1; + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'likes_count', + 'reblogs_count', + 'scope', + 'visibility', + 'local', + 'place_id', + 'comments_disabled', + 'cw_summary', + 'created_at', + 'updated_at' + )->whereProfileId($profile->id) + ->whereIn('type', $scope) + ->where('id', $dir, $id) + ->whereIn('visibility', $visibility) + ->limit($limit) + ->orderByDesc('id') + ->get(); + + $resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }); + return $res; + } + $dir = $min_id ? '>' : '<'; $id = $min_id ?? $max_id; $timeline = Status::select( @@ -560,6 +622,8 @@ class PublicApiController extends Controller 'scope', 'visibility', 'local', + 'place_id', + 'comments_disabled', 'cw_summary', 'created_at', 'updated_at' diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index f98fa5d63..c0bf8e06d 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -6,6 +6,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\StatusDelete; use App\Jobs\SharePipeline\SharePipeline; +use App\AccountInterstitial; use App\Media; use App\Profile; use App\Status; @@ -162,14 +163,49 @@ class StatusController extends Controller $status = Status::findOrFail($request->input('item')); - if ($status->profile_id === Auth::user()->profile->id || Auth::user()->is_admin == true) { + $user = Auth::user(); + + if($status->profile_id != $user->profile->id && + $user->is_admin == true && + $status->uri == null + ) { + $media = $status->media; + + $ai = new AccountInterstitial; + $ai->user_id = $status->profile->user_id; + $ai->type = 'post.removed'; + $ai->view = 'account.moderation.post.removed'; + $ai->item_type = 'App\Status'; + $ai->item_id = $status->id; + $ai->has_media = (bool) $media->count(); + $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + $ai->meta = json_encode([ + 'caption' => $status->caption, + 'created_at' => $status->created_at, + 'type' => $status->type, + 'url' => $status->url(), + 'is_nsfw' => $status->is_nsfw, + 'scope' => $status->scope, + 'reblog' => $status->reblog_of_id, + 'likes_count' => $status->likes_count, + 'reblogs_count' => $status->reblogs_count, + ]); + $ai->save(); + + $u = $status->profile->user; + $u->has_interstitial = true; + $u->save(); + } + + if ($status->profile_id == $user->profile->id || $user->is_admin == true) { Cache::forget('profile:status_count:'.$status->profile_id); StatusDelete::dispatch($status); } + if($request->wantsJson()) { return response()->json(['Status successfully deleted.']); } else { - return redirect(Auth::user()->url()); + return redirect($user->url()); } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 94597e211..c68302667 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -66,6 +66,7 @@ class Kernel extends HttpKernel 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'twofactor' => \App\Http\Middleware\TwoFactorAuth::class, 'validemail' => \App\Http\Middleware\EmailVerificationCheck::class, + 'interstitial' => \App\Http\Middleware\AccountInterstitial::class, // 'restricted' => \App\Http\Middleware\RestrictedAccess::class, ]; } diff --git a/app/Http/Middleware/AccountInterstitial.php b/app/Http/Middleware/AccountInterstitial.php new file mode 100644 index 000000000..19bf8ae1e --- /dev/null +++ b/app/Http/Middleware/AccountInterstitial.php @@ -0,0 +1,48 @@ +is($ar)) { + if($request->user()->has_interstitial) { + if($request->wantsJson()) { + $res = ['_refresh'=>true,'error' => 403, 'message' => \App\AccountInterstitial::JSON_MESSAGE]; + return response()->json($res, 403); + } else { + return redirect('/i/warning'); + } + } else { + return $next($request); + } + } else { + return $next($request); + } + } +} diff --git a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php index c9777df5e..a225b48d9 100644 --- a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use DB; use Illuminate\Support\Str; use App\{ + AccountInterstitial, AccountLog, Activity, Avatar, @@ -68,6 +69,10 @@ class DeleteAccountPipeline implements ShouldQueue }); }); + DB::transaction(function() use ($user) { + AccountInterstitial::whereUserId($user->id)->delete(); + }); + DB::transaction(function() use ($user) { if($user->profile) { $avatar = $user->profile->avatar; @@ -79,6 +84,7 @@ class DeleteAccountPipeline implements ShouldQueue Bookmark::whereProfileId($user->profile_id)->forceDelete(); EmailVerification::whereUserId($user->id)->forceDelete(); StatusHashtag::whereProfileId($id)->delete(); + DirectMessage::whereFromId($user->profile_id)->delete(); FollowRequest::whereFollowingId($id) ->orWhere('follower_id', $id) ->forceDelete(); diff --git a/app/Status.php b/app/Status.php index 6232a3359..3b6f6120c 100644 --- a/app/Status.php +++ b/app/Status.php @@ -89,7 +89,8 @@ class Status extends Model public function thumb($showNsfw = false) { - return Cache::remember('status:thumb:'.$this->id, now()->addMinutes(15), function() use ($showNsfw) { + $key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id; + return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) { $type = $this->type ?? $this->setType(); $is_nsfw = !$showNsfw ? $this->is_nsfw : false; if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) { diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index 07eefb4a2..9023586e1 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -5,6 +5,8 @@ namespace App\Transformer\Api; use App\Status; use League\Fractal; use Cache; +use App\Services\HashidService; +use App\Services\MediaTagService; class StatusStatelessTransformer extends Fractal\TransformerAbstract { @@ -17,8 +19,11 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract public function transform(Status $status) { + $taggedPeople = MediaTagService::get($status->id); + return [ 'id' => (string) $status->id, + 'shortcode' => HashidService::encode($status->id), 'uri' => $status->url(), 'url' => $status->url(), 'in_reply_to_id' => $status->in_reply_to_id, @@ -42,13 +47,17 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'language' => null, 'pinned' => null, + 'mentions' => [], + 'tags' => [], 'pf_type' => $status->type ?? $status->setType(), 'reply_count' => (int) $status->reply_count, 'comments_disabled' => $status->comments_disabled ? true : false, 'thread' => false, 'replies' => [], - 'parent' => $status->parent() ? $this->transform($status->parent()) : [], + 'parent' => [], + 'place' => $status->place, 'local' => (bool) $status->local, + 'taggedPeople' => $taggedPeople ]; } diff --git a/app/User.php b/app/User.php index 717c4f316..4bd60a075 100644 --- a/app/User.php +++ b/app/User.php @@ -88,4 +88,9 @@ class User extends Authenticatable return $this->hasMany(AccountLog::class); } + public function interstitials() + { + return $this->hasMany(AccountInterstitial::class); + } + } diff --git a/app/Util/Blurhash/AC.php b/app/Util/Blurhash/AC.php new file mode 100644 index 000000000..a157c48b8 --- /dev/null +++ b/app/Util/Blurhash/AC.php @@ -0,0 +1,34 @@ + 0; + return $sign * pow(abs($base), $exp); + } +} \ No newline at end of file diff --git a/app/Util/Blurhash/Base83.php b/app/Util/Blurhash/Base83.php new file mode 100644 index 000000000..043ca2e60 --- /dev/null +++ b/app/Util/Blurhash/Base83.php @@ -0,0 +1,39 @@ + 9) || ($components_y < 1 || $components_y > 9)) { + throw new InvalidArgumentException("x and y component counts must be between 1 and 9 inclusive."); + } + $height = count($image); + $width = count($image[0]); + + $image_linear = $image; + if (!$linear) { + $image_linear = []; + for ($y = 0; $y < $height; $y++) { + $line = []; + for ($x = 0; $x < $width; $x++) { + $pixel = $image[$y][$x]; + $line[] = [ + Color::toLinear($pixel[0]), + Color::toLinear($pixel[1]), + Color::toLinear($pixel[2]) + ]; + } + $image_linear[] = $line; + } + } + + $components = []; + $scale = 1 / ($width * $height); + for ($y = 0; $y < $components_y; $y++) { + for ($x = 0; $x < $components_x; $x++) { + $normalisation = $x == 0 && $y == 0 ? 1 : 2; + $r = $g = $b = 0; + for ($i = 0; $i < $width; $i++) { + for ($j = 0; $j < $height; $j++) { + $color = $image_linear[$j][$i]; + $basis = $normalisation + * cos(M_PI * $i * $x / $width) + * cos(M_PI * $j * $y / $height); + + $r += $basis * $color[0]; + $g += $basis * $color[1]; + $b += $basis * $color[2]; + } + } + + $components[] = [ + $r * $scale, + $g * $scale, + $b * $scale + ]; + } + } + + $dc_value = DC::encode(array_shift($components) ?: []); + + $max_ac_component = 0; + foreach ($components as $component) { + $component[] = $max_ac_component; + $max_ac_component = max ($component); + } + + $quant_max_ac_component = (int) max(0, min(82, floor($max_ac_component * 166 - 0.5))); + $ac_component_norm_factor = ($quant_max_ac_component + 1) / 166; + + $ac_values = []; + foreach ($components as $component) { + $ac_values[] = AC::encode($component, $ac_component_norm_factor); + } + + $blurhash = Base83::encode($components_x - 1 + ($components_y - 1) * 9, 1); + $blurhash .= Base83::encode($quant_max_ac_component, 1); + $blurhash .= Base83::encode($dc_value, 4); + foreach ($ac_values as $ac_value) { + $blurhash .= Base83::encode((int) $ac_value, 2); + } + + return $blurhash; + } + + public static function decode (string $blurhash, int $width, int $height, float $punch = 1.0, bool $linear = false): array { + if (empty($blurhash) || strlen($blurhash) < 6) { + throw new InvalidArgumentException("Blurhash string must be at least 6 characters"); + } + + $size_info = Base83::decode($blurhash[0]); + $size_y = floor($size_info / 9) + 1; + $size_x = ($size_info % 9) + 1; + + $length = (int) strlen($blurhash); + $expected_length = (int) (4 + (2 * $size_y * $size_x)); + if ($length !== $expected_length) { + throw new InvalidArgumentException("Blurhash length mismatch: length is {$length} but it should be {$expected_length}"); + } + + $colors = [DC::decode(Base83::decode(substr($blurhash, 2, 4)))]; + + $quant_max_ac_component = Base83::decode($blurhash[1]); + $max_value = ($quant_max_ac_component + 1) / 166; + for ($i = 1; $i < $size_x * $size_y; $i++) { + $value = Base83::decode(substr($blurhash, 4 + $i * 2, 2)); + $colors[$i] = AC::decode($value, $max_value * $punch); + } + + $pixels = []; + for ($y = 0; $y < $height; $y++) { + $row = []; + for ($x = 0; $x < $width; $x++) { + $r = $g = $b = 0; + for ($j = 0; $j < $size_y; $j++) { + for ($i = 0; $i < $size_x; $i++) { + $color = $colors[$i + $j * $size_x]; + $basis = + cos((M_PI * $x * $i) / $width) * + cos((M_PI * $y * $j) / $height); + + $r += $color[0] * $basis; + $g += $color[1] * $basis; + $b += $color[2] * $basis; + } + } + + $row[] = $linear ? [$r, $g, $b] : [ + Color::toSRGB($r), + Color::toSRGB($g), + Color::toSRGB($b) + ]; + } + $pixels[] = $row; + } + + return $pixels; + } +} \ No newline at end of file diff --git a/app/Util/Blurhash/Color.php b/app/Util/Blurhash/Color.php new file mode 100644 index 000000000..4b64c32f7 --- /dev/null +++ b/app/Util/Blurhash/Color.php @@ -0,0 +1,19 @@ +> 16; + $g = ($value >> 8) & 255; + $b = $value & 255; + return [ + Color::toLinear($r), + Color::toLinear($g), + Color::toLinear($b) + ]; + } +} \ No newline at end of file diff --git a/app/Util/Media/Blurhash.php b/app/Util/Media/Blurhash.php new file mode 100644 index 000000000..2be054e02 --- /dev/null +++ b/app/Util/Media/Blurhash.php @@ -0,0 +1,47 @@ +mime, ['image/png', 'image/jpeg'])) { + return; + } + + $file = storage_path('app/' . $media->thumbnail_path); + + if(!is_file($file)) { + return; + } + + $image = imagecreatefromstring(file_get_contents($file)); + $width = imagesx($image); + $height = imagesy($image); + + $pixels = []; + for ($y = 0; $y < $height; ++$y) { + $row = []; + for ($x = 0; $x < $width; ++$x) { + $index = imagecolorat($image, $x, $y); + $colors = imagecolorsforindex($image, $index); + + $row[] = [$colors['red'], $colors['green'], $colors['blue']]; + } + $pixels[] = $row; + } + + $components_x = 4; + $components_y = 4; + $blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y); + if(strlen($blurhash) > 191) { + return; + } + return $blurhash; + } + +} \ No newline at end of file diff --git a/app/Util/Media/Image.php b/app/Util/Media/Image.php index bbc877ffa..0cf359237 100644 --- a/app/Util/Media/Image.php +++ b/app/Util/Media/Image.php @@ -182,6 +182,10 @@ class Image $media->save(); + + if($thumbnail) { + $this->generateBlurhash($media); + } Cache::forget('status:transformer:media:attachments:'.$media->status_id); Cache::forget('status:thumb:'.$media->status_id); } catch (Exception $e) { @@ -198,4 +202,13 @@ class Image return ['path' => $basePath, 'png' => $png]; } + + protected function generateBlurhash($media) + { + $blurhash = Blurhash::generate($media); + if($blurhash) { + $media->blurhash = $blurhash; + $media->save(); + } + } } diff --git a/database/migrations/2020_12_01_073200_add_indexes_to_likes_table.php b/database/migrations/2020_12_01_073200_add_indexes_to_likes_table.php new file mode 100644 index 000000000..1a1256fbb --- /dev/null +++ b/database/migrations/2020_12_01_073200_add_indexes_to_likes_table.php @@ -0,0 +1,34 @@ +index('profile_id', 'likes_profile_id_index'); + $table->index('status_id', 'likes_status_id_index'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('likes', function (Blueprint $table) { + $table->dropIndex('likes_profile_id_index'); + $table->dropIndex('likes_status_id_index'); + }); + } +} diff --git a/database/migrations/2020_12_03_050018_create_account_interstitials_table.php b/database/migrations/2020_12_03_050018_create_account_interstitials_table.php new file mode 100644 index 000000000..0b0e8862c --- /dev/null +++ b/database/migrations/2020_12_03_050018_create_account_interstitials_table.php @@ -0,0 +1,53 @@ +id(); + $table->unsignedInteger('user_id')->nullable()->index(); + $table->string('type')->nullable(); + $table->string('view')->nullable(); + $table->bigInteger('item_id')->unsigned()->nullable(); + $table->string('item_type')->nullable(); + $table->boolean('has_media')->default(false)->nullable(); + $table->string('blurhash')->nullable(); + $table->text('message')->nullable(); + $table->text('violation_header')->nullable(); + $table->text('violation_body')->nullable(); + $table->json('meta')->nullable(); + $table->text('appeal_message')->nullable(); + $table->timestamp('appeal_requested_at')->nullable()->index(); + $table->timestamp('appeal_handled_at')->nullable()->index(); + $table->timestamp('read_at')->nullable()->index(); + $table->timestamps(); + }); + + Schema::table('users', function(Blueprint $table) { + $table->boolean('has_interstitial')->default(false)->index(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('account_interstitials'); + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('has_interstitial'); + }); + } +} diff --git a/package-lock.json b/package-lock.json index 494c83bfc..9ed8b9ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1649,6 +1649,11 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz", "integrity": "sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==" }, + "blurhash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-1.1.3.tgz", + "integrity": "sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw==" + }, "bn.js": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.1.1.tgz", diff --git a/package.json b/package.json index 4c1ccd439..3a6285a0b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@trevoreyre/autocomplete-vue": "^2.2.0", "animate.css": "^4.1.0", + "blurhash": "^1.1.3", "bootstrap-vue": "^2.16.0", "filesize": "^3.6.1", "howler": "^2.2.0", diff --git a/public/js/activity.js b/public/js/activity.js index 66eea31c7..4fdce4aea 100644 Binary files a/public/js/activity.js and b/public/js/activity.js differ diff --git a/public/js/app.js b/public/js/app.js index 76304486c..dbad08097 100644 Binary files a/public/js/app.js and b/public/js/app.js differ diff --git a/public/js/collectioncompose.js b/public/js/collectioncompose.js index 7d894f3fd..8df735e94 100644 Binary files a/public/js/collectioncompose.js and b/public/js/collectioncompose.js differ diff --git a/public/js/collections.js b/public/js/collections.js index 7bba7df6d..2d8dc26b8 100644 Binary files a/public/js/collections.js and b/public/js/collections.js differ diff --git a/public/js/compose.js b/public/js/compose.js index 809574fbb..e7857291f 100644 Binary files a/public/js/compose.js and b/public/js/compose.js differ diff --git a/public/js/direct.js b/public/js/direct.js index 052bede4c..8887f2dd7 100644 Binary files a/public/js/direct.js and b/public/js/direct.js differ diff --git a/public/js/discover.js b/public/js/discover.js index 6cd6bbc25..fd516e6ca 100644 Binary files a/public/js/discover.js and b/public/js/discover.js differ diff --git a/public/js/hashtag.js b/public/js/hashtag.js index 7ed5d4ab9..eae521b25 100644 Binary files a/public/js/hashtag.js and b/public/js/hashtag.js differ diff --git a/public/js/memoryprofile.js b/public/js/memoryprofile.js new file mode 100644 index 000000000..caab9dabb Binary files /dev/null and b/public/js/memoryprofile.js differ diff --git a/public/js/mode-dot.js b/public/js/mode-dot.js index 74719c40f..3770f2662 100644 Binary files a/public/js/mode-dot.js and b/public/js/mode-dot.js differ diff --git a/public/js/profile-directory.js b/public/js/profile-directory.js index 2afbae464..78f1ccbea 100644 Binary files a/public/js/profile-directory.js and b/public/js/profile-directory.js differ diff --git a/public/js/profile.js b/public/js/profile.js index dd544cad5..2b806e3da 100644 Binary files a/public/js/profile.js and b/public/js/profile.js differ diff --git a/public/js/quill.js b/public/js/quill.js index 832411642..c729e43a2 100644 Binary files a/public/js/quill.js and b/public/js/quill.js differ diff --git a/public/js/rempos.js b/public/js/rempos.js index 870fcace9..507035dc0 100644 Binary files a/public/js/rempos.js and b/public/js/rempos.js differ diff --git a/public/js/rempro.js b/public/js/rempro.js index 0a7215a78..79f96c846 100644 Binary files a/public/js/rempro.js and b/public/js/rempro.js differ diff --git a/public/js/search.js b/public/js/search.js index 30c84b51d..c42c7bd95 100644 Binary files a/public/js/search.js and b/public/js/search.js differ diff --git a/public/js/status.js b/public/js/status.js index 7448fe911..916ef53ac 100644 Binary files a/public/js/status.js and b/public/js/status.js differ diff --git a/public/js/story-compose.js b/public/js/story-compose.js index 3b55a9212..1b3f4dbd9 100644 Binary files a/public/js/story-compose.js and b/public/js/story-compose.js differ diff --git a/public/js/theme-monokai.js b/public/js/theme-monokai.js index 50e47bb97..88f12f95b 100644 Binary files a/public/js/theme-monokai.js and b/public/js/theme-monokai.js differ diff --git a/public/js/timeline.js b/public/js/timeline.js index a4412c1d5..bc2dde2c7 100644 Binary files a/public/js/timeline.js and b/public/js/timeline.js differ diff --git a/public/js/vendor.js b/public/js/vendor.js index 66d8d35cb..b545a291d 100644 Binary files a/public/js/vendor.js and b/public/js/vendor.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 4f20ddd23..9651aab36 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 4664ac4e6..dbfc5e607 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -6,6 +6,7 @@ require('bootstrap'); window.axios = require('axios'); window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; require('readmore-js'); +window.blurhash = require("blurhash"); let token = document.head.querySelector('meta[name="csrf-token"]'); if (token) { diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 3a85aad87..72ed91e81 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -635,15 +635,23 @@ export default { methods: { fetchProfile() { let self = this; - axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => { - self.profile = res.data; - window.pixelfed.currentUser = res.data; - if(res.data.locked == true) { - self.visibility = 'private'; - self.visibilityTag = 'Followers Only'; + if(window._sharedData.curUser) { + self.profile = window._sharedData.curUser; + if(self.profile.locked == true) { + self.visibility = 'private'; + self.visibilityTag = 'Followers Only'; } - }).catch(err => { - }); + } else { + axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => { + self.profile = res.data; + window.pixelfed.currentUser = res.data; + if(res.data.locked == true) { + self.visibility = 'private'; + self.visibilityTag = 'Followers Only'; + } + }).catch(err => { + }); + } }, addMedia(event) { diff --git a/resources/views/account/moderation/post/cw.blade.php b/resources/views/account/moderation/post/cw.blade.php new file mode 100644 index 000000000..d1e1d3537 --- /dev/null +++ b/resources/views/account/moderation/post/cw.blade.php @@ -0,0 +1,130 @@ +@extends('layouts.blank') + +@section('content') + +
Your Post Contains Sensitive or Offensive Material
+We applied a Content Warning to your post because it doesn't follow our Community Guidelines.
+To continue you must click the "I Understand" button or "REQUEST APPEAL" button at the bottom of this page.
+Post Details
+ @if($interstitial->has_media) ++ Caption: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
++ Comment: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
+Review the Community Guidelines
+We want to keep {{config('app.name')}} a safe place for everyone, and we created these Community Guidelines to support and protect our community.
+Your Post Has Been Deleted
+We removed your post because it doesn't follow our Community Guidelines. If you violate our guidelines again, your account may be restricted or disabled.
+To continue you must click the "I Understand" button at the bottom of this page.
+Post Details
+ @if($interstitial->has_media) ++ Caption: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
++ Comment: {{$meta->caption}} +
++ Posted on {{$meta->created_at}} +
++ {{$meta->url}} +
+Review the Community Guidelines
+We want to keep {{config('app.name')}} a safe place for everyone, and we created these Community Guidelines to support and protect our community.
+Your Post Was Unlisted
+We removed your post from public timelines because it doesn't follow our Community Guidelines.
+Post Details
+ @if($interstitial->has_media) ++ Caption: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
++ Comment: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
+Review the Community Guidelines
+We want to keep {{config('app.name')}} a safe place for everyone, and we created these Community Guidelines to support and protect our community.
+{{App\AccountInterstitial::whereNull('appeal_handled_at')->whereNotNull('appeal_requested_at')->count()}}
+active appeals
+{{App\AccountInterstitial::whereNotNull('appeal_handled_at')->whereNotNull('appeal_requested_at')->count()}}
+closed appeals
+No appeals found!
+{{$appeal->type}}
+ @if($appeal->item_type) +{{starts_with($appeal->item_type, 'App\\') ? explode('\\',$appeal->item_type)[1] : $appeal->item_type}}
+ @endif + +@{{$appeal->user->username}}
+{{$appeal->created_at->diffForHumans(null, null, true)}}
++ +
+{!!$appeals->render()!!}
+Moderation Appeal
+From @{{$appeal->user->username}} about {{$appeal->appeal_requested_at->diffForHumans()}}.
++ {{$appeal->has_media ? 'Caption' : 'Comment'}}: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
++ Message: {{$appeal->appeal_message}} +
++ {{$appeal->has_media ? 'Caption' : 'Comment'}}: {{$meta->caption}} +
+ @endif ++ Like Count: {{$meta->likes_count}} +
++ Share Count: {{$meta->reblogs_count}} +
++ Timestamp: {{now()->parse($meta->created_at)->format('r')}} +
++ URL: {{$meta->url}} +
++ Message: {{$appeal->appeal_message}} +
++ Open Appeals: {{App\AccountInterstitial::whereUserId($appeal->user_id)->whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count()}} +
++ Total Appeals: {{App\AccountInterstitial::whereUserId($appeal->user_id)->whereNotNull('appeal_requested_at')->count()}} +
++ Total Warnings: {{App\AccountInterstitial::whereUserId($appeal->user_id)->count()}} +
++ Status Count: {{$appeal->user->statuses()->count()}} +
++ Joined: {{$appeal->user->created_at->diffForHumans(null, null, false)}} +
+