diff --git a/app/Console/Commands/FixHashtags.php b/app/Console/Commands/FixHashtags.php new file mode 100644 index 000000000..dd292e6f5 --- /dev/null +++ b/app/Console/Commands/FixHashtags.php @@ -0,0 +1,109 @@ +info(' ____ _ ______ __ '); + $this->info(' / __ \(_) _____ / / __/__ ____/ / '); + $this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / '); + $this->info(' / ____/ /> info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ '); + $this->info(' '); + $this->info(' '); + $this->info('Pixelfed version: ' . config('pixelfed.version')); + $this->info(' '); + $this->info('Running Fix Hashtags command'); + $this->info(' '); + + $missingCount = StatusHashtag::doesntHave('profile')->doesntHave('status')->count(); + if($missingCount > 0) { + $this->info("Found {$missingCount} orphaned StatusHashtag records to delete ..."); + $this->info(' '); + $bar = $this->output->createProgressBar($missingCount); + $bar->start(); + foreach(StatusHashtag::doesntHave('profile')->doesntHave('status')->get() as $tag) { + $tag->delete(); + $bar->advance(); + } + $bar->finish(); + $this->info(' '); + } else { + $this->info(' '); + $this->info('Found no orphaned hashtags to delete!'); + } + + + $this->info(' '); + + $count = StatusHashtag::whereNull('status_visibility')->count(); + if($count > 0) { + $this->info("Found {$count} hashtags to fix ..."); + $this->info(' '); + } else { + $this->info('Found no hashtags to fix!'); + $this->info(' '); + return; + } + + $bar = $this->output->createProgressBar($count); + $bar->start(); + + StatusHashtag::with('status') + ->whereNull('status_visibility') + ->chunk(50, function($tags) use($bar) { + foreach($tags as $tag) { + if(!$tag->status || !$tag->status->scope) { + continue; + } + $tag->status_visibility = $tag->status->scope; + $tag->save(); + $bar->advance(); + } + }); + + $bar->finish(); + $this->info(' '); + $this->info(' '); + } +} diff --git a/app/HashtagFollow.php b/app/HashtagFollow.php new file mode 100644 index 000000000..0503330b0 --- /dev/null +++ b/app/HashtagFollow.php @@ -0,0 +1,19 @@ +belongsTo(Hashtag::class); + } +} diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index a18efa5d2..6fa1073f2 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -59,14 +59,11 @@ class BaseApiController extends Controller $res = $this->fractal->createData($resource)->toArray(); } else { $this->validate($request, [ - 'page' => 'nullable|integer|min:1', + 'page' => 'nullable|integer|min:1|max:10', 'limit' => 'nullable|integer|min:1|max:10' ]); $limit = $request->input('limit') ?? 10; $page = $request->input('page') ?? 1; - if($page > 3) { - return response()->json([]); - } $end = (int) $page * $limit; $start = (int) $end - $limit; $res = NotificationService::get($pid, $start, $end); diff --git a/app/Http/Controllers/DiscoverController.php b/app/Http/Controllers/DiscoverController.php index bbc8d765c..1a0e6d23e 100644 --- a/app/Http/Controllers/DiscoverController.php +++ b/app/Http/Controllers/DiscoverController.php @@ -6,6 +6,7 @@ use App\{ DiscoverCategory, Follower, Hashtag, + HashtagFollow, Profile, Status, StatusHashtag, @@ -17,6 +18,7 @@ use App\Transformer\Api\StatusStatelessTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; +use App\Services\StatusHashtagService; class DiscoverController extends Controller { @@ -36,57 +38,11 @@ class DiscoverController extends Controller public function showTags(Request $request, $hashtag) { - abort_if(!Auth::check(), 403); + abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403); - $tag = Hashtag::whereSlug($hashtag) - ->firstOrFail(); - - $page = 1; - $key = 'discover:tag-'.$tag->id.':page-'.$page; - $keyMinutes = 15; - - $posts = Cache::remember($key, now()->addMinutes($keyMinutes), function() use ($tag, $request) { - $tags = StatusHashtag::select('status_id') - ->whereHashtagId($tag->id) - ->orderByDesc('id') - ->take(48) - ->pluck('status_id'); - - return Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'created_at', - 'updated_at' - )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) - ->with('media') - ->whereLocal(true) - ->whereNull('uri') - ->whereIn('id', $tags) - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->whereNull('url') - ->whereNull('uri') - ->withCount(['likes', 'comments']) - ->whereIsNsfw(false) - ->whereVisibility('public') - ->orderBy('id', 'desc') - ->get(); - }); - - if($posts->count() == 0) { - abort(404); - } - - return view('discover.tags.show', compact('tag', 'posts')); + $tag = Hashtag::whereSlug($hashtag)->firstOrFail(); + $tagCount = StatusHashtagService::count($tag->id); + return view('discover.tags.show', compact('tag', 'tagCount')); } public function showCategory(Request $request, $slug) @@ -156,7 +112,6 @@ class DiscoverController extends Controller return $res; } - public function loopWatch(Request $request) { abort_if(!Auth::check(), 403); @@ -171,4 +126,26 @@ class DiscoverController extends Controller return response()->json(200); } + + public function getHashtags(Request $request) + { + $auth = Auth::check(); + abort_if(!config('instance.discover.tags.is_public') && !$auth, 403); + + $this->validate($request, [ + 'hashtag' => 'required|alphanum|min:2|max:124', + 'page' => 'nullable|integer|min:1|max:' . ($auth ? 19 : 3) + ]); + + $page = $request->input('page') ?? '1'; + $end = $page > 1 ? $page * 9 : 1; + $tag = $request->input('hashtag'); + + $hashtag = Hashtag::whereName($tag)->firstOrFail(); + $res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end); + if($page == 1) { + $res['follows'] = HashtagFollow::whereUserId(Auth::id())->whereHashtagId($hashtag->id)->exists(); + } + return $res; + } } diff --git a/app/Http/Controllers/HashtagFollowController.php b/app/Http/Controllers/HashtagFollowController.php new file mode 100644 index 000000000..585248abb --- /dev/null +++ b/app/Http/Controllers/HashtagFollowController.php @@ -0,0 +1,61 @@ +middleware('auth'); + } + + public function store(Request $request) + { + $this->validate($request, [ + 'name' => 'required|alpha_num|min:1|max:124|exists:hashtags,name' + ]); + + $user = Auth::user(); + $profile = $user->profile; + $tag = $request->input('name'); + + $hashtag = Hashtag::whereName($tag)->firstOrFail(); + + $hashtagFollow = HashtagFollow::firstOrCreate([ + 'user_id' => $user->id, + 'profile_id' => $user->profile_id ?? $user->profile->id, + 'hashtag_id' => $hashtag->id + ]); + + if($hashtagFollow->wasRecentlyCreated) { + $state = 'created'; + // todo: send to HashtagFollowService + } else { + $state = 'deleted'; + $hashtagFollow->delete(); + } + + return [ + 'state' => $state + ]; + } + + public function getTags(Request $request) + { + return HashtagFollow::with('hashtag')->whereUserId(Auth::id()) + ->inRandomOrder() + ->take(3) + ->get() + ->map(function($follow, $k) { + return $follow->hashtag->name; + }); + } +} diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index dce50d009..bd22505e3 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -211,6 +211,10 @@ class PublicApiController extends Controller 'limit' => 'nullable|integer|max:20' ]); + if(config('instance.timeline.local.is_public') == false && !Auth::check()) { + abort(403, 'Authentication required.'); + } + $page = $request->input('page'); $min = $request->input('min_id'); $max = $request->input('max_id'); @@ -331,6 +335,8 @@ class PublicApiController extends Controller ->orWhere('status', '!=', null) ->pluck('id'); }); + + $private = $private->diff($following)->flatten(); $filters = UserFilter::whereUserId($pid) ->whereFilterableType('App\Profile') diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 6c9d4b83c..fc336c301 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -143,6 +143,7 @@ class SearchController extends Controller 'tokens' => [$item->caption], 'name' => $item->caption, 'thumb' => $item->thumb(), + 'filter' => $item->firstMedia()->filter_class ]; }); $tokens['posts'] = $posts; diff --git a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php index ba265379d..06e8191a6 100644 --- a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php @@ -80,8 +80,10 @@ class DeleteAccountPipeline implements ShouldQueue Bookmark::whereProfileId($user->profile->id)->forceDelete(); EmailVerification::whereUserId($user->id)->forceDelete(); - $id = $user->profile->id; + + StatusHashtag::whereProfileId($id)->delete(); + FollowRequest::whereFollowingId($id)->orWhere('follower_id', $id)->forceDelete(); Follower::whereProfileId($id)->orWhere('following_id', $id)->forceDelete(); diff --git a/app/Jobs/StatusPipeline/StatusEntityLexer.php b/app/Jobs/StatusPipeline/StatusEntityLexer.php index ecad11e1e..197477672 100644 --- a/app/Jobs/StatusPipeline/StatusEntityLexer.php +++ b/app/Jobs/StatusPipeline/StatusEntityLexer.php @@ -89,6 +89,9 @@ class StatusEntityLexer implements ShouldQueue $status = $this->status; foreach ($tags as $tag) { + if(mb_strlen($tag) > 124) { + continue; + } DB::transaction(function () use ($status, $tag) { $slug = str_slug($tag, '-', false); $hashtag = Hashtag::firstOrCreate( @@ -98,7 +101,8 @@ class StatusEntityLexer implements ShouldQueue [ 'status_id' => $status->id, 'hashtag_id' => $hashtag->id, - 'profile_id' => $status->profile_id + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, ] ); }); diff --git a/app/Observers/StatusHashtagObserver.php b/app/Observers/StatusHashtagObserver.php new file mode 100644 index 000000000..51832bcb8 --- /dev/null +++ b/app/Observers/StatusHashtagObserver.php @@ -0,0 +1,64 @@ +hashtag_id, $hashtag->status_id); + } + + /** + * Handle the notification "updated" event. + * + * @param \App\Notification $notification + * @return void + */ + public function updated(StatusHashtag $hashtag) + { + StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id); + } + + /** + * Handle the notification "deleted" event. + * + * @param \App\Notification $notification + * @return void + */ + public function deleted(StatusHashtag $hashtag) + { + StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id); + } + + /** + * Handle the notification "restored" event. + * + * @param \App\Notification $notification + * @return void + */ + public function restored(StatusHashtag $hashtag) + { + StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id); + } + + /** + * Handle the notification "force deleted" event. + * + * @param \App\Notification $notification + * @return void + */ + public function forceDeleted(StatusHashtag $hashtag) + { + StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cf52e7fd4..6bfe29459 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,11 +5,13 @@ namespace App\Providers; use App\Observers\{ AvatarObserver, NotificationObserver, + StatusHashtagObserver, UserObserver }; use App\{ Avatar, Notification, + StatusHashtag, User }; use Auth, Horizon, URL; @@ -31,6 +33,7 @@ class AppServiceProvider extends ServiceProvider Avatar::observe(AvatarObserver::class); Notification::observe(NotificationObserver::class); + StatusHashtag::observe(StatusHashtagObserver::class); User::observe(UserObserver::class); Horizon::auth(function ($request) { diff --git a/app/Services/StatusHashtagService.php b/app/Services/StatusHashtagService.php new file mode 100644 index 000000000..7c5ed87bb --- /dev/null +++ b/app/Services/StatusHashtagService.php @@ -0,0 +1,80 @@ +whereStatusVisibility('public') + ->whereHas('media') + ->skip($stop) + ->latest() + ->take(9) + ->pluck('status_id') + ->map(function ($i, $k) use ($id) { + return self::getStatus($i, $id); + }) + ->all(); + } + + public static function coldGet($id, $start = 0, $stop = 2000) + { + $stop = $stop > 2000 ? 2000 : $stop; + $ids = StatusHashtag::whereHashtagId($id) + ->whereStatusVisibility('public') + ->whereHas('media') + ->latest() + ->skip($start) + ->take($stop) + ->pluck('status_id'); + foreach($ids as $key) { + self::set($id, $key); + } + return $ids; + } + + public static function set($key, $val) + { + return Redis::zadd(self::CACHE_KEY . $key, $val, $val); + } + + public static function del($key) + { + return Redis::zrem(self::CACHE_KEY . $key, $key); + } + + public static function count($id) + { + $count = Redis::zcount(self::CACHE_KEY . $id, '-inf', '+inf'); + if(empty($count)) { + $count = StatusHashtag::whereHashtagId($id)->count(); + } + return $count; + } + + public static function getStatus($statusId, $hashtagId) + { + return Cache::remember('pf:services:status-hashtag:post:'.$statusId.':hashtag:'.$hashtagId, now()->addMonths(3), function() use($statusId, $hashtagId) { + $statusHashtag = StatusHashtag::with('profile', 'status', 'hashtag') + ->whereStatusVisibility('public') + ->whereStatusId($statusId) + ->whereHashtagId($hashtagId) + ->first(); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($statusHashtag, new StatusHashtagTransformer()); + return $fractal->createData($resource)->toArray(); + }); + } +} \ No newline at end of file diff --git a/app/StatusHashtag.php b/app/StatusHashtag.php index 15e282025..cee8e39b9 100644 --- a/app/StatusHashtag.php +++ b/app/StatusHashtag.php @@ -9,7 +9,8 @@ class StatusHashtag extends Model public $fillable = [ 'status_id', 'hashtag_id', - 'profile_id' + 'profile_id', + 'status_visibility' ]; public function status() @@ -26,4 +27,16 @@ class StatusHashtag extends Model { return $this->belongsTo(Profile::class); } + + public function media() + { + return $this->hasManyThrough( + Media::class, + Status::class, + 'id', + 'status_id', + 'status_id', + 'id' + ); + } } diff --git a/app/Transformer/Api/StatusHashtagTransformer.php b/app/Transformer/Api/StatusHashtagTransformer.php new file mode 100644 index 000000000..1dfdba3b9 --- /dev/null +++ b/app/Transformer/Api/StatusHashtagTransformer.php @@ -0,0 +1,38 @@ +hashtag; + $status = $statusHashtag->status; + $profile = $statusHashtag->profile; + + return [ + 'status' => [ + 'id' => (int) $status->id, + 'type' => $status->type, + 'url' => $status->url(), + 'thumb' => $status->thumb(), + 'filter' => $status->firstMedia()->filter_class, + 'sensitive' => (bool) $status->is_nsfw, + 'like_count' => $status->likes_count, + 'share_count' => $status->reblogs_count, + 'user' => [ + 'username' => $profile->username, + 'url' => $profile->url(), + ], + 'visibility' => $status->visibility ?? $status->scope + ], + 'hashtag' => [ + 'name' => $hashtag->name, + 'url' => $hashtag->url(), + ] + ]; + } +} diff --git a/app/Util/Lexer/Extractor.php b/app/Util/Lexer/Extractor.php index 9e194b068..bcdbba919 100755 --- a/app/Util/Lexer/Extractor.php +++ b/app/Util/Lexer/Extractor.php @@ -264,7 +264,9 @@ class Extractor extends Regex if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) { continue; } - + if(mb_strlen($hashtag[0]) > 124) { + continue; + } $tags[] = [ 'hashtag' => $hashtag[0], 'indices' => [$start_position, $end_position], diff --git a/app/Util/RateLimit/User.php b/app/Util/RateLimit/User.php index c93aa6c4f..f54c94d33 100644 --- a/app/Util/RateLimit/User.php +++ b/app/Util/RateLimit/User.php @@ -49,8 +49,23 @@ trait User { return 500; } + public function getMaxUserBansPerDayAttribute() + { + return 100; + } + public function getMaxInstanceBansPerDayAttribute() { return 100; } + + public function getMaxHashtagFollowsPerHourAttribute() + { + return 20; + } + + public function getMaxHashtagFollowsPerDayAttribute() + { + return 100; + } } \ No newline at end of file diff --git a/config/instance.php b/config/instance.php index 6fc04c902..15d1a400e 100644 --- a/config/instance.php +++ b/config/instance.php @@ -1,15 +1,33 @@ env('INSTANCE_CONTACT_EMAIL'), + + 'announcement' => [ + 'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true), + 'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.
Something else here') + ], 'contact' => [ 'enabled' => env('INSTANCE_CONTACT_FORM', false), 'max_per_day' => env('INSTANCE_CONTACT_MAX_PER_DAY', 1), ], - 'announcement' => [ - 'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true), - 'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.
Something else here') - ] + 'discover' => [ + 'loops' => [ + 'enabled' => false + ], + 'tags' => [ + 'is_public' => env('INSTANCE_PUBLIC_HASHTAGS', false) + ], + ], + + 'email' => env('INSTANCE_CONTACT_EMAIL'), + + 'timeline' => [ + 'local' => [ + 'is_public' => env('INSTANCE_PUBLIC_LOCAL_TIMELINE', false) + ] + ], + + ]; \ No newline at end of file diff --git a/database/migrations/2019_07_05_034644_create_hashtag_follows_table.php b/database/migrations/2019_07_05_034644_create_hashtag_follows_table.php new file mode 100644 index 000000000..20f20f524 --- /dev/null +++ b/database/migrations/2019_07_05_034644_create_hashtag_follows_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->bigInteger('user_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->bigInteger('hashtag_id')->unsigned()->index(); + $table->unique(['user_id', 'profile_id', 'hashtag_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('hashtag_follows'); + } +} diff --git a/database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php b/database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php new file mode 100644 index 000000000..fab19c210 --- /dev/null +++ b/database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php @@ -0,0 +1,32 @@ +string('status_visibility')->nullable()->index()->after('profile_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('status_hashtags', function (Blueprint $table) { + $table->dropColumn('status_visibility'); + }); + } +} diff --git a/public/js/direct.js b/public/js/direct.js deleted file mode 100644 index 3c9c23974..000000000 Binary files a/public/js/direct.js and /dev/null differ diff --git a/public/js/hashtag.js b/public/js/hashtag.js new file mode 100644 index 000000000..197c93842 Binary files /dev/null and b/public/js/hashtag.js differ diff --git a/public/js/loops.js b/public/js/loops.js index 277aaf8d7..60a1c1ddb 100644 Binary files a/public/js/loops.js and b/public/js/loops.js differ diff --git a/public/js/mode-dot.js b/public/js/mode-dot.js index 95c1dd94b..9517e65d9 100644 Binary files a/public/js/mode-dot.js and b/public/js/mode-dot.js differ diff --git a/public/js/profile.js b/public/js/profile.js index 0ddcd0afe..84b7d23c5 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 4a8e134f1..3c07d0bfa 100644 Binary files a/public/js/quill.js and b/public/js/quill.js differ diff --git a/public/js/search.js b/public/js/search.js index e4c95d9cb..c7ff1bd3b 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 ba4af018e..6c35e9bf3 100644 Binary files a/public/js/status.js and b/public/js/status.js differ diff --git a/public/js/theme-monokai.js b/public/js/theme-monokai.js index 3a510271a..ff34783e2 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 f4c3175df..62a3690bc 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 0cbfa2406..6a508774a 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 cbe51aa43..c0c17236c 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/js/components/Hashtag.vue b/resources/assets/js/components/Hashtag.vue new file mode 100644 index 000000000..9d9c5fcd8 --- /dev/null +++ b/resources/assets/js/components/Hashtag.vue @@ -0,0 +1,187 @@ + + + + + \ No newline at end of file diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index 8ea58b59c..27973c4d5 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -107,8 +107,8 @@
-

- {{statusUsername}} +

+ {{statusUsername}}

@@ -124,10 +124,13 @@
-
-

This comment may contain sensitive material

-

Show

-
+ + {{truncate(reply.account.username,15)}} + + This comment may contain sensitive material + Show + +

diff --git a/resources/assets/js/components/PostMenu.vue b/resources/assets/js/components/PostMenu.vue index dc5ab0933..b219978ad 100644 --- a/resources/assets/js/components/PostMenu.vue +++ b/resources/assets/js/components/PostMenu.vue @@ -6,8 +6,8 @@