فهرست منبع

Merge pull request #2895 from pixelfed/staging

Archives, Polls and Stories
daniel 3 سال پیش
والد
کامیت
f593e2b709
100فایلهای تغییر یافته به همراه4835 افزوده شده و 1690 حذف شده
  1. 27 0
      CHANGELOG.md
  2. 1 1
      app/Collection.php
  3. 1 1
      app/CollectionItem.php
  4. 1 1
      app/Console/Commands/FailedJobGC.php
  5. 27 72
      app/Console/Commands/StoryGC.php
  6. 19 0
      app/HasSnowflakePrimary.php
  7. 9 0
      app/Http/Controllers/AdminController.php
  8. 21 2
      app/Http/Controllers/Api/ApiV1Controller.php
  9. 108 21
      app/Http/Controllers/Api/BaseApiController.php
  10. 74 3
      app/Http/Controllers/ComposeController.php
  11. 1 2
      app/Http/Controllers/LikeController.php
  12. 73 0
      app/Http/Controllers/PollController.php
  13. 6 5
      app/Http/Controllers/ProfileController.php
  14. 79 103
      app/Http/Controllers/PublicApiController.php
  15. 66 2
      app/Http/Controllers/SettingsController.php
  16. 501 0
      app/Http/Controllers/StoryComposeController.php
  17. 162 351
      app/Http/Controllers/StoryController.php
  18. 51 48
      app/Jobs/FollowPipeline/FollowPipeline.php
  19. 56 0
      app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php
  20. 43 0
      app/Jobs/InstancePipeline/InstanceCrawlPipeline.php
  21. 47 0
      app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php
  22. 94 81
      app/Jobs/StatusPipeline/StatusActivityPubDeliver.php
  23. 136 0
      app/Jobs/StoryPipeline/StoryDelete.php
  24. 169 0
      app/Jobs/StoryPipeline/StoryExpire.php
  25. 107 0
      app/Jobs/StoryPipeline/StoryFanout.php
  26. 144 0
      app/Jobs/StoryPipeline/StoryFetch.php
  27. 70 0
      app/Jobs/StoryPipeline/StoryReactionDeliver.php
  28. 70 0
      app/Jobs/StoryPipeline/StoryReplyDeliver.php
  29. 61 0
      app/Jobs/StoryPipeline/StoryRotateMedia.php
  30. 70 0
      app/Jobs/StoryPipeline/StoryViewDeliver.php
  31. 1 1
      app/Mail/ContactAdmin.php
  32. 4 0
      app/Media.php
  33. 35 0
      app/Models/Poll.php
  34. 11 0
      app/Models/PollVote.php
  35. 64 0
      app/Observers/FollowerObserver.php
  36. 0 1
      app/Place.php
  37. 317 324
      app/Profile.php
  38. 3 0
      app/Providers/AppServiceProvider.php
  39. 31 4
      app/Services/AccountService.php
  40. 24 36
      app/Services/FollowerService.php
  41. 12 0
      app/Services/InstanceService.php
  42. 5 0
      app/Services/LikeService.php
  43. 7 6
      app/Services/MediaPathService.php
  44. 62 0
      app/Services/MediaService.php
  45. 1 1
      app/Services/MediaStorageService.php
  46. 76 0
      app/Services/NodeinfoService.php
  47. 2 2
      app/Services/NotificationService.php
  48. 97 0
      app/Services/PollService.php
  49. 7 23
      app/Services/ProfileService.php
  50. 32 4
      app/Services/SnowflakeService.php
  51. 18 0
      app/Services/StatusHashtagService.php
  52. 20 5
      app/Services/StatusService.php
  53. 162 0
      app/Services/StoryService.php
  54. 410 405
      app/Status.php
  55. 44 9
      app/Story.php
  56. 2 9
      app/Transformer/ActivityPub/StatusTransformer.php
  57. 46 0
      app/Transformer/ActivityPub/Verb/CreateQuestion.php
  58. 29 0
      app/Transformer/ActivityPub/Verb/CreateStory.php
  59. 25 0
      app/Transformer/ActivityPub/Verb/DeleteStory.php
  60. 89 0
      app/Transformer/ActivityPub/Verb/Question.php
  61. 39 0
      app/Transformer/ActivityPub/Verb/StoryVerb.php
  62. 41 75
      app/Transformer/Api/Mastodon/v1/StatusTransformer.php
  63. 4 2
      app/Transformer/Api/NotificationTransformer.php
  64. 12 34
      app/Transformer/Api/StatusStatelessTransformer.php
  65. 10 32
      app/Transformer/Api/StatusTransformer.php
  66. 74 3
      app/Util/ActivityPub/Helpers.php
  67. 373 15
      app/Util/ActivityPub/Inbox.php
  68. 34 0
      app/Util/ActivityPub/Validator/StoryValidator.php
  69. 57 0
      app/Util/Lexer/Bearcap.php
  70. 15 0
      app/Util/Media/License.php
  71. 3 2
      app/Util/Site/Config.php
  72. 7 1
      config/database.php
  73. 1 0
      config/exp.php
  74. 3 2
      config/horizon.php
  75. 1 1
      config/image-optimizer.php
  76. 4 0
      config/instance.php
  77. 2 0
      config/pixelfed.php
  78. 46 0
      database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php
  79. 42 0
      database/migrations/2021_07_29_014835_create_polls_table.php
  80. 37 0
      database/migrations/2021_07_29_014849_create_poll_votes_table.php
  81. 54 0
      database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php
  82. 46 0
      database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php
  83. BIN
      public/css/app.css
  84. BIN
      public/css/appdark.css
  85. BIN
      public/css/landing.css
  86. BIN
      public/fonts/fa-light-300.eot
  87. BIN
      public/fonts/fa-light-300.svg
  88. BIN
      public/fonts/fa-light-300.ttf
  89. BIN
      public/fonts/fa-light-300.woff
  90. BIN
      public/fonts/fa-light-300.woff2
  91. BIN
      public/fonts/fa-regular-400.eot
  92. BIN
      public/fonts/fa-regular-400.svg
  93. BIN
      public/fonts/fa-regular-400.ttf
  94. BIN
      public/fonts/fa-regular-400.woff
  95. BIN
      public/fonts/fa-regular-400.woff2
  96. BIN
      public/fonts/fa-solid-900.eot
  97. BIN
      public/fonts/fa-solid-900.svg
  98. BIN
      public/fonts/fa-solid-900.ttf
  99. BIN
      public/fonts/fa-solid-900.woff
  100. BIN
      public/fonts/fa-solid-900.woff2

+ 27 - 0
CHANGELOG.md

@@ -6,6 +6,11 @@
 - Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540))
 - Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86))
 - Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc))
+- Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c))
+- Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
+- Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a))
+- Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88))
+- Polls ([77092200](https://github.com/pixelfed/pixelfed/commit/77092200))
 
 ### Updated
 - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b))
@@ -73,6 +78,28 @@
 - Updated RemotePost.vue, improve text only post UI. ([b0257be2](https://github.com/pixelfed/pixelfed/commit/b0257be2))
 - Updated Timeline, make text-only posts opt-in by default. ([0153ed6d](https://github.com/pixelfed/pixelfed/commit/0153ed6d))
 - Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad))
+- Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604))
+- Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
+- Updated ApiV1Controller, add default license support. ([2a791f19](https://github.com/pixelfed/pixelfed/commit/2a791f19))
+- Updated StatusTransformers, remove includes and use cached services. ([09d5198c](https://github.com/pixelfed/pixelfed/commit/09d5198c))
+- Updated RemotePost component, update likes reaction bar. ([1060dd23](https://github.com/pixelfed/pixelfed/commit/1060dd23))
+- Updated FollowPipeline, fix cache invalidation bug. ([c1f14f89](https://github.com/pixelfed/pixelfed/commit/c1f14f89))
+- Updated PublicApiController, improve accountStatuses api perf. ([bce8edd9](https://github.com/pixelfed/pixelfed/commit/bce8edd9))
+- Updated ApiControllers, use NotificationService. ([f9516ac3](https://github.com/pixelfed/pixelfed/commit/f9516ac3))
+- Updated Notification components, fix old notifications with missing attributes. ([b6e226ae](https://github.com/pixelfed/pixelfed/commit/b6e226ae))
+- Updated LikeController, improve query perf. ([f3d6023e](https://github.com/pixelfed/pixelfed/commit/f3d6023e))
+- Updated License util, add nameToId method. ([f6131ed7](https://github.com/pixelfed/pixelfed/commit/f6131ed7))
+- Updated RemoteProfile, add warning about potentially out of date information. ([7274574c](https://github.com/pixelfed/pixelfed/commit/7274574c))
+- Updated NotifcationCard.vue component, add refresh button for cold notification cache. ([0e178a33](https://github.com/pixelfed/pixelfed/commit/0e178a33))
+- Updated RemoteProfile component, add follower modals. ([c4146a30](https://github.com/pixelfed/pixelfed/commit/c4146a30))
+- Updated FollowerService, cache audience. ([22257cc2](https://github.com/pixelfed/pixelfed/commit/22257cc2))
+- Updated StatusService, add non-public option and improve cache invalidation. ([15c4fdd9](https://github.com/pixelfed/pixelfed/commit/15c4fdd9))
+- Updated ContactAdmin mail, set New Support Message subject. ([bc3add05](https://github.com/pixelfed/pixelfed/commit/bc3add05))
+- Updated StatusTransformer, prioritize scope over deprecated visibility attribute. ([6e45021f](https://github.com/pixelfed/pixelfed/commit/6e45021f))
+- Updated StatusService, invalidate profile embed cache on deletion. ([acaf630d](https://github.com/pixelfed/pixelfed/commit/acaf630d))
+- Updated status.reply view, fix archived post leakage. ([4fb3d1fa](https://github.com/pixelfed/pixelfed/commit/4fb3d1fa))
+- Updated PostComponents, re-add time to timestamp. ([c5281dcd](https://github.com/pixelfed/pixelfed/commit/c5281dcd))
+- Updated follow intent, fix follower count leak. ([03199e2f](https://github.com/pixelfed/pixelfed/commit/03199e2f))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)

+ 1 - 1
app/Collection.php

@@ -4,7 +4,7 @@ namespace App;
 
 use Illuminate\Support\Str;
 use Illuminate\Database\Eloquent\Model;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
+use App\HasSnowflakePrimary;
 
 class Collection extends Model
 {

+ 1 - 1
app/CollectionItem.php

@@ -3,7 +3,7 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
+use App\HasSnowflakePrimary;
 
 class CollectionItem extends Model
 {

+ 1 - 1
app/Console/Commands/FailedJobGC.php

@@ -40,7 +40,7 @@ class FailedJobGC extends Command
     {
         FailedJob::chunk(50, function($jobs) {
             foreach($jobs as $job) {
-                if($job->failed_at->lt(now()->subMonth())) {
+                if($job->failed_at->lt(now()->subHours(48))) {
                     $job->delete();
                 }
             }

+ 27 - 72
app/Console/Commands/StoryGC.php

@@ -7,6 +7,9 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 use App\Story;
 use App\StoryView;
+use App\Jobs\StoryPipeline\StoryExpire;
+use App\Jobs\StoryPipeline\StoryRotateMedia;
+use App\Services\StoryService;
 
 class StoryGC extends Command
 {
@@ -41,89 +44,41 @@ class StoryGC extends Command
 	*/
 	public function handle()
 	{
-		$this->directoryScan();
-		$this->deleteViews();
-		$this->deleteStories();
+		$this->archiveExpiredStories();
+		$this->rotateMedia();
 	}
 
-	protected function directoryScan()
+	protected function archiveExpiredStories()
 	{
-		$day = now()->day;
-
-		if($day !== 3) {
-			return;
-		}
-
-		$monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
-
-		$t1 = Storage::directories('public/_esm.t1');
-		$t2 = Storage::directories('public/_esm.t2');
-
-		$dirs = array_merge($t1, $t2);
-
-		foreach($dirs as $dir) {
-			$hash = last(explode('/', $dir));
-			if($hash != $monthHash) {
-				$this->info('Found directory to delete: ' . $dir);
-				$this->deleteDirectory($dir);
-			}
-		}
-
-		$mh = hash('sha256', date('Y').'-.-'.date('m'));
-		$monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6);
-		$dirs = Storage::directories('public/_esm.t3');
-
-		foreach($dirs as $dir) {
-			$hash = last(explode('/', $dir));
-			if($hash != $monthHash) {
-				$this->info('Found directory to delete: ' . $dir);
-				$this->deleteDirectory($dir);
-			}
-		}
-	}
-
-	protected function deleteDirectory($path)
-	{
-		Storage::deleteDirectory($path);
-	}
-
-	protected function deleteViews()
-	{
-		StoryView::where('created_at', '<', now()->subMinutes(1441))->delete();
-	}
-
-	protected function deleteStories()
-	{
-		$stories = Story::where('created_at', '>', now()->subMinutes(30))
-		->whereNull('active')
+		$stories = Story::whereActive(true)
+		->where('expires_at', '<', now())
 		->get();
 
 		foreach($stories as $story) {
-			if(Storage::exists($story->path) == true) {
-				Storage::delete($story->path);
-			}
-			DB::transaction(function() use($story) {
-				StoryView::whereStoryId($story->id)->delete();
-				$story->delete();
-			});
+			StoryExpire::dispatch($story)->onQueue('story');
 		}
+	}
 
-		$stories = Story::where('created_at', '<', now()
-			->subMinutes(1441))
-		->get();
+	protected function rotateMedia()
+	{
+		$queue = StoryService::rotateQueue();
 
-		if($stories->count() == 0) {
-			exit;
+		if(!$queue || count($queue) == 0) {
+			return;
 		}
 
-		foreach($stories as $story) {
-			if(Storage::exists($story->path) == true) {
-				Storage::delete($story->path);
-			}
-			DB::transaction(function() use($story) {
-				StoryView::whereStoryId($story->id)->delete();
-				$story->delete();
+		collect($queue)
+			->each(function($id) {
+				$story = StoryService::getById($id);
+				if(!$story) {
+					StoryService::removeRotateQueue($id);
+					return;
+				}
+				if($story->created_at->gt(now()->subMinutes(20))) {
+					return;
+				}
+				StoryRotateMedia::dispatch($story)->onQueue('story');
+				StoryService::removeRotateQueue($id);
 			});
-		}
 	}
 }

+ 19 - 0
app/HasSnowflakePrimary.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use App\Services\SnowflakeService;
+
+trait HasSnowflakePrimary
+{
+	public static function bootHasSnowflakePrimary()
+	{
+		static::saving(function ($model) {
+			if (is_null($model->getKey())) {
+				$keyName = $model->getKeyName();
+				$id = SnowflakeService::next();
+				$model->setAttribute($keyName, $id);
+			}
+		});
+	}
+}

+ 9 - 0
app/Http/Controllers/AdminController.php

@@ -11,6 +11,7 @@ use App\{
 	Profile,
 	Report,
 	Status,
+	Story,
 	User
 };
 use DB, Cache;
@@ -27,6 +28,7 @@ use App\Http\Controllers\Admin\{
 };
 use Illuminate\Validation\Rule;
 use App\Services\AdminStatsService;
+use App\Services\StoryService;
 
 class AdminController extends Controller
 {
@@ -465,4 +467,11 @@ class AdminController extends Controller
 
 		return response()->json($res);
 	}
+
+	public function stories(Request $request)
+	{
+		$stories = Story::with('profile')->latest()->paginate(10);
+		$stats = StoryService::adminStats();
+		return view('admin.stories.home', compact('stories', 'stats'));
+	}
 }

+ 21 - 2
app/Http/Controllers/Api/ApiV1Controller.php

@@ -1048,7 +1048,7 @@ class ApiV1Controller extends Controller
 		  },
 		  'filter_name' => 'nullable|string|max:24',
 		  'filter_class' => 'nullable|alpha_dash|max:24',
-		  'description' => 'nullable|string|max:420'
+		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
 		]);
 
 		$user = $request->user();
@@ -1091,6 +1091,17 @@ class ApiV1Controller extends Controller
 		$storagePath = MediaPathService::get($user, 2);
 		$path = $photo->store($storagePath);
 		$hash = \hash_file('sha256', $photo);
+		$license = null;
+
+		$settings = UserSetting::whereUserId($user->id)->first();
+
+		if($settings && !empty($settings->compose_settings)) {
+			$compose = json_decode($settings->compose_settings, true);
+
+			if(isset($compose['default_license']) && $compose['default_license'] != 1) {
+				$license = $compose['default_license'];
+			}
+		}
 
 		abort_if(MediaBlocklistService::exists($hash) == true, 451);
 
@@ -1105,6 +1116,9 @@ class ApiV1Controller extends Controller
 		$media->caption = $request->input('description');
 		$media->filter_class = $filterClass;
 		$media->filter_name = $filterName;
+		if($license) {
+			$media->license = $license;
+		}
 		$media->save();
 
 		switch ($media->mime) {
@@ -1140,7 +1154,7 @@ class ApiV1Controller extends Controller
 		abort_if(!$request->user(), 403);
 
 		$this->validate($request, [
-		  'description' => 'nullable|string|max:420'
+		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
 		]);
 
 		$user = $request->user();
@@ -1302,6 +1316,11 @@ class ApiV1Controller extends Controller
 			}
 		}
 
+		if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+			Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+			NotificationService::warmCache($pid, 400, true);
+		}
+
 		$baseUrl = config('app.url') . '/api/v1/notifications?';
 
 		if($minId == $maxId) {

+ 108 - 21
app/Http/Controllers/Api/BaseApiController.php

@@ -15,7 +15,8 @@ use App\{
     Media,
     Notification,
     Profile,
-    Status
+    Status,
+    StatusArchived
 };
 use App\Transformer\Api\{
     AccountTransformer,
@@ -36,9 +37,11 @@ use App\Jobs\VideoPipeline\{
     VideoPostProcess,
     VideoThumbnail
 };
+use App\Services\AccountService;
 use App\Services\NotificationService;
 use App\Services\MediaPathService;
 use App\Services\MediaBlocklistService;
+use App\Services\StatusService;
 
 class BaseApiController extends Controller
 {
@@ -54,26 +57,40 @@ class BaseApiController extends Controller
     public function notifications(Request $request)
     {
         abort_if(!$request->user(), 403);
-        $pid = $request->user()->profile_id;
-        $pg = $request->input('pg');
-        if($pg == true) {
-            $timeago = Carbon::now()->subMonths(6);
-            $notifications = Notification::whereProfileId($pid)
-                ->whereDate('created_at', '>', $timeago)
-                ->latest()
-                ->simplePaginate(10);
-            $resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer());
-            $res = $this->fractal->createData($resource)->toArray();
-        } else {
-            $this->validate($request, [
-                'page' => 'nullable|integer|min:1|max:10',
-                'limit' => 'nullable|integer|min:1|max:40'
-            ]);
-            $limit = $request->input('limit') ?? 10;
-            $page = $request->input('page') ?? 1;
-            $end = (int) $page * $limit;
-            $start = (int) $end - $limit;
-            $res = NotificationService::get($pid, $start, $end);
+
+		$pid = $request->user()->profile_id;
+		$limit = $request->input('limit', 20);
+
+		$since = $request->input('since_id');
+		$min = $request->input('min_id');
+		$max = $request->input('max_id');
+
+		if(!$since && !$min && !$max) {
+			$min = 1;
+		}
+
+		$maxId = null;
+		$minId = null;
+
+		if($max) {
+			$res = NotificationService::getMax($pid, $max, $limit);
+			$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
+			if(!empty($ids)) {
+				$maxId = max($ids);
+				$minId = min($ids);
+			}
+		} else {
+			$res = NotificationService::getMin($pid, $min ?? $since, $limit);
+			$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+			if(!empty($ids)) {
+				$maxId = max($ids);
+				$minId = min($ids);
+			}
+		}
+
+        if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+        	Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+        	NotificationService::warmCache($pid, 400, true);
         }
 
         return response()->json($res);
@@ -272,4 +289,74 @@ class BaseApiController extends Controller
 
         return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
     }
+
+    public function archive(Request $request, $id)
+    {
+        abort_if(!$request->user(), 403);
+
+        $status = Status::whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->whereProfileId($request->user()->profile_id)
+            ->findOrFail($id);
+
+        if($status->scope === 'archived') {
+            return [200];
+        }
+
+        $archive = new StatusArchived;
+        $archive->status_id = $status->id;
+        $archive->profile_id = $status->profile_id;
+        $archive->original_scope = $status->scope;
+        $archive->save();
+
+        $status->scope = 'archived';
+        $status->visibility = 'draft';
+        $status->save();
+        StatusService::del($status->id);
+        AccountService::syncPostCount($status->profile_id);
+
+        return [200];
+    }
+
+    public function unarchive(Request $request, $id)
+    {
+        abort_if(!$request->user(), 403);
+
+        $status = Status::whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->whereProfileId($request->user()->profile_id)
+            ->findOrFail($id);
+
+        if($status->scope !== 'archived') {
+            return [200];
+        }
+
+        $archive = StatusArchived::whereStatusId($status->id)
+            ->whereProfileId($status->profile_id)
+            ->firstOrFail();
+
+        $status->scope = $archive->original_scope;
+        $status->visibility = $archive->original_scope;
+        $status->save();
+        $archive->delete();
+        StatusService::del($status->id);
+        AccountService::syncPostCount($status->profile_id);
+
+        return [200];
+    }
+
+    public function archivedPosts(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $statuses = Status::whereProfileId($request->user()->profile_id)
+            ->whereScope('archived')
+            ->orderByDesc('id')
+            ->simplePaginate(10);
+
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
+        return $fractal->createData($resource)->toArray();
+    }
 }

+ 74 - 3
app/Http/Controllers/ComposeController.php

@@ -15,8 +15,10 @@ use App\{
 	Profile,
 	Place,
 	Status,
-	UserFilter
+	UserFilter,
+	UserSetting
 };
+use App\Models\Poll;
 use App\Transformer\Api\{
 	MediaTransformer,
 	MediaDraftTransformer,
@@ -41,7 +43,7 @@ use App\Services\MediaPathService;
 use App\Services\MediaBlocklistService;
 use App\Services\MediaStorageService;
 use App\Services\MediaTagService;
-use App\Services\ServiceService;
+use App\Services\StatusService;
 use Illuminate\Support\Str;
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\Extractor;
@@ -403,7 +405,7 @@ class ComposeController extends Controller
 			'media.*.id' => 'required|integer|min:1',
 			'media.*.filter_class' => 'nullable|alpha_dash|max:30',
 			'media.*.license' => 'nullable|string|max:140',
-			'media.*.alt' => 'nullable|string|max:140',
+			'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
 			'cw' => 'nullable|boolean',
 			'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
 			'place' => 'nullable',
@@ -661,4 +663,73 @@ class ComposeController extends Controller
 			'finished' => $finished
 		];
 	}
+
+	public function composeSettings(Request $request)
+	{
+		$uid = $request->user()->id;
+		$default = [
+			'default_license' => 1,
+			'media_descriptions' => false,
+			'max_altext_length' => config_cache('pixelfed.max_altext_length')
+		];
+
+		return array_merge($default, Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) {
+			$res = UserSetting::whereUserId($uid)->first();
+
+			if(!$res || empty($res->compose_settings)) {
+				return [];
+			}
+
+			return json_decode($res->compose_settings, true);
+		}));
+	}
+
+	public function createPoll(Request $request)
+	{
+		$this->validate($request, [
+			'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
+			'cw' => 'nullable|boolean',
+			'visibility' => 'required|string|in:public,private',
+			'comments_disabled' => 'nullable',
+			'expiry' => 'required|in:60,360,1440,10080',
+			'pollOptions' => 'required|array|min:1|max:4'
+		]);
+
+		abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled');
+
+		abort_if(Status::whereType('poll')
+			->whereProfileId($request->user()->profile_id)
+			->whereCaption($request->input('caption'))
+			->where('created_at', '>', now()->subDays(2))
+			->exists()
+		, 422, 'Duplicate detected.');
+
+		$status = new Status;
+		$status->profile_id = $request->user()->profile_id;
+		$status->caption = $request->input('caption');
+		$status->rendered = Autolink::create()->autolink($status->caption);
+		$status->visibility = 'draft';
+		$status->scope = 'draft';
+		$status->type = 'poll';
+		$status->local = true;
+		$status->save();
+
+		$poll = new Poll;
+		$poll->status_id = $status->id;
+		$poll->profile_id = $status->profile_id;
+		$poll->poll_options = $request->input('pollOptions');
+		$poll->expires_at = now()->addMinutes($request->input('expiry'));
+		$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+			return 0;
+		})->toArray();
+		$poll->save();
+
+		$status->visibility = $request->input('visibility');
+		$status->scope = $request->input('visibility');
+		$status->save();
+
+		NewStatusPipeline::dispatch($status);
+
+		return ['url' => $status->url()];
+	}
 }

+ 1 - 2
app/Http/Controllers/LikeController.php

@@ -29,8 +29,7 @@ class LikeController extends Controller
 		$profile = $user->profile;
 		$status = Status::findOrFail($request->input('item'));
 
-
-		if ($status->likes()->whereProfileId($profile->id)->count() !== 0) {
+		if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) {
 			$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
 			UnlikePipeline::dispatch($like);
 		} else {

+ 73 - 0
app/Http/Controllers/PollController.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Status;
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Services\PollService;
+use App\Services\FollowerService;
+
+class PollController extends Controller
+{
+
+	public function __construct()
+	{
+		abort_if(!config_cache('instance.polls.enabled'), 404);
+	}
+
+	public function getPoll(Request $request, $id)
+	{
+		$poll = Poll::findOrFail($id);
+		$status = Status::findOrFail($poll->status_id);
+		if($status->scope != 'public') {
+			abort_if(!$request->user(), 403);
+			if($request->user()->profile_id != $status->profile_id) {
+				abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404);
+			}
+		}
+		$pid = $request->user() ? $request->user()->profile_id : false;
+		$poll = PollService::getById($id, $pid);
+		return $poll;
+	}
+
+    public function vote(Request $request, $id)
+    {
+    	abort_unless($request->user(), 403);
+
+    	$this->validate($request, [
+    		'choices' => 'required|array'
+    	]);
+
+    	$pid = $request->user()->profile_id;
+    	$poll_id = $id;
+    	$choices = $request->input('choices');
+
+    	// todo: implement multiple choice
+    	$choice = $choices[0];
+
+    	$poll = Poll::findOrFail($poll_id);
+
+    	abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.');
+
+    	abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.');
+
+    	$vote = new PollVote;
+    	$vote->status_id = $poll->status_id;
+    	$vote->profile_id = $pid;
+    	$vote->poll_id = $poll->id;
+    	$vote->choice = $choice;
+    	$vote->save();
+
+    	$poll->votes_count = $poll->votes_count + 1;
+    	$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) {
+    		return $choice == $key ? $tally + 1 : $tally;
+    	})->toArray();
+    	$poll->save();
+
+    	PollService::del($poll->status_id);
+    	$res = PollService::get($poll->status_id, $pid);
+    	return $res;
+    }
+}

+ 6 - 5
app/Http/Controllers/ProfileController.php

@@ -13,6 +13,7 @@ use App\Story;
 use App\User;
 use App\UserFilter;
 use League\Fractal;
+use App\Services\FollowerService;
 use App\Util\Lexer\Nickname;
 use App\Util\Webfinger\Webfinger;
 use App\Transformer\ActivityPub\ProfileOutbox;
@@ -238,12 +239,12 @@ class ProfileController extends Controller
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 		$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
 		$pid = $profile->id;
-		$authed = Auth::user()->profile;
-		abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404);
+		$authed = Auth::user()->profile_id;
+		abort_if($pid != $authed && !FollowerService::follows($authed, $pid), 404);
 		$exists = Story::whereProfileId($pid)
-			->where('expires_at', '>', now())
-			->count();
-		abort_unless($exists > 0, 404);
+			->whereActive(true)
+			->exists();
+		abort_unless($exists, 404);
 		return view('profile.story', compact('pid', 'profile'));
 	}
 }

+ 79 - 103
app/Http/Controllers/PublicApiController.php

@@ -29,6 +29,7 @@ use App\Services\{
     AccountService,
     LikeService,
     PublicTimelineService,
+    ProfileService,
     StatusService,
     SnowflakeService,
     UserFilterService
@@ -92,20 +93,15 @@ 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(),
-                ];
-                return $res;
-            });
-            return response()->json($res);
+        if(!$request->user()) {
+        	$res = ['status' => StatusService::get($status->id)];
+        } else {
+        	$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
+	        $res = [
+	        	'status' => $this->fractal->createData($item)->toArray(),
+	        ];
         }
-        $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
-        $res = [
-        	'status' => $this->fractal->createData($item)->toArray(),
-        ];
+
         return response()->json($res);
     }
 
@@ -402,11 +398,22 @@ class PublicApiController extends Controller
         }
 
         $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
-        $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
-        $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
-        $types = $textOnlyPosts ?
-        	['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] :
-        	['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
+        $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
+
+        $textOnlyReplies = false;
+
+        if(config('exp.top')) {
+	        $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
+	        $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
+
+	        if($textOnlyPosts) {
+	        	array_push($types, 'text');
+	        }
+        }
+
+        if(config('exp.polls') == true) {
+        	array_push($types, 'poll');
+        }
 
         if($min || $max) {
             $dir = $min ? '>' : '<';
@@ -432,7 +439,7 @@ class PublicApiController extends Controller
                         'updated_at'
                       )
             		  ->whereIn('type', $types)
-                      ->when(!$textOnlyReplies, function($q, $textOnlyReplies) {
+                      ->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
                       	return $q->whereNull('in_reply_to_id');
                   	  })
                       ->with('profile', 'hashtags', 'mentions')
@@ -591,17 +598,27 @@ class PublicApiController extends Controller
     public function accountFollowers(Request $request, $id)
     {
         abort_unless(Auth::check(), 403);
-        $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id);
+        $profile = Profile::with('user')->whereNull('status')->findOrFail($id);
         $owner = Auth::id() == $profile->user_id;
-        if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) {
+
+        if(Auth::id() != $profile->user_id && $profile->is_private) {
             return response()->json([]);
         }
+        if(!$profile->domain && !$profile->user->settings->show_profile_followers) {
+        	return response()->json([]);
+        }
         if(!$owner && $request->page > 5) {
         	return [];
         }
-        $followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10);
-        $resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
-        $res = $this->fractal->createData($resource)->toArray();
+
+        $res = Follower::select('id', 'profile_id', 'following_id')
+            ->whereFollowingId($profile->id)
+            ->orderByDesc('id')
+            ->simplePaginate(10)
+            ->map(function($follower) {
+                return ProfileService::get($follower['profile_id']);
+            })
+            ->toArray();
 
         return response()->json($res);
     }
@@ -612,7 +629,6 @@ class PublicApiController extends Controller
 
         $profile = Profile::with('user')
             ->whereNull('status')
-            ->whereNull('domain')
             ->findOrFail($id);
 
         // filter by username
@@ -621,7 +637,10 @@ class PublicApiController extends Controller
         $filter = ($owner == true) && ($search != null);
 
         abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404);
-        abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
+
+        if(!$profile->domain) {
+        	abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
+        }
 
         if(!$owner && $request->page > 5) {
         	return [];
@@ -656,28 +675,27 @@ class PublicApiController extends Controller
             'limit' => 'nullable|integer|min:1|max:24'
         ]);
 
+        $user = $request->user();
         $profile = Profile::whereNull('status')->findOrFail($id);
 
         $limit = $request->limit ?? 9;
         $max_id = $request->max_id;
         $min_id = $request->min_id;
-        $scope = $request->only_media == true ?
-            ['photo', 'photo:album', 'video', 'video:album'] :
-            ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
+        $scope = ['photo', 'photo:album', 'video', 'video:album'];
 
         if($profile->is_private) {
-            if(!Auth::check()) {
+            if(!$user) {
                 return response()->json([]);
             }
-            $pid = Auth::user()->profile->id;
+            $pid = $user->profile_id;
             $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
                 $following = Follower::whereProfileId($pid)->pluck('following_id');
                 return $following->push($pid)->toArray();
             });
             $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
         } else {
-            if(Auth::check()) {
-                $pid = Auth::user()->profile->id;
+            if($user) {
+                $pid = $user->profile_id;
                 $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
                     $following = Follower::whereProfileId($pid)->pluck('following_id');
                     return $following->push($pid)->toArray();
@@ -688,84 +706,42 @@ 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(
+        $res = 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();
+            'created_at'
+          )
+        ->whereProfileId($profile->id)
+        ->whereNull('in_reply_to_id')
+        ->whereNull('reblog_of_id')
+        ->whereIn('type', $scope)
+        ->where('id', $dir, $id)
+        ->whereIn('scope', $visibility)
+        ->limit($limit)
+        ->orderByDesc('id')
+        ->get()
+        ->map(function($s) use($user) {
+        	try {
+            	$status = StatusService::get($s->id, false);
+        	} catch (\Exception $e) {
+        		$status = false;
+        	}
+            if($user && $status) {
+            	$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+            }
+            return $status;
+        })
+        ->filter(function($s) {
+        	return $s;
+        })
+        ->values();
 
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+        return response()->json($res);
     }
 
 }

+ 66 - 2
app/Http/Controllers/SettingsController.php

@@ -7,6 +7,7 @@ use App\Following;
 use App\ProfileSponsor;
 use App\Report;
 use App\UserFilter;
+use App\UserSetting;
 use Auth, Cookie, DB, Cache, Purify;
 use Illuminate\Support\Facades\Redis;
 use Carbon\Carbon;
@@ -21,6 +22,7 @@ use App\Http\Controllers\Settings\{
 	SecuritySettings
 };
 use App\Jobs\DeletePipeline\DeleteAccountPipeline;
+use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
 
 class SettingsController extends Controller
 {
@@ -221,7 +223,7 @@ class SettingsController extends Controller
 		$sponsors->sponsors = json_encode($res);
 		$sponsors->save();
 		$sponsors = $res;
-		return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');;
+		return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');
 	}
 
 	public function timelineSettings(Request $request)
@@ -249,7 +251,69 @@ class SettingsController extends Controller
 		} else {
 			Redis::zrem('pf:tl:replies', $pid);
 		}
-		return redirect(route('settings.timeline'));
+		return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!');;
+	}
+
+	public function mediaSettings(Request $request)
+	{
+		$setting = UserSetting::whereUserId($request->user()->id)->firstOrFail();
+		$compose = $setting->compose_settings ? json_decode($setting->compose_settings, true) : [
+			'default_license' => null,
+			'media_descriptions' => false
+		];
+		return view('settings.media', compact('compose'));
+	}
+
+	public function updateMediaSettings(Request $request)
+	{
+		$this->validate($request, [
+			'default' => 'required|int|min:1|max:16',
+			'sync' => 'nullable',
+			'media_descriptions' => 'nullable'
+		]);
+
+		$license = $request->input('default');
+		$sync = $request->input('sync') == 'on';
+		$media_descriptions = $request->input('media_descriptions') == 'on';
+		$uid = $request->user()->id;
+
+		$setting = UserSetting::whereUserId($uid)->firstOrFail();
+		$compose = json_decode($setting->compose_settings, true);
+		$changed = false;
+
+		if($sync) {
+			$key = 'pf:settings:mls_recently:'.$uid;
+			if(Cache::get($key) == 2) {
+				$msg = 'You can only sync licenses twice per 24 hours. Try again later.';
+				return redirect(route('settings'))
+					->with('error', $msg);
+			}
+		}
+
+		if(!isset($compose['default_license']) || $compose['default_license'] !== $license) {
+			$compose['default_license'] = (int) $license;
+			$changed = true;
+		}
+
+		if(!isset($compose['media_descriptions']) || $compose['media_descriptions'] !== $media_descriptions) {
+			$compose['media_descriptions'] = $media_descriptions;
+			$changed = true;
+		}
+
+		if($changed) {
+			$setting->compose_settings = json_encode($compose);
+			$setting->save();
+			Cache::forget('profile:compose:settings:' . $request->user()->id);
+		}
+
+		if($sync) {
+			$val = Cache::has($key) ? 2 : 1;
+			Cache::put($key, $val, 86400);
+			MediaSyncLicensePipeline::dispatch($uid, $license);
+			return redirect(route('settings'))->with('status', 'Media licenses successfully synced! It may take a few minutes to take effect for every post.');
+		}
+
+		return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
 	}
 
 }

+ 501 - 0
app/Http/Controllers/StoryComposeController.php

@@ -0,0 +1,501 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Media;
+use App\Profile;
+use App\Report;
+use App\DirectMessage;
+use App\Notification;
+use App\Status;
+use App\Story;
+use App\StoryView;
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Services\ProfileService;
+use App\Services\StoryService;
+use Cache, Storage;
+use Image as Intervention;
+use App\Services\FollowerService;
+use App\Services\MediaPathService;
+use FFMpeg;
+use FFMpeg\Coordinate\Dimension;
+use FFMpeg\Format\Video\X264;
+use App\Jobs\StoryPipeline\StoryReactionDeliver;
+use App\Jobs\StoryPipeline\StoryReplyDeliver;
+use App\Jobs\StoryPipeline\StoryFanout;
+use App\Jobs\StoryPipeline\StoryDelete;
+use ImageOptimizer;
+
+class StoryComposeController extends Controller
+{
+    public function apiV1Add(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'file' => function() {
+				return [
+					'required',
+					'mimes:image/jpeg,image/png,video/mp4',
+					'max:' . config_cache('pixelfed.max_photo_size'),
+				];
+			},
+		]);
+
+		$user = $request->user();
+
+		$count = Story::whereProfileId($user->profile_id)
+			->whereActive(true)
+			->where('expires_at', '>', now())
+			->count();
+
+		if($count >= Story::MAX_PER_DAY) {
+			abort(418, 'You have reached your limit for new Stories today.');
+		}
+
+		$photo = $request->file('file');
+		$path = $this->storePhoto($photo, $user);
+
+		$story = new Story();
+		$story->duration = 3;
+		$story->profile_id = $user->profile_id;
+		$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
+		$story->mime = $photo->getMimeType();
+		$story->path = $path;
+		$story->local = true;
+		$story->size = $photo->getSize();
+		$story->bearcap_token = str_random(64);
+		$story->save();
+
+		$url = $story->path;
+
+		$res = [
+			'code' => 200,
+			'msg'  => 'Successfully added',
+			'media_id' => (string) $story->id,
+			'media_url' => url(Storage::url($url)) . '?v=' . time(),
+			'media_type' => $story->type
+		];
+
+		if($story->type === 'video') {
+			$video = FFMpeg::open($path);
+			$duration = $video->getDurationInSeconds();
+			$res['media_duration'] = $duration;
+			if($duration > 500) {
+				Storage::delete($story->path);
+				$story->delete();
+				return response()->json([
+					'message' => 'Video duration cannot exceed 60 seconds'
+				], 422);
+			}
+		}
+
+		return $res;
+	}
+
+	protected function storePhoto($photo, $user)
+	{
+		$mimes = explode(',', config_cache('pixelfed.media_types'));
+		if(in_array($photo->getMimeType(), [
+			'image/jpeg',
+			'image/png',
+			'video/mp4'
+		]) == false) {
+			abort(400, 'Invalid media type');
+			return;
+		}
+
+		$storagePath = MediaPathService::story($user->profile);
+		$path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
+		if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
+			$fpath = storage_path('app/' . $path);
+			$img = Intervention::make($fpath);
+			$img->orientate();
+			$img->save($fpath, config_cache('pixelfed.image_quality'));
+			$img->destroy();
+		}
+		return $path;
+	}
+
+	public function cropPhoto(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'media_id' => 'required|integer|min:1',
+			'width' => 'required',
+			'height' => 'required',
+			'x' => 'required',
+			'y' => 'required'
+		]);
+
+		$user = $request->user();
+		$id = $request->input('media_id');
+		$width = round($request->input('width'));
+		$height = round($request->input('height'));
+		$x = round($request->input('x'));
+		$y = round($request->input('y'));
+
+		$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
+
+		$path = storage_path('app/' . $story->path);
+
+		if(!is_file($path)) {
+			abort(400, 'Invalid or missing media.');
+		}
+
+		if($story->type === 'photo') {
+			$img = Intervention::make($path);
+			$img->crop($width, $height, $x, $y);
+			$img->resize(1080, 1920, function ($constraint) {
+				$constraint->aspectRatio();
+			});
+			$img->save($path, config_cache('pixelfed.image_quality'));
+		}
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully cropped',
+		];
+	}
+
+	public function publishStory(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'media_id' => 'required',
+			'duration' => 'required|integer|min:3|max:120',
+			'can_reply' => 'required|boolean',
+			'can_react' => 'required|boolean'
+		]);
+
+		$id = $request->input('media_id');
+		$user = $request->user();
+		$story = Story::whereProfileId($user->profile_id)
+			->findOrFail($id);
+
+		$story->active = true;
+		$story->expires_at = now()->addMinutes(1440);
+		$story->duration = $request->input('duration', 10);
+		$story->can_reply = $request->input('can_reply');
+		$story->can_react = $request->input('can_react');
+		$story->save();
+
+		StoryService::delLatest($story->profile_id);
+		StoryFanout::dispatch($story)->onQueue('story');
+		StoryService::addRotateQueue($story->id);
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully published',
+		];
+	}
+
+	public function apiV1Delete(Request $request, $id)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$user = $request->user();
+
+		$story = Story::whereProfileId($user->profile_id)
+			->findOrFail($id);
+		$story->active = false;
+		$story->save();
+
+		StoryDelete::dispatch($story)->onQueue('story');
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully deleted'
+		];
+	}
+
+	public function compose(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		return view('stories.compose');
+	}
+
+	public function createPoll(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		abort_if(!config_cache('instance.polls.enabled'), 404);
+
+		return $request->all();
+	}
+
+	public function publishStoryPoll(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'question' => 'required|string|min:6|max:140',
+			'options' => 'required|array|min:2|max:4',
+			'can_reply' => 'required|boolean',
+			'can_react' => 'required|boolean'
+		]);
+
+		$pid = $request->user()->profile_id;
+
+		$count = Story::whereProfileId($pid)
+			->whereActive(true)
+			->where('expires_at', '>', now())
+			->count();
+
+		if($count >= Story::MAX_PER_DAY) {
+			abort(418, 'You have reached your limit for new Stories today.');
+		}
+
+		$story = new Story;
+		$story->type = 'poll';
+		$story->story = json_encode([
+			'question' => $request->input('question'),
+			'options' => $request->input('options')
+		]);
+		$story->public = false;
+		$story->local = true;
+		$story->profile_id = $pid;
+		$story->expires_at = now()->addMinutes(1440);
+		$story->duration = 30;
+		$story->can_reply = false;
+		$story->can_react = false;
+		$story->save();
+
+		$poll = new Poll;
+		$poll->story_id = $story->id;
+		$poll->profile_id = $pid;
+		$poll->poll_options = $request->input('options');
+		$poll->expires_at = $story->expires_at;
+		$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+			return 0;
+		})->toArray();
+		$poll->save();
+
+		$story->active = true;
+		$story->save();
+
+		StoryService::delLatest($story->profile_id);
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully published',
+		];
+	}
+
+	public function storyPollVote(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'sid' => 'required',
+			'ci' => 'required|integer|min:0|max:3'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$ci = $request->input('ci');
+		$story = Story::findOrFail($request->input('sid'));
+		abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
+		$poll = Poll::whereStoryId($story->id)->firstOrFail();
+
+		$vote = new PollVote;
+		$vote->profile_id = $pid;
+		$vote->poll_id = $poll->id;
+		$vote->story_id = $story->id;
+		$vote->status_id = null;
+		$vote->choice = $ci;
+		$vote->save();
+
+		$poll->votes_count = $poll->votes_count + 1;
+    	$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
+    		return $ci == $key ? $tally + 1 : $tally;
+    	})->toArray();
+    	$poll->save();
+
+		return 200;
+	}
+
+	public function storeReport(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+            'type'  => 'required|alpha_dash',
+            'id'    => 'required|integer|min:1',
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $sid = $request->input('id');
+        $type = $request->input('type');
+
+        $types = [
+            // original 3
+            'spam',
+            'sensitive',
+            'abusive',
+
+            // new
+            'underage',
+            'copyright',
+            'impersonation',
+            'scam',
+            'terrorism'
+        ];
+
+        abort_if(!in_array($type, $types), 422, 'Invalid story report type');
+
+        $story = Story::findOrFail($sid);
+
+        abort_if($story->profile_id == $pid, 422, 'Cannot report your own story');
+        abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
+
+        if( Report::whereProfileId($pid)
+        	->whereObjectType('App\Story')
+        	->whereObjectId($story->id)
+        	->exists()
+        ) {
+        	return response()->json(['error' => [
+        		'code' => 409,
+        		'message' => 'Cannot report the same story again'
+        	]], 409);
+        }
+
+		$report = new Report;
+        $report->profile_id = $pid;
+        $report->user_id = $request->user()->id;
+        $report->object_id = $story->id;
+        $report->object_type = 'App\Story';
+        $report->reported_profile_id = $story->profile_id;
+        $report->type = $type;
+        $report->message = null;
+        $report->save();
+
+        return [200];
+	}
+
+	public function react(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		$this->validate($request, [
+			'sid' => 'required',
+			'reaction' => 'required|string'
+		]);
+		$pid = $request->user()->profile_id;
+		$text = $request->input('reaction');
+
+		$story = Story::findOrFail($request->input('sid'));
+
+		abort_if(!$story->can_react, 422);
+		abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
+
+		$status = new Status;
+		$status->profile_id = $pid;
+		$status->type = 'story:reaction';
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id,
+			'reaction' => $text
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $pid;
+		$dm->type = 'story:react';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $story->profile->username,
+			'story_actor_username' => $request->user()->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'reaction' => $text
+		]);
+		$dm->save();
+
+		if($story->local) {
+			// generate notification
+			$n = new Notification;
+			$n->profile_id = $dm->to_id;
+			$n->actor_id = $dm->from_id;
+			$n->item_id = $dm->id;
+			$n->item_type = 'App\DirectMessage';
+			$n->action = 'story:react';
+			$n->message = "{$request->user()->username} reacted to your story";
+			$n->rendered = "{$request->user()->username} reacted to your story";
+			$n->save();
+		} else {
+			StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
+		}
+
+		StoryService::reactIncrement($story->id, $pid);
+
+		return 200;
+	}
+
+	public function comment(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		$this->validate($request, [
+			'sid' => 'required',
+			'caption' => 'required|string'
+		]);
+		$pid = $request->user()->profile_id;
+		$text = $request->input('caption');
+
+		$story = Story::findOrFail($request->input('sid'));
+
+		abort_if(!$story->can_reply, 422);
+
+		$status = new Status;
+		$status->type = 'story:reply';
+		$status->profile_id = $pid;
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $pid;
+		$dm->type = 'story:comment';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $story->profile->username,
+			'story_actor_username' => $request->user()->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'caption' => $text
+		]);
+		$dm->save();
+
+		if($story->local) {
+			// generate notification
+			$n = new Notification;
+			$n->profile_id = $dm->to_id;
+			$n->actor_id = $dm->from_id;
+			$n->item_id = $dm->id;
+			$n->item_type = 'App\DirectMessage';
+			$n->action = 'story:comment';
+			$n->message = "{$request->user()->username} commented on story";
+			$n->rendered = "{$request->user()->username} commented on story";
+			$n->save();
+		} else {
+			StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
+		}
+
+		return 200;
+	}
+}

+ 162 - 351
app/Http/Controllers/StoryController.php

@@ -4,337 +4,106 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use App\DirectMessage;
+use App\Follower;
+use App\Notification;
 use App\Media;
 use App\Profile;
+use App\Status;
 use App\Story;
 use App\StoryView;
+use App\Services\PollService;
+use App\Services\ProfileService;
 use App\Services\StoryService;
 use Cache, Storage;
 use Image as Intervention;
+use App\Services\AccountService;
 use App\Services\FollowerService;
 use App\Services\MediaPathService;
 use FFMpeg;
 use FFMpeg\Coordinate\Dimension;
 use FFMpeg\Format\Video\X264;
+use League\Fractal\Manager;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Resource\Item;
+use App\Transformer\ActivityPub\Verb\StoryVerb;
+use App\Jobs\StoryPipeline\StoryViewDeliver;
 
-class StoryController extends Controller
+class StoryController extends StoryComposeController
 {
-	public function apiV1Add(Request $request)
+	public function recent(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'file' => function() {
-				return [
-					'required',
-					'mimes:image/jpeg,image/png,video/mp4',
-					'max:' . config_cache('pixelfed.max_photo_size'),
-				];
-			},
-		]);
-
-		$user = $request->user();
-
-		if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) {
-			abort(400, 'You have reached your limit for new Stories today.');
-		}
-
-		$photo = $request->file('file');
-		$path = $this->storePhoto($photo, $user);
-
-		$story = new Story();
-		$story->duration = 3;
-		$story->profile_id = $user->profile_id;
-		$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
-		$story->mime = $photo->getMimeType();
-		$story->path = $path;
-		$story->local = true;
-		$story->size = $photo->getSize();
-		$story->save();
-
-		$url = $story->path;
-
-		if($story->type === 'video') {
-			$video = FFMpeg::open($path);
-			$width = $video->getVideoStream()->get('width');
-			$height = $video->getVideoStream()->get('height');
-
-
-			if($width !== 1080 || $height !== 1920) {
-				Storage::delete($story->path);
-				$story->delete();
-				abort(422, 'Invalid video dimensions, must be 1080x1920');
-			}
-		}
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully added',
-			'media_id' => (string) $story->id,
-			'media_url' => url(Storage::url($url)) . '?v=' . time(),
-			'media_type' => $story->type
-		];
-	}
-
-	protected function storePhoto($photo, $user)
-	{
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), [
-			'image/jpeg',
-			'image/png',
-			'video/mp4'
-		]) == false) {
-			abort(400, 'Invalid media type');
-			return;
-		}
-
-		$storagePath = MediaPathService::story($user->profile);
-		$path = $photo->store($storagePath);
-		if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
-			$fpath = storage_path('app/' . $path);
-			$img = Intervention::make($fpath);
-			$img->orientate();
-			$img->save($fpath, config_cache('pixelfed.image_quality'));
-			$img->destroy();
-		}
-		return $path;
-	}
-
-	public function cropPhoto(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required|integer|min:1',
-			'width' => 'required',
-			'height' => 'required',
-			'x' => 'required',
-			'y' => 'required'
-		]);
-
-		$user = $request->user();
-		$id = $request->input('media_id');
-		$width = round($request->input('width'));
-		$height = round($request->input('height'));
-		$x = round($request->input('x'));
-		$y = round($request->input('y'));
-
-		$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
-
-		$path = storage_path('app/' . $story->path);
-
-		if(!is_file($path)) {
-			abort(400, 'Invalid or missing media.');
-		}
-
-		if($story->type === 'photo') {
-			$img = Intervention::make($path);
-			$img->crop($width, $height, $x, $y);
-			$img->resize(1080, 1920, function ($constraint) {
-				$constraint->aspectRatio();
-			});
-			$img->save($path, config_cache('pixelfed.image_quality'));
-		}
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully cropped',
-		];
-	}
-
-	public function publishStory(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required',
-			'duration' => 'required|integer|min:3|max:10'
-		]);
-
-		$id = $request->input('media_id');
-		$user = $request->user();
-		$story = Story::whereProfileId($user->profile_id)
-			->findOrFail($id);
-
-		$story->active = true;
-		$story->duration = $request->input('duration', 10);
-		$story->expires_at = now()->addMinutes(1450);
-		$story->save();
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully published',
-		];
-	}
-
-	public function apiV1Delete(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$user = $request->user();
-
-		$story = Story::whereProfileId($user->profile_id)
-			->findOrFail($id);
-
-		if(Storage::exists($story->path) == true) {
-			Storage::delete($story->path);
-		}
-
-		$story->delete();
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully deleted'
-		];
-	}
-
-	public function apiV1Recent(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$profile = $request->user()->profile;
-		$following = $profile->following->pluck('id')->toArray();
-
-		if(config('database.default') == 'pgsql') {
-			$db = Story::with('profile')
-			->whereActive(true)
-			->whereIn('profile_id', $following)
-			->where('expires_at', '>', now())
-			->distinct('profile_id')
-			->take(9)
-			->get();
-		} else {
-			$db = Story::with('profile')
-			->whereActive(true)
-			->whereIn('profile_id', $following)
-			->where('created_at', '>', now()->subDay())
-			->orderByDesc('expires_at')
-			->groupBy('profile_id')
-			->take(9)
+		$pid = $request->user()->profile_id;
+
+		$s = Story::select('stories.*', 'followers.following_id')
+			->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
+			->where('followers.profile_id', $pid)
+			->where('stories.active', true)
+			->groupBy('followers.following_id')
+			->orderByDesc('id')
 			->get();
-		}
 
-		$stories = $db->map(function($s, $k) {
+		$res = $s->map(function($s) use($pid) {
+			$profile = AccountService::get($s->profile_id);
+			$url = $profile['local'] ? url("/stories/{$profile['username']}") :
+				url("/i/rs/{$profile['id']}");
 			return [
-				'id' => (string) $s->id,
-				'photo' => $s->profile->avatarUrl(),
-				'name'	=> $s->profile->username,
-				'link'	=> $s->profile->url(),
-				'lastUpdated' => (int) $s->created_at->format('U'),
-				'seen' => $s->seen(),
-				'items' => [],
-				'pid' => (string) $s->profile->id
+				'pid' => $profile['id'],
+				'avatar' => $profile['avatar'],
+				'local' => $profile['local'],
+				'username'	=> $profile['acct'],
+				'url' => $url,
+				'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
+				'sid' => $s->id
 			];
-		});
-
-		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function apiV1Fetch(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$authed = $request->user()->profile;
-		$profile = Profile::findOrFail($id);
-		if($id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
-		}
-
-		$stories = Story::whereProfileId($profile->id)
-		->whereActive(true)
-		->orderBy('expires_at', 'desc')
-		->where('expires_at', '>', now())
-		->when(!$publicOnly, function($query, $publicOnly) {
-			return $query->wherePublic(true);
 		})
-		->get()
-		->map(function($s, $k) {
-			return [
-				'id' => (string) $s->id,
-				'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
-				'length' => 3,
-				'src' => url(Storage::url($s->path)),
-				'preview' => null,
-				'link' => null,
-				'linkText' => null,
-				'time' => $s->created_at->format('U'),
-				'expires_at' => (int)  $s->expires_at->format('U'),
-				'created_ago' => $s->created_at->diffForHumans(null, true, true),
-				'seen' => $s->seen()
-			];
-		})->toArray();
-		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function apiV1Item(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$authed = $request->user()->profile;
-		$story = Story::with('profile')
-			->whereActive(true)
-			->where('expires_at', '>', now())
-			->findOrFail($id);
-
-		$profile = $story->profile;
-		if($story->profile_id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
-		}
-
-		abort_if(!$publicOnly, 403);
-
-		$res = [
-			'id' => (string) $story->id,
-			'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo',
-			'length' => 10,
-			'src' => url(Storage::url($story->path)),
-			'preview' => null,
-			'link' => null,
-			'linkText' => null,
-			'time' => $story->created_at->format('U'),
-			'expires_at' => (int)  $story->expires_at->format('U'),
-			'seen' => $story->seen()
-		];
+		->sortBy('seen')
+		->values();
 		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function apiV1Profile(Request $request, $id)
+	public function profile(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$authed = $request->user()->profile;
+		$authed = $request->user()->profile_id;
 		$profile = Profile::findOrFail($id);
-		if($id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
+
+		if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
+			return [];
 		}
 
 		$stories = Story::whereProfileId($profile->id)
 		->whereActive(true)
 		->orderBy('expires_at')
-		->where('expires_at', '>', now())
-		->when(!$publicOnly, function($query, $publicOnly) {
-			return $query->wherePublic(true);
-		})
 		->get()
-		->map(function($s, $k) {
-			return [
-				'id' => $s->id,
-				'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
-				'length' => 10,
+		->map(function($s, $k) use($authed) {
+			$seen = StoryService::hasSeen($authed, $s->id);
+			$res = [
+				'id' => (string) $s->id,
+				'type' => $s->type,
+				'duration' => $s->duration,
 				'src' => url(Storage::url($s->path)),
-				'preview' => null,
-				'link' => null,
-				'linkText' => null,
-				'time' => $s->created_at->format('U'),
-				'expires_at' => (int) $s->expires_at->format('U'),
-				'seen' => $s->seen()
+				'created_at' => $s->created_at->toAtomString(),
+				'expires_at' => $s->expires_at->toAtomString(),
+				'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
+				'seen' => $seen,
+				'progress' => $seen ? 100 : 0,
+				'can_reply' => (bool) $s->can_reply,
+				'can_react' => (bool) $s->can_react
 			];
+
+			if($s->type == 'poll') {
+				$res['question'] = json_decode($s->story, true)['question'];
+				$res['options'] = json_decode($s->story, true)['options'];
+				$res['voted'] = PollService::votedStory($s->id, $authed);
+				if($res['voted']) {
+					$res['voted_index'] = PollService::storyChoice($s->id, $authed);
+				}
+			}
+
+			return $res;
 		})->toArray();
 		if(count($stories) == 0) {
 			return [];
@@ -342,32 +111,27 @@ class StoryController extends Controller
 		$cursor = count($stories) - 1;
 		$stories = [[
 			'id' => (string) $stories[$cursor]['id'],
-			'photo' => $profile->avatarUrl(),
-			'name'	=> $profile->username,
-			'link'	=> $profile->url(),
-			'lastUpdated' => (int) now()->format('U'),
-			'seen' => null,
-			'items' => $stories,
+			'nodes' => $stories,
+			'account' => AccountService::get($profile->id),
 			'pid' => (string) $profile->id
 		]];
 		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function apiV1Viewed(Request $request)
+	public function viewed(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
 		$this->validate($request, [
-			'id'	=> 'required|integer|min:1|exists:stories',
+			'id'	=> 'required|min:1',
 		]);
 		$id = $request->input('id');
 
 		$authed = $request->user()->profile;
 
 		$story = Story::with('profile')
-			->where('expires_at', '>', now())
-			->orderByDesc('expires_at')
 			->findOrFail($id);
+		$exp = $story->expires_at;
 
 		$profile = $story->profile;
 
@@ -378,81 +142,128 @@ class StoryController extends Controller
 		$publicOnly = (bool) $profile->followedBy($authed);
 		abort_if(!$publicOnly, 403);
 
-		StoryView::firstOrCreate([
+
+		$v = StoryView::firstOrCreate([
 			'story_id' => $id,
 			'profile_id' => $authed->id
 		]);
 
-		$story->view_count = $story->view_count + 1;
-		$story->save();
+		if($v->wasRecentlyCreated) {
+			Story::findOrFail($story->id)->increment('view_count');
 
+			if($story->local == false) {
+				StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
+			}
+		}
+
+		Cache::forget('stories:recent:by_id:' . $authed->id);
+		StoryService::addSeen($authed->id, $story->id);
 		return ['code' => 200];
 	}
 
-	public function apiV1Exists(Request $request, $id)
+	public function exists(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$res = (bool) Story::whereProfileId($id)
+		return response()->json(Story::whereProfileId($id)
 		->whereActive(true)
-		->where('expires_at', '>', now())
-		->count();
+		->exists());
+	}
+
+	public function iRedirect(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		return response()->json($res);
+		$user = $request->user();
+		abort_if(!$user, 404);
+		$username = $user->username;
+		return redirect("/stories/{$username}");
 	}
 
-	public function apiV1Me(Request $request)
+	public function viewers(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$profile = $request->user()->profile;
-		$stories = Story::whereProfileId($profile->id)
+		$this->validate($request, [
+			'sid' => 'required|string'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$sid = $request->input('sid');
+
+		$story = Story::whereProfileId($pid)
 			->whereActive(true)
-			->orderBy('expires_at')
-			->where('expires_at', '>', now())
-			->get()
-			->map(function($s, $k) {
-				return [
-					'id' => $s->id,
-					'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
-					'length' => 3,
-					'src' => url(Storage::url($s->path)),
-					'preview' => null,
-					'link' => null,
-					'linkText' => null,
-					'time' => $s->created_at->format('U'),
-					'expires_at' => (int) $s->expires_at->format('U'),
-					'seen' => true
-				];
-		})->toArray();
-		$ts = count($stories) ? last($stories)['time'] : null;
-		$res = [
-			'id' => (string) $profile->id,
-			'photo' => $profile->avatarUrl(),
-			'name' => $profile->username,
-			'link' => $profile->url(),
-			'lastUpdated' => $ts,
-			'seen' => true,
-			'items' => $stories
-		];
+			->findOrFail($sid);
 
-		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+		$viewers = StoryView::whereStoryId($story->id)
+			->latest()
+			->simplePaginate(10)
+			->map(function($view) {
+				return AccountService::get($view->profile_id);
+			})
+			->values();
+
+		return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function compose(Request $request)
+	public function remoteStory(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		return view('stories.compose');
+		$profile = Profile::findOrFail($id);
+		if($profile->user_id != null || $profile->domain == null) {
+			return redirect('/stories/' . $profile->username);
+		}
+		$pid = $profile->id;
+		return view('stories.show_remote', compact('pid'));
 	}
 
-	public function iRedirect(Request $request)
+	public function pollResults(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$user = $request->user();
-		abort_if(!$user, 404);
-		$username = $user->username;
-		return redirect("/stories/{$username}");
+		$this->validate($request, [
+			'sid' => 'required|string'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$sid = $request->input('sid');
+
+		$story = Story::whereProfileId($pid)
+			->whereActive(true)
+			->findOrFail($sid);
+
+		return PollService::storyResults($sid);
+	}
+
+	public function getActivityObject(Request $request, $username, $id)
+	{
+		abort_if(!config_cache('instance.stories.enabled'), 404);
+
+		if(!$request->wantsJson()) {
+			return redirect('/stories/' . $username);
+		}
+
+		abort_if(!$request->hasHeader('Authorization'), 404);
+
+		$profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
+		$story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
+
+		abort_if($story->bearcap_token == null, 404);
+		abort_if(now()->gt($story->expires_at), 404);
+		$token = substr($request->header('Authorization'), 7);
+		abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
+		abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
+
+		$fractal = new Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Item($story, new StoryVerb());
+		$res = $fractal->createData($resource)->toArray();
+		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function showSystemStory()
+	{
+		// return view('stories.system');
 	}
 }

+ 51 - 48
app/Jobs/FollowPipeline/FollowPipeline.php

@@ -14,59 +14,62 @@ use Illuminate\Support\Facades\Redis;
 
 class FollowPipeline implements ShouldQueue
 {
-    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
-    protected $follower;
+	protected $follower;
 
-    /**
-     * Delete the job if its models no longer exist.
-     *
-     * @var bool
-     */
-    public $deleteWhenMissingModels = true;
-    
-    /**
-     * Create a new job instance.
-     *
-     * @return void
-     */
-    public function __construct($follower)
-    {
-        $this->follower = $follower;
-    }
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+	
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct($follower)
+	{
+		$this->follower = $follower;
+	}
 
-    /**
-     * Execute the job.
-     *
-     * @return void
-     */
-    public function handle()
-    {
-        $follower = $this->follower;
-        $actor = $follower->actor;
-        $target = $follower->target;
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$follower = $this->follower;
+		$actor = $follower->actor;
+		$target = $follower->target;
 
-        if($target->domain || !$target->private_key) {
-            return;
-        }
+		Cache::forget('profile:following:' . $actor->id);
+		Cache::forget('profile:following:' . $target->id);
 
-        try {
-            $notification = new Notification();
-            $notification->profile_id = $target->id;
-            $notification->actor_id = $actor->id;
-            $notification->action = 'follow';
-            $notification->message = $follower->toText();
-            $notification->rendered = $follower->toHtml();
-            $notification->item_id = $target->id;
-            $notification->item_type = "App\Profile";
-            $notification->save();
+		if($target->domain || !$target->private_key) {
+			return;
+		}
 
-            $redis = Redis::connection();
+		try {
+			$notification = new Notification();
+			$notification->profile_id = $target->id;
+			$notification->actor_id = $actor->id;
+			$notification->action = 'follow';
+			$notification->message = $follower->toText();
+			$notification->rendered = $follower->toHtml();
+			$notification->item_id = $target->id;
+			$notification->item_type = "App\Profile";
+			$notification->save();
 
-            $nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
-            $redis->lpush($nkey, $notification->id);
-        } catch (Exception $e) {
-            Log::error($e);
-        }
-    }
+			$redis = Redis::connection();
+
+			$nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
+			$redis->lpush($nkey, $notification->id);
+		} catch (Exception $e) {
+			Log::error($e);
+		}
+	}
 }

+ 56 - 0
app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Jobs\InstancePipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
+use App\Instance;
+use App\Profile;
+use App\Services\NodeinfoService;
+
+class FetchNodeinfoPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $instance;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Instance $instance)
+	{
+		$this->instance = $instance;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$instance = $this->instance;
+
+		$ni = NodeinfoService::get($instance->domain);
+		if($ni) {
+			if(isset($ni['software']) && is_array($ni['software']) && isset($ni['software']['name'])) {
+				$software = $ni['software']['name'];
+				$instance->software = strtolower(strip_tags($software));
+				$instance->last_crawled_at = now();
+				$instance->user_count = Profile::whereDomain($instance->domain)->count();
+				$instance->save();
+			}
+		} else {
+			$instance->user_count = Profile::whereDomain($instance->domain)->count();
+			$instance->last_crawled_at = now();
+			$instance->save();
+		}
+	}
+}

+ 43 - 0
app/Jobs/InstancePipeline/InstanceCrawlPipeline.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Jobs\InstancePipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Http;
+use App\Instance;
+use App\Profile;
+use App\Services\NodeinfoService;
+
+class InstanceCrawlPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        //
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        Instance::whereNull('last_crawled_at')->whereNull('software')->chunk(50, function($instances) use($headers) {
+			foreach($instances as $instance) {
+				FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
+			}
+		});
+    }
+}

+ 47 - 0
app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Jobs\MediaPipeline;
+
+use App\Media;
+use App\User;
+use Cache;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Services\StatusService;
+
+class MediaSyncLicensePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $userId;
+	protected $licenseId;
+
+	public function __construct($userId, $licenseId)
+	{
+		$this->userId = $userId;
+		$this->licenseId = $licenseId;
+	}
+
+	public function handle()
+	{
+		$licenseId = $this->licenseId;
+
+		if(!$licenseId || !$this->userId) {
+			return 1;
+		}
+
+		Media::whereUserId($this->userId)
+			->chunk(100, function($medias) use($licenseId) {
+				foreach($medias as $media) {
+					$media->license = $licenseId;
+					$media->save();
+					Cache::forget('status:transformer:media:attachments:'. $media->status_id);
+					StatusService::del($media->status_id);
+				}
+		});
+	}
+
+}

+ 94 - 81
app/Jobs/StatusPipeline/StatusActivityPubDeliver.php

@@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 use App\Transformer\ActivityPub\Verb\CreateNote;
+use App\Transformer\ActivityPub\Verb\CreateQuestion;
 use App\Util\ActivityPub\Helpers;
 use GuzzleHttp\Pool;
 use GuzzleHttp\Client;
@@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature;
 
 class StatusActivityPubDeliver implements ShouldQueue
 {
-    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-    protected $status;
-    
-    /**
-     * Delete the job if its models no longer exist.
-     *
-     * @var bool
-     */
-    public $deleteWhenMissingModels = true;
-    
-    /**
-     * Create a new job instance.
-     *
-     * @return void
-     */
-    public function __construct(Status $status)
-    {
-        $this->status = $status;
-    }
-
-    /**
-     * Execute the job.
-     *
-     * @return void
-     */
-    public function handle()
-    {
-        $status = $this->status;
-        $profile = $status->profile;
-
-        if($status->local == false || $status->url || $status->uri) {
-            return;
-        }
-
-        $audience = $status->profile->getAudienceInbox();
-
-        if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
-            // Return on profiles with no remote followers
-            return;
-        }
-
-
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Item($status, new CreateNote());
-        $activity = $fractal->createData($resource)->toArray();
-
-        $payload = json_encode($activity);
-        
-        $client = new Client([
-            'timeout'  => config('federation.activitypub.delivery.timeout')
-        ]);
-
-        $requests = function($audience) use ($client, $activity, $profile, $payload) {
-            foreach($audience as $url) {
-                $headers = HttpSignature::sign($profile, $url, $activity);
-                yield function() use ($client, $url, $headers, $payload) {
-                    return $client->postAsync($url, [
-                        'curl' => [
-                            CURLOPT_HTTPHEADER => $headers, 
-                            CURLOPT_POSTFIELDS => $payload,
-                            CURLOPT_HEADER => true
-                        ]
-                    ]);
-                };
-            }
-        };
-
-        $pool = new Pool($client, $requests($audience), [
-            'concurrency' => config('federation.activitypub.delivery.concurrency'),
-            'fulfilled' => function ($response, $index) {
-            },
-            'rejected' => function ($reason, $index) {
-            }
-        ]);
-        
-        $promise = $pool->promise();
-
-        $promise->wait();
-    }
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Status $status)
+	{
+		$this->status = $status;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$status = $this->status;
+		$profile = $status->profile;
+
+		if($status->local == false || $status->url || $status->uri) {
+			return;
+		}
+
+		$audience = $status->profile->getAudienceInbox();
+
+		if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
+			// Return on profiles with no remote followers
+			return;
+		}
+
+		switch($status->type) {
+			case 'poll':
+				$activitypubObject = new CreateQuestion();
+			break;
+
+			default:
+				$activitypubObject = new CreateNote();
+			break;
+		}
+
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($status, $activitypubObject);
+		$activity = $fractal->createData($resource)->toArray();
+
+		$payload = json_encode($activity);
+
+		$client = new Client([
+			'timeout'  => config('federation.activitypub.delivery.timeout')
+		]);
+
+		$requests = function($audience) use ($client, $activity, $profile, $payload) {
+			foreach($audience as $url) {
+				$headers = HttpSignature::sign($profile, $url, $activity);
+				yield function() use ($client, $url, $headers, $payload) {
+					return $client->postAsync($url, [
+						'curl' => [
+							CURLOPT_HTTPHEADER => $headers,
+							CURLOPT_POSTFIELDS => $payload,
+							CURLOPT_HEADER => true,
+							CURLOPT_SSL_VERIFYPEER => false,
+							CURLOPT_SSL_VERIFYHOST => false
+						]
+					]);
+				};
+			}
+		};
+
+		$pool = new Pool($client, $requests($audience), [
+			'concurrency' => config('federation.activitypub.delivery.concurrency'),
+			'fulfilled' => function ($response, $index) {
+			},
+			'rejected' => function ($reason, $index) {
+			}
+		]);
+
+		$promise = $pool->promise();
+
+		$promise->wait();
+	}
 }

+ 136 - 0
app/Jobs/StoryPipeline/StoryDelete.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use App\Story;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\DeleteStory;
+use App\Util\ActivityPub\Helpers;
+use GuzzleHttp\Pool;
+use GuzzleHttp\Client;
+use GuzzleHttp\Promise;
+use App\Util\ActivityPub\HttpSignature;
+use App\Services\FollowerService;
+use App\Services\StoryService;
+
+class StoryDelete implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story)
+	{
+		$this->story = $story;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+
+		if($story->local == false) {
+			return;
+		}
+
+		StoryService::removeRotateQueue($story->id);
+		StoryService::delLatest($story->profile_id);
+		StoryService::delById($story->id);
+
+		if(Storage::exists($story->path) == true) {
+			Storage::delete($story->path);
+		}
+
+		$story->views()->delete();
+
+		$profile = $story->profile;
+
+		$activity = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $story->url() . '#delete',
+			'type' => 'Delete',
+			'actor' => $profile->permalink(),
+			'object' => [
+				'id' => $story->url(),
+				'type' => 'Story',
+			],
+		];
+
+		$this->fanoutExpiry($profile, $activity);
+
+		// delete notifications
+		// delete polls
+		// delete reports
+
+		$story->delete();
+
+		return;
+	}
+
+	protected function fanoutExpiry($profile, $activity)
+	{
+		$audience = FollowerService::softwareAudience($profile->id, 'pixelfed');
+
+		if(empty($audience)) {
+			// Return on profiles with no remote followers
+			return;
+		}
+
+		$payload = json_encode($activity);
+
+		$client = new Client([
+			'timeout'  => config('federation.activitypub.delivery.timeout')
+		]);
+
+		$requests = function($audience) use ($client, $activity, $profile, $payload) {
+			foreach($audience as $url) {
+				$headers = HttpSignature::sign($profile, $url, $activity);
+				yield function() use ($client, $url, $headers, $payload) {
+					return $client->postAsync($url, [
+						'curl' => [
+							CURLOPT_HTTPHEADER => $headers,
+							CURLOPT_POSTFIELDS => $payload,
+							CURLOPT_HEADER => true
+						]
+					]);
+				};
+			}
+		};
+
+		$pool = new Pool($client, $requests($audience), [
+			'concurrency' => config('federation.activitypub.delivery.concurrency'),
+			'fulfilled' => function ($response, $index) {
+			},
+			'rejected' => function ($reason, $index) {
+			}
+		]);
+
+		$promise = $pool->promise();
+
+		$promise->wait();
+	}
+}

+ 169 - 0
app/Jobs/StoryPipeline/StoryExpire.php

@@ -0,0 +1,169 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use App\Story;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\DeleteStory;
+use App\Util\ActivityPub\Helpers;
+use GuzzleHttp\Pool;
+use GuzzleHttp\Client;
+use GuzzleHttp\Promise;
+use App\Util\ActivityPub\HttpSignature;
+use App\Services\FollowerService;
+use App\Services\StoryService;
+
+class StoryExpire implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story)
+	{
+		$this->story = $story;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+
+		if($story->local == false) {
+			$this->handleRemoteExpiry();
+			return;
+		}
+
+		if($story->active == false) {
+			return;
+		}
+
+		if($story->expires_at->gt(now())) {
+			return;
+		}
+
+		$story->active = false;
+		$story->save();
+
+		$this->rotateMediaPath();
+		$this->fanoutExpiry();
+
+		StoryService::delLatest($story->profile_id);
+	}
+
+	protected function rotateMediaPath()
+	{
+		$story = $this->story;
+		$date = date('Y').date('m');
+		$old = $story->path;
+		$base = "story_archives/{$story->profile_id}/{$date}/";
+		$paths = explode('/', $old);
+		$path = array_pop($paths);
+		$newPath = $base . $path;
+
+		if(Storage::exists($old) == true) {
+			$dir = implode('/', $paths);
+			Storage::move($old, $newPath);
+			Storage::delete($old);
+			$story->bearcap_token = null;
+			$story->path = $newPath;
+			$story->save();
+			Storage::deleteDirectory($dir);
+		}
+	}
+
+	protected function fanoutExpiry()
+	{
+		$story = $this->story;
+		$profile = $story->profile;
+
+		if($story->local == false || $story->remote_url) {
+			return;
+		}
+
+		$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
+
+		if(empty($audience)) {
+			// Return on profiles with no remote followers
+			return;
+		}
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($story, new DeleteStory());
+		$activity = $fractal->createData($resource)->toArray();
+
+		$payload = json_encode($activity);
+
+		$client = new Client([
+			'timeout'  => config('federation.activitypub.delivery.timeout')
+		]);
+
+		$requests = function($audience) use ($client, $activity, $profile, $payload) {
+			foreach($audience as $url) {
+				$headers = HttpSignature::sign($profile, $url, $activity);
+				yield function() use ($client, $url, $headers, $payload) {
+					return $client->postAsync($url, [
+						'curl' => [
+							CURLOPT_HTTPHEADER => $headers,
+							CURLOPT_POSTFIELDS => $payload,
+							CURLOPT_HEADER => true
+						]
+					]);
+				};
+			}
+		};
+
+		$pool = new Pool($client, $requests($audience), [
+			'concurrency' => config('federation.activitypub.delivery.concurrency'),
+			'fulfilled' => function ($response, $index) {
+			},
+			'rejected' => function ($reason, $index) {
+			}
+		]);
+
+		$promise = $pool->promise();
+
+		$promise->wait();
+	}
+
+	protected function handleRemoteExpiry()
+	{
+		$story = $this->story;
+		$story->active = false;
+		$story->save();
+
+		$path = $story->path;
+
+		if(Storage::exists($path) == true) {
+			Storage::delete($path);
+		}
+
+		$story->views()->delete();
+		$story->delete();
+	}
+}

+ 107 - 0
app/Jobs/StoryPipeline/StoryFanout.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use Cache, Log;
+use App\Story;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\CreateStory;
+use App\Util\ActivityPub\Helpers;
+use GuzzleHttp\Pool;
+use GuzzleHttp\Client;
+use GuzzleHttp\Promise;
+use App\Util\ActivityPub\HttpSignature;
+use App\Services\FollowerService;
+use App\Services\StoryService;
+
+class StoryFanout implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story)
+	{
+		$this->story = $story;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+		$profile = $story->profile;
+
+		if($story->local == false || $story->remote_url) {
+			return;
+		}
+
+		StoryService::delLatest($story->profile_id);
+
+		$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
+
+		if(empty($audience)) {
+			// Return on profiles with no remote followers
+			return;
+		}
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($story, new CreateStory());
+		$activity = $fractal->createData($resource)->toArray();
+
+		$payload = json_encode($activity);
+
+		$client = new Client([
+			'timeout'  => config('federation.activitypub.delivery.timeout')
+		]);
+
+		$requests = function($audience) use ($client, $activity, $profile, $payload) {
+			foreach($audience as $url) {
+				$headers = HttpSignature::sign($profile, $url, $activity);
+				yield function() use ($client, $url, $headers, $payload) {
+					return $client->postAsync($url, [
+						'curl' => [
+							CURLOPT_HTTPHEADER => $headers,
+							CURLOPT_POSTFIELDS => $payload,
+							CURLOPT_HEADER => true
+						]
+					]);
+				};
+			}
+		};
+
+		$pool = new Pool($client, $requests($audience), [
+			'concurrency' => config('federation.activitypub.delivery.concurrency'),
+			'fulfilled' => function ($response, $index) {
+			},
+			'rejected' => function ($reason, $index) {
+			}
+		]);
+
+		$promise = $pool->promise();
+
+		$promise->wait();
+	}
+}

+ 144 - 0
app/Jobs/StoryPipeline/StoryFetch.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use Cache, Log;
+use App\Story;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+use App\Services\FollowerService;
+use App\Util\Lexer\Bearcap;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Http\Client\RequestException;
+use Illuminate\Http\Client\ConnectionException;
+use App\Util\ActivityPub\Validator\StoryValidator;
+use App\Services\StoryService;
+use App\Services\MediaPathService;
+use Illuminate\Support\Str;
+use Illuminate\Http\File;
+use Illuminate\Support\Facades\Storage;
+
+class StoryFetch implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $activity;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct($activity)
+	{
+		$this->activity = $activity;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$activity = $this->activity;
+		$activityId = $activity['id'];
+		$activityActor = $activity['actor'];
+
+		if(parse_url($activityId, PHP_URL_HOST) !== parse_url($activityActor, PHP_URL_HOST)) {
+			return;
+		}
+
+		$bearcap = Bearcap::decode($activity['object']['object']);
+
+		if(!$bearcap) {
+			return;
+		}
+
+		$url = $bearcap['url'];
+		$token = $bearcap['token'];
+
+		if(parse_url($activityId, PHP_URL_HOST) !== parse_url($url, PHP_URL_HOST)) {
+			return;
+		}
+
+		$version = config('pixelfed.version');
+		$appUrl = config('app.url');
+		$headers = [
+			'Accept'     	=> 'application/json',
+			'Authorization' => 'Bearer ' . $token,
+			'User-Agent' 	=> "(Pixelfed/{$version}; +{$appUrl})",
+		];
+
+		try {
+			$res = Http::withHeaders($headers)
+				->timeout(30)
+				->get($url);
+		} catch (RequestException $e) {
+			return false;
+		} catch (ConnectionException $e) {
+			return false;
+		} catch (\Exception $e) {
+			return false;
+		}
+
+		$payload = $res->json();
+
+		if(StoryValidator::validate($payload) == false) {
+			return;
+		}
+
+		if(Helpers::validateUrl($payload['attachment']['url']) == false) {
+			return;
+		}
+
+		$type = $payload['attachment']['type'] == 'Image' ? 'photo' : 'video';
+
+		$profile = Helpers::profileFetch($payload['attributedTo']);
+
+		$ext = pathinfo($payload['attachment']['url'], PATHINFO_EXTENSION);
+		$storagePath = MediaPathService::story($profile);
+		$fileName = Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $ext;
+		$contextOptions = [
+			'ssl' => [
+				'verify_peer' => false,
+				'verify_peername' => false
+			]
+		];
+		$ctx = stream_context_create($contextOptions);
+		$data = file_get_contents($payload['attachment']['url'], false, $ctx);
+		$tmpBase = storage_path('app/remcache/');
+		$tmpPath = $profile->id . '-' . $fileName;
+		$tmpName = $tmpBase . $tmpPath;
+		file_put_contents($tmpName, $data);
+		$disk = Storage::disk(config('filesystems.default'));
+		$path = $disk->putFileAs($storagePath, new File($tmpName), $fileName, 'public');
+		$size = filesize($tmpName);
+		unlink($tmpName);
+
+		$story = new Story;
+		$story->profile_id = $profile->id;
+		$story->object_id = $payload['id'];
+		$story->size = $size;
+		$story->mime = $payload['attachment']['mediaType'];
+		$story->duration = $payload['duration'];
+		$story->media_url = $payload['attachment']['url'];
+		$story->type = $type;
+		$story->public = false;
+		$story->local = false;
+		$story->active = true;
+		$story->path = $path;
+		$story->view_count = 0;
+		$story->can_reply = $payload['can_reply'];
+		$story->can_react = $payload['can_react'];
+		$story->created_at = now()->parse($payload['published']);
+		$story->expires_at = now()->parse($payload['expiresAt']);
+		$story->save();
+
+		StoryService::delLatest($story->profile_id);
+	}
+}

+ 70 - 0
app/Jobs/StoryPipeline/StoryReactionDeliver.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use App\Story;
+use App\Status;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+
+class StoryReactionDeliver implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+	protected $status;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story, Status $status)
+	{
+		$this->story = $story;
+		$this->status = $status;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+		$status = $this->status;
+
+		if($story->local == true) {
+			return;
+		}
+
+		$target = $story->profile;
+		$actor = $status->profile;
+		$to = $target->inbox_url;
+
+		$payload = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $status->permalink(),
+			'type' => 'Story:Reaction',
+			'to' => $target->permalink(),
+			'actor' => $actor->permalink(),
+			'content' => $status->caption,
+			'inReplyTo' => $story->object_id,
+			'published' => $status->created_at->toAtomString()
+		];
+
+		Helpers::sendSignedObject($actor, $to, $payload);
+	}
+}

+ 70 - 0
app/Jobs/StoryPipeline/StoryReplyDeliver.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use App\Story;
+use App\Status;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+
+class StoryReplyDeliver implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+	protected $status;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story, Status $status)
+	{
+		$this->story = $story;
+		$this->status = $status;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+		$status = $this->status;
+
+		if($story->local == true) {
+			return;
+		}
+
+		$target = $story->profile;
+		$actor = $status->profile;
+		$to = $target->inbox_url;
+
+		$payload = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $status->permalink(),
+			'type' => 'Story:Reply',
+			'to' => $target->permalink(),
+			'actor' => $actor->permalink(),
+			'content' => $status->caption,
+			'inReplyTo' => $story->object_id,
+			'published' => $status->created_at->toAtomString()
+		];
+
+		Helpers::sendSignedObject($actor, $to, $payload);
+	}
+}

+ 61 - 0
app/Jobs/StoryPipeline/StoryRotateMedia.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use App\Story;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+
+class StoryRotateMedia implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story)
+	{
+		$this->story = $story;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+
+		if($story->local == false) {
+			return;
+		}
+
+		$paths = explode('/', $story->path);
+		$name = array_pop($paths);
+
+		$oldPath = $story->path;
+		$ext = pathinfo($name, PATHINFO_EXTENSION);
+		$new = Str::random(13) . '_' . Str::random(24) . '_' . Str::random(3) . '.' . $ext;
+		array_push($paths, $new);
+		$newPath = implode('/', $paths);
+
+		if(Storage::exists($oldPath)) {
+			Storage::copy($oldPath, $newPath);
+			$story->path = $newPath;
+			$story->bearcap_token = null;
+			$story->save();
+			Storage::delete($oldPath);
+		}
+	}
+}

+ 70 - 0
app/Jobs/StoryPipeline/StoryViewDeliver.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Jobs\StoryPipeline;
+
+use App\Story;
+use App\Profile;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+
+class StoryViewDeliver implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $story;
+	protected $profile;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Story $story, Profile $profile)
+	{
+		$this->story = $story;
+		$this->profile = $profile;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$story = $this->story;
+
+		if($story->local == true) {
+			return;
+		}
+
+		$actor = $this->profile;
+		$target = $story->profile;
+		$to = $target->inbox_url;
+
+		$payload = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $actor->permalink('#stories/' . $story->id . '/view'),
+			'type' => 'View',
+			'to' => $target->permalink(),
+			'actor' => $actor->permalink(),
+			'object' => [
+				'type' => 'Story',
+				'object' => $story->object_id
+			]
+		];
+
+		Helpers::sendSignedObject($actor, $to, $payload);
+	}
+}

+ 1 - 1
app/Mail/ContactAdmin.php

@@ -32,6 +32,6 @@ class ContactAdmin extends Mailable
     public function build()
     {
         $contact = $this->contact;
-        return $this->markdown('emails.contact.admin')->with(compact('contact'));
+        return $this->subject('New Support Message')->markdown('emails.contact.admin')->with(compact('contact'));
     }
 }

+ 4 - 0
app/Media.php

@@ -18,6 +18,10 @@ class Media extends Model
      */
     protected $dates = ['deleted_at'];
 
+    protected $casts = [
+    	'srcset' => 'array'
+    ];
+
     public function status()
     {
         return $this->belongsTo(Status::class);

+ 35 - 0
app/Models/Poll.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\HasSnowflakePrimary;
+
+class Poll extends Model
+{
+	use HasSnowflakePrimary, HasFactory;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+	protected $casts = [
+		'poll_options' => 'array',
+		'cached_tallies' => 'array',
+		'expires_at' => 'datetime'
+	];
+
+	public function votes()
+	{
+		return $this->hasMany(PollVote::class);
+	}
+
+	public function getTallies()
+	{
+		return $this->cached_tallies;
+	}
+}

+ 11 - 0
app/Models/PollVote.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class PollVote extends Model
+{
+    use HasFactory;
+}

+ 64 - 0
app/Observers/FollowerObserver.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Observers;
+
+use App\Follower;
+use App\Services\FollowerService;
+
+class FollowerObserver
+{
+    /**
+     * Handle the Follower "created" event.
+     *
+     * @param  \App\Models\Follower  $follower
+     * @return void
+     */
+    public function created(Follower $follower)
+    {
+        FollowerService::add($follower->profile_id, $follower->following_id);
+    }
+
+    /**
+     * Handle the Follower "updated" event.
+     *
+     * @param  \App\Models\Follower  $follower
+     * @return void
+     */
+    public function updated(Follower $follower)
+    {
+        FollowerService::add($follower->profile_id, $follower->following_id);
+    }
+
+    /**
+     * Handle the Follower "deleted" event.
+     *
+     * @param  \App\Models\Follower  $follower
+     * @return void
+     */
+    public function deleted(Follower $follower)
+    {
+        FollowerService::remove($follower->profile_id, $follower->following_id);
+    }
+
+    /**
+     * Handle the Follower "restored" event.
+     *
+     * @param  \App\Models\Follower  $follower
+     * @return void
+     */
+    public function restored(Follower $follower)
+    {
+        FollowerService::add($follower->profile_id, $follower->following_id);
+    }
+
+    /**
+     * Handle the Follower "force deleted" event.
+     *
+     * @param  \App\Models\Follower  $follower
+     * @return void
+     */
+    public function forceDeleted(Follower $follower)
+    {
+        FollowerService::remove($follower->profile_id, $follower->following_id);
+    }
+}

+ 0 - 1
app/Place.php

@@ -3,7 +3,6 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
 
 class Place extends Model
 {

+ 317 - 324
app/Profile.php

@@ -2,333 +2,326 @@
 
 namespace App;
 
-use Auth, Cache, Storage;
+use Auth, Cache, DB, Storage;
 use App\Util\Lexer\PrettyNumber;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
+use App\HasSnowflakePrimary;
 use Illuminate\Database\Eloquent\{Model, SoftDeletes};
+use App\Services\FollowerService;
 
 class Profile extends Model
 {
-    use HasSnowflakePrimary, SoftDeletes;
-
-    /**
-     * Indicates if the IDs are auto-incrementing.
-     *
-     * @var bool
-     */
-    public $incrementing = false;
-    
-    protected $dates = [
-        'deleted_at',
-        'last_fetched_at'
-    ];
-    protected $hidden = ['private_key'];
-    protected $visible = ['id', 'user_id', 'username', 'name'];
-    protected $fillable = ['user_id'];
-
-    public function user()
-    {
-        return $this->belongsTo(User::class);
-    }
-
-    public function url($suffix = null)
-    {
-        return $this->remote_url ?? url($this->username . $suffix);
-    }
-
-    public function localUrl($suffix = null)
-    {
-        return url($this->username . $suffix);
-    }
-
-    public function permalink($suffix = null)
-    {
-        return $this->remote_url ?? url('users/' . $this->username . $suffix);
-    }
-
-    public function emailUrl()
-    {
-        if($this->domain) {
-            return $this->username;
-        }
-        
-        $domain = parse_url(config('app.url'), PHP_URL_HOST);
-
-        return $this->username.'@'.$domain;
-    }
-
-    public function statuses()
-    {
-        return $this->hasMany(Status::class);
-    }
-
-    public function followingCount($short = false)
-    {
-        $count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() {
-            if($this->domain == null && $this->user->settings->show_profile_following_count == false) {
-                return 0;
-            }
-            $count = $this->following()->count();
-            if($this->following_count != $count) {
-                $this->following_count = $count;
-                $this->save();
-            }
-            return $count;
-        });
-
-        return $short ? PrettyNumber::convert($count) : $count;
-    }
-
-    public function followerCount($short = false)
-    {
-        $count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() {
-            if($this->domain == null && $this->user->settings->show_profile_follower_count == false) {
-                return 0;
-            }
-            $count = $this->followers()->count();
-            if($this->followers_count != $count) {
-                $this->followers_count = $count;
-                $this->save();
-            }
-            return $count;
-        });
-        return $short ? PrettyNumber::convert($count) : $count;
-    }
-
-    public function statusCount()
-    {
-        return $this->status_count;
-    }
-
-    public function following()
-    {
-        return $this->belongsToMany(
-            self::class,
-            'followers',
-            'profile_id',
-            'following_id'
-        );
-    }
-
-    public function followers()
-    {
-        return $this->belongsToMany(
-            self::class,
-            'followers',
-            'following_id',
-            'profile_id'
-        );
-    }
-
-    public function follows($profile) : bool
-    {
-        return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists();
-    }
-
-    public function followedBy($profile) : bool
-    {
-        return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists();
-    }
-
-    public function bookmarks()
-    {
-        return $this->belongsToMany(
-            Status::class,
-            'bookmarks',
-            'profile_id',
-            'status_id'
-        );
-    }
-
-    public function likes()
-    {
-        return $this->hasMany(Like::class);
-    }
-
-    public function avatar()
-    {
-        return $this->hasOne(Avatar::class)->withDefault([
-            'media_path' => 'public/avatars/default.jpg',
-            'change_count' => 0
-        ]);
-    }
-
-    public function avatarUrl()
-    {
-        $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () {
-            $avatar = $this->avatar;
-
-            if($avatar->cdn_url) {
-                return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
-            }
-
-            if($avatar->is_remote) {
-                return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
-            }
-            
-            $path = $avatar->media_path;
-            $path = "{$path}?v={$avatar->change_count}";
-
-            return config('app.url') . Storage::url($path);
-        });
-
-        return $url;
-    }
-
-    // deprecated
-    public function recommendFollowers()
-    {
-        return collect([]);
-    }
-
-    public function keyId()
-    {
-        if ($this->remote_url) {
-            return;
-        }
-
-        return $this->permalink('#main-key');
-    }
-
-    public function mutedIds()
-    {
-        return UserFilter::whereUserId($this->id)
-            ->whereFilterableType('App\Profile')
-            ->whereFilterType('mute')
-            ->pluck('filterable_id');
-    }
-
-    public function blockedIds()
-    {
-        return UserFilter::whereUserId($this->id)
-            ->whereFilterableType('App\Profile')
-            ->whereFilterType('block')
-            ->pluck('filterable_id');
-    }
-
-    public function mutedProfileUrls()
-    {
-        $ids = $this->mutedIds();
-        return $this->whereIn('id', $ids)->get()->map(function($i) {
-            return $i->url();
-        });
-    }
-
-    public function blockedProfileUrls()
-    {
-        $ids = $this->blockedIds();
-        return $this->whereIn('id', $ids)->get()->map(function($i) {
-            return $i->url();
-        });
-    }
-
-    public function reports()
-    {
-        return $this->hasMany(Report::class, 'profile_id');
-    }
-
-    public function media()
-    {
-        return $this->hasMany(Media::class, 'profile_id');
-    }
-
-    public function inboxUrl()
-    {
-        return $this->inbox_url ?? $this->permalink('/inbox');
-    }
-
-    public function outboxUrl()
-    {
-        return $this->outbox_url ?? $this->permalink('/outbox');
-    }
-
-    public function sharedInbox()
-    {
-        return $this->sharedInbox ?? $this->inboxUrl();
-    }
-
-    public function getDefaultScope()
-    {
-        return $this->is_private == true ? 'private' : 'public';
-    }
-
-    public function getAudience($scope = false)
-    {
-        if($this->remote_url) {
-            return [];
-        }
-        $scope = $scope ?? $this->getDefaultScope();
-        $audience = [];
-        switch ($scope) {
-            case 'public':
-                $audience = [
-                    'to' => [
-                        'https://www.w3.org/ns/activitystreams#Public'
-                    ],
-                    'cc' => [
-                        $this->permalink('/followers')
-                    ]
-                ];
-                break;
-        }
-        return $audience;
-    }
-
-    public function getAudienceInbox($scope = 'public')
-    {
-        return $this
-            ->followers()
-            ->whereLocalProfile(false)
-            ->get()
-            ->map(function($follow) {
-                return $follow->sharedInbox ?? $follow->inbox_url;
-             })
-            ->unique()
-            ->toArray();
-    }
-
-    public function circles()
-    {
-        return $this->hasMany(Circle::class);
-    }
-
-    public function hashtags()
-    {
-        return $this->hasManyThrough(
-            Hashtag::class,
-            StatusHashtag::class,
-            'profile_id',
-            'id',
-            'id',
-            'hashtag_id'
-        );
-    }
-
-    public function hashtagFollowing()
-    {
-        return $this->hasMany(HashtagFollow::class);
-    }
-
-    public function collections()
-    {
-        return $this->hasMany(Collection::class);
-    }
-
-    public function hasFollowRequestById(int $id)
-    {
-        return FollowRequest::whereFollowerId($id)
-            ->whereFollowingId($this->id)
-            ->exists();
-    }
-
-    public function stories()
-    {
-        return $this->hasMany(Story::class);
-    }
-
-
-    public function reported()
-    {
-        return $this->hasMany(Report::class, 'object_id');
-    }
+	use HasSnowflakePrimary, SoftDeletes;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+	protected $dates = [
+		'deleted_at',
+		'last_fetched_at'
+	];
+	protected $hidden = ['private_key'];
+	protected $visible = ['id', 'user_id', 'username', 'name'];
+	protected $fillable = ['user_id'];
+
+	public function user()
+	{
+		return $this->belongsTo(User::class);
+	}
+
+	public function url($suffix = null)
+	{
+		return $this->remote_url ?? url($this->username . $suffix);
+	}
+
+	public function localUrl($suffix = null)
+	{
+		return url($this->username . $suffix);
+	}
+
+	public function permalink($suffix = null)
+	{
+		return $this->remote_url ?? url('users/' . $this->username . $suffix);
+	}
+
+	public function emailUrl()
+	{
+		if($this->domain) {
+			return $this->username;
+		}
+
+		$domain = parse_url(config('app.url'), PHP_URL_HOST);
+
+		return $this->username.'@'.$domain;
+	}
+
+	public function statuses()
+	{
+		return $this->hasMany(Status::class);
+	}
+
+	public function followingCount($short = false)
+	{
+		$count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() {
+			if($this->domain == null && $this->user->settings->show_profile_following_count == false) {
+				return 0;
+			}
+			$count = DB::table('followers')->select('following_id')->where('following_id', $this->id)->count();
+			if($this->following_count != $count) {
+				$this->following_count = $count;
+				$this->save();
+			}
+			return $count;
+		});
+
+		return $short ? PrettyNumber::convert($count) : $count;
+	}
+
+	public function followerCount($short = false)
+	{
+		$count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() {
+			if($this->domain == null && $this->user->settings->show_profile_follower_count == false) {
+				return 0;
+			}
+			$count = $this->followers()->count();
+			if($this->followers_count != $count) {
+				$this->followers_count = $count;
+				$this->save();
+			}
+			return $count;
+		});
+		return $short ? PrettyNumber::convert($count) : $count;
+	}
+
+	public function statusCount()
+	{
+		return $this->status_count;
+	}
+
+	public function following()
+	{
+		return $this->belongsToMany(
+			self::class,
+			'followers',
+			'profile_id',
+			'following_id'
+		);
+	}
+
+	public function followers()
+	{
+		return $this->belongsToMany(
+			self::class,
+			'followers',
+			'following_id',
+			'profile_id'
+		);
+	}
+
+	public function follows($profile) : bool
+	{
+		return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists();
+	}
+
+	public function followedBy($profile) : bool
+	{
+		return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists();
+	}
+
+	public function bookmarks()
+	{
+		return $this->belongsToMany(
+			Status::class,
+			'bookmarks',
+			'profile_id',
+			'status_id'
+		);
+	}
+
+	public function likes()
+	{
+		return $this->hasMany(Like::class);
+	}
+
+	public function avatar()
+	{
+		return $this->hasOne(Avatar::class)->withDefault([
+			'media_path' => 'public/avatars/default.jpg',
+			'change_count' => 0
+		]);
+	}
+
+	public function avatarUrl()
+	{
+		$url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () {
+			$avatar = $this->avatar;
+
+			if($avatar->cdn_url) {
+				return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
+			}
+
+			if($avatar->is_remote) {
+				return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
+			}
+			
+			$path = $avatar->media_path;
+			$path = "{$path}?v={$avatar->change_count}";
+
+			return config('app.url') . Storage::url($path);
+		});
+
+		return $url;
+	}
+
+	// deprecated
+	public function recommendFollowers()
+	{
+		return collect([]);
+	}
+
+	public function keyId()
+	{
+		if ($this->remote_url) {
+			return;
+		}
+
+		return $this->permalink('#main-key');
+	}
+
+	public function mutedIds()
+	{
+		return UserFilter::whereUserId($this->id)
+			->whereFilterableType('App\Profile')
+			->whereFilterType('mute')
+			->pluck('filterable_id');
+	}
+
+	public function blockedIds()
+	{
+		return UserFilter::whereUserId($this->id)
+			->whereFilterableType('App\Profile')
+			->whereFilterType('block')
+			->pluck('filterable_id');
+	}
+
+	public function mutedProfileUrls()
+	{
+		$ids = $this->mutedIds();
+		return $this->whereIn('id', $ids)->get()->map(function($i) {
+			return $i->url();
+		});
+	}
+
+	public function blockedProfileUrls()
+	{
+		$ids = $this->blockedIds();
+		return $this->whereIn('id', $ids)->get()->map(function($i) {
+			return $i->url();
+		});
+	}
+
+	public function reports()
+	{
+		return $this->hasMany(Report::class, 'profile_id');
+	}
+
+	public function media()
+	{
+		return $this->hasMany(Media::class, 'profile_id');
+	}
+
+	public function inboxUrl()
+	{
+		return $this->inbox_url ?? $this->permalink('/inbox');
+	}
+
+	public function outboxUrl()
+	{
+		return $this->outbox_url ?? $this->permalink('/outbox');
+	}
+
+	public function sharedInbox()
+	{
+		return $this->sharedInbox ?? $this->inboxUrl();
+	}
+
+	public function getDefaultScope()
+	{
+		return $this->is_private == true ? 'private' : 'public';
+	}
+
+	public function getAudience($scope = false)
+	{
+		if($this->remote_url) {
+			return [];
+		}
+		$scope = $scope ?? $this->getDefaultScope();
+		$audience = [];
+		switch ($scope) {
+			case 'public':
+				$audience = [
+					'to' => [
+						'https://www.w3.org/ns/activitystreams#Public'
+					],
+					'cc' => [
+						$this->permalink('/followers')
+					]
+				];
+				break;
+		}
+		return $audience;
+	}
+
+	public function getAudienceInbox($scope = 'public')
+	{
+		return FollowerService::audience($this->id, $scope);
+	}
+
+	public function circles()
+	{
+		return $this->hasMany(Circle::class);
+	}
+
+	public function hashtags()
+	{
+		return $this->hasManyThrough(
+			Hashtag::class,
+			StatusHashtag::class,
+			'profile_id',
+			'id',
+			'id',
+			'hashtag_id'
+		);
+	}
+
+	public function hashtagFollowing()
+	{
+		return $this->hasMany(HashtagFollow::class);
+	}
+
+	public function collections()
+	{
+		return $this->hasMany(Collection::class);
+	}
+
+	public function hasFollowRequestById(int $id)
+	{
+		return FollowRequest::whereFollowerId($id)
+			->whereFollowingId($this->id)
+			->exists();
+	}
+
+	public function stories()
+	{
+		return $this->hasMany(Story::class);
+	}
+
+
+	public function reported()
+	{
+		return $this->hasMany(Report::class, 'object_id');
+	}
 }

+ 3 - 0
app/Providers/AppServiceProvider.php

@@ -4,6 +4,7 @@ namespace App\Providers;
 
 use App\Observers\{
 	AvatarObserver,
+	FollowerObserver,
 	LikeObserver,
 	NotificationObserver,
 	ModLogObserver,
@@ -14,6 +15,7 @@ use App\Observers\{
 };
 use App\{
 	Avatar,
+	Follower,
 	Like,
 	Notification,
 	ModLog,
@@ -48,6 +50,7 @@ class AppServiceProvider extends ServiceProvider
 		StatusHashtag::observe(StatusHashtagObserver::class);
 		User::observe(UserObserver::class);
 		UserFilter::observe(UserFilterObserver::class);
+		Follower::observe(FollowerObserver::class);
 		Horizon::auth(function ($request) {
 			return Auth::check() && $request->user()->is_admin;
 		});

+ 31 - 4
app/Services/AccountService.php

@@ -4,12 +4,13 @@ namespace App\Services;
 
 use Cache;
 use App\Profile;
+use App\Status;
 use App\Transformer\Api\AccountTransformer;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 
-class AccountService {
-
+class AccountService
+{
 	const CACHE_KEY = 'pf:services:account:';
 
 	public static function get($id)
@@ -19,7 +20,7 @@ class AccountService {
 		}
 
 		$key = self::CACHE_KEY . $id;
-		$ttl = now()->addMinutes(15);
+		$ttl = now()->addHours(12);
 
 		return Cache::remember($key, $ttl, function() use($id) {
 			$fractal = new Fractal\Manager();
@@ -35,4 +36,30 @@ class AccountService {
 		return Cache::forget(self::CACHE_KEY . $id);
 	}
 
-}
+	public static function syncPostCount($id)
+	{
+		$profile = Profile::find($id);
+
+		if(!$profile) {
+			return false;
+		}
+
+		$key = self::CACHE_KEY . 'pcs:' . $id;
+
+		if(Cache::has($key)) {
+			return;
+		}
+
+		$count = Status::whereProfileId($id)
+			->whereNull('in_reply_to_id')
+			->whereNull('reblog_of_id')
+			->whereIn('scope', ['public', 'unlisted', 'private'])
+			->count();
+
+		$profile->status_count = $count;
+		$profile->save();
+
+		Cache::put($key, 1, 900);
+		return true;
+	}
+}

+ 24 - 36
app/Services/FollowerService.php

@@ -3,7 +3,7 @@
 namespace App\Services;
 
 use Illuminate\Support\Facades\Redis;
-
+use Cache;
 use App\{
 	Follower,
 	Profile,
@@ -25,6 +25,8 @@ class FollowerService
 	{
 		Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
 		Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
+		Cache::forget('pf:services:follow:audience:' . $actor);
+		Cache::forget('pf:services:follow:audience:' . $target);
 	}
 
 	public static function followers($id, $start = 0, $stop = 10)
@@ -42,46 +44,34 @@ class FollowerService
 		return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
 	}
 
-	public static function audience($profile)
+	public static function audience($profile, $scope = null)
 	{
-		return (new self)->getAudienceInboxes($profile);
+		return (new self)->getAudienceInboxes($profile, $scope);
 	}
 
-	protected function getAudienceInboxes($profile)
+	public static function softwareAudience($profile, $software = 'pixelfed')
 	{
-		if($profile instanceOf User) {
-			return $profile
-				->profile
-				->followers()
-				->whereLocalProfile(false)
-				->get()
-				->map(function($follow) {
-					return $follow->sharedInbox ?? $follow->inbox_url;
-				})
-				->unique()
-				->values()
-				->toArray();
-		}
-
-		if($profile instanceOf Profile) {
-			return $profile
-				->followers()
-				->whereLocalProfile(false)
-				->get()
-				->map(function($follow) {
-					return $follow->sharedInbox ?? $follow->inbox_url;
-				})
-				->unique()
-				->values()
-				->toArray();
-		}
+		return collect(self::audience($profile))
+			->filter(function($inbox) use($software) {
+				$domain = parse_url($inbox, PHP_URL_HOST);
+				if(!$domain) {
+					return false;
+				}
+				return InstanceService::software($domain) === strtolower($software);
+			})
+			->unique()
+			->values()
+			->toArray();
+	}
 
-		if(is_string($profile) || is_integer($profile)) {
-			$profile = Profile::whereNull('domain')->find($profile);
+	protected function getAudienceInboxes($pid, $scope = null)
+	{
+		$key = 'pf:services:follow:audience:' . $pid;
+		return Cache::remember($key, 86400, function() use($pid) {
+			$profile = Profile::find($pid);
 			if(!$profile) {
 				return [];
 			}
-
 			return $profile
 				->followers()
 				->whereLocalProfile(false)
@@ -92,9 +82,7 @@ class FollowerService
 				->unique()
 				->values()
 				->toArray();
-		}
-
-		return [];
+		});
 	}
 
 }

+ 12 - 0
app/Services/InstanceService.php

@@ -27,4 +27,16 @@ class InstanceService
 			return Instance::whereAutoCw(true)->pluck('domain')->toArray();
 		});
 	}
+
+	public static function software($domain)
+	{
+		$key = 'instances:software:' . strtolower($domain);
+		return Cache::remember($key, 86400, function() use($domain) {
+			$instance = Instance::whereDomain($domain)->first();
+			if(!$instance) {
+				return;
+			}
+			return $instance->software;
+		});
+	}
 }

+ 5 - 0
app/Services/LikeService.php

@@ -80,4 +80,9 @@ class LikeService {
 
 		return $res;
 	}
+
+	public static function count($id)
+	{
+		return Like::whereStatusId($id)->count();
+	}
 }

+ 7 - 6
app/Services/MediaPathService.php

@@ -8,6 +8,7 @@ use Illuminate\Support\Str;
 use App\Media;
 use App\Profile;
 use App\User;
+use App\Services\HashidService;
 
 class MediaPathService {
 
@@ -51,27 +52,27 @@ class MediaPathService {
 	public static function story($account, $version = 1)
 	{
 		$mh = hash('sha256', date('Y').'-.-'.date('m'));
-		$monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6);
-		$random = '03'.Str::random(random_int(6,9)).'_'.Str::random(random_int(6,17));
+		$monthHash = HashidService::encode(date('Y').date('m'));
+		$random = date('d').Str::random(32);
 
 		if($account instanceOf User) {
 			switch ($version) {
 				case 1:
-					$userHash = $account->profile_id;
+					$userHash = HashidService::encode($account->profile_id);
 					$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
 					break;
 				
 				default:
-					$userHash = $account->profile_id;
+					$userHash = HashidService::encode($account->profile_id);
 					$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
 					break;
 			}
 		} 
 		if($account instanceOf Profile) {
-			$userHash = $account->id;
+			$userHash = HashidService::encode($account->id);
 			$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
 		}
 		return $path;
 	}
 
-}
+}

+ 62 - 0
app/Services/MediaService.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Services;
+
+use Cache;
+use Illuminate\Support\Facades\Redis;
+use App\Media;
+use App\Status;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Transformer\Api\MediaTransformer;
+use App\Util\Media\License;
+
+class MediaService
+{
+	const CACHE_KEY = 'status:transformer:media:attachments:';
+
+	public static function get($statusId)
+	{
+		$status = Status::find($statusId);
+		$ttl = $status->created_at->lt(now()->subMinutes(30)) ? 129600 : 30;
+		return Cache::remember(self::CACHE_KEY.$statusId, $ttl, function() use($status) {
+			if(!$status) {
+				return [];
+			}
+			if(in_array($status->type, ['group:post', 'photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
+				$media = Media::whereStatusId($status->id)->orderBy('order')->get();
+				$fractal = new Fractal\Manager();
+				$fractal->setSerializer(new ArraySerializer());
+				$resource = new Fractal\Resource\Collection($media, new MediaTransformer());
+				return $fractal->createData($resource)->toArray();
+			}
+			return [];
+		});
+	}
+
+	public static function del($statusId)
+	{
+		return Cache::forget(self::CACHE_KEY . $statusId);
+	}
+
+	public static function activitypub($statusId)
+	{
+		$status = self::get($statusId);
+		if(!$status) {
+			return [];
+		}
+
+		return collect($status)->map(function($s) {
+			$license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null;
+			return [
+				'type'      => 'Document',
+				'mediaType' => $s['mime'],
+				'url'       => $s['url'],
+				'name'      => $s['description'],
+				'blurhash'  => $s['blurhash'],
+				'license'   => $license
+			];
+		});
+	}
+}

+ 1 - 1
app/Services/MediaStorageService.php

@@ -70,7 +70,7 @@ class MediaStorageService {
 	protected function localToCloud($media)
 	{
 		$path = storage_path('app/'.$media->media_path);
-        $thumb = storage_path('app/'.$media->thumbnail_path);
+		$thumb = storage_path('app/'.$media->thumbnail_path);
 
 		$p = explode('/', $media->media_path);
 		$name = array_pop($p);

+ 76 - 0
app/Services/NodeinfoService.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Http\Client\RequestException;
+use Illuminate\Http\Client\ConnectionException;
+
+class NodeinfoService
+{
+    public static function get($domain)
+    {
+    	$version = config('pixelfed.version');
+		$appUrl = config('app.url');
+		$headers = [
+			'Accept'     => 'application/json',
+			'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
+		];
+
+        $url = 'https://' . $domain;
+        $wk = $url . '/.well-known/nodeinfo';
+
+        try {
+            $res = Http::withHeaders($headers)
+            ->timeout(5)
+            ->get($wk);
+        } catch (RequestException $e) {
+            return false;
+        } catch (ConnectionException $e) {
+            return false;
+        } catch (\Exception $e) {
+            return false;
+        }
+
+        if(!$res) {
+            return false;
+        }
+
+        $json = $res->json();
+
+        if( !isset($json['links'])) {
+            return false;
+        }
+
+        if(is_array($json['links'])) {
+            if(isset($json['links']['href'])) {
+                $href = $json['links']['href'];
+            } else {
+                $href = $json['links'][0]['href'];
+            }
+        } else {
+            return false;
+        }
+
+        $domain = parse_url($url, PHP_URL_HOST);
+        $hrefDomain = parse_url($href, PHP_URL_HOST);
+
+        if($domain !== $hrefDomain) {
+            return 60;
+        }
+
+        try {
+            $res = Http::withHeaders($headers)
+            ->timeout(5)
+            ->get($href);
+        } catch (RequestException $e) {
+            return false;
+        } catch (ConnectionException $e) {
+            return false;
+        } catch (\Exception $e) {
+            return false;
+        }
+        return $res->json();
+    }
+}

+ 2 - 2
app/Services/NotificationService.php

@@ -46,7 +46,7 @@ class NotificationService {
 		return $ids;
 	}
 
-	public static function getMax($id = false, $start, $limit = 10)
+	public static function getMax($id = false, $start = 0, $limit = 10)
 	{
 		$ids = self::getRankedMaxId($id, $start, $limit);
 
@@ -61,7 +61,7 @@ class NotificationService {
 		return $res->toArray();
 	}
 
-	public static function getMin($id = false, $start, $limit = 10)
+	public static function getMin($id = false, $start = 0, $limit = 10)
 	{
 		$ids = self::getRankedMinId($id, $start, $limit);
 

+ 97 - 0
app/Services/PollService.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Status;
+use Illuminate\Support\Facades\Cache;
+
+class PollService
+{
+	const CACHE_KEY = 'pf:services:poll:status_id:';
+
+	public static function get($id, $profileId = false)
+	{
+		$key = self::CACHE_KEY . $id;
+
+		$res = Cache::remember($key, 1800, function() use($id) {
+			$poll = Poll::whereStatusId($id)->firstOrFail();
+			return [
+				'id' => (string) $poll->id,
+				'expires_at' => $poll->expires_at->format('c'),
+				'expired' => null,
+				'multiple' => $poll->multiple,
+				'votes_count' => $poll->votes_count,
+				'voters_count' => null,
+				'voted' => false,
+				'own_votes' => [],
+				'options' => collect($poll->poll_options)->map(function($option, $key) use($poll) {
+					$tally = $poll->cached_tallies && isset($poll->cached_tallies[$key]) ? $poll->cached_tallies[$key] : 0;
+					return [
+						'title' => $option,
+						'votes_count' => $tally
+					];
+				})->toArray(),
+				'emojis' => []
+			];
+		});
+
+		if($profileId) {
+			$res['voted'] = self::voted($id, $profileId);
+			$res['own_votes'] = self::ownVotes($id, $profileId);
+		}
+
+		return $res;
+	}
+
+	public static function getById($id, $pid)
+	{
+		$poll = Poll::findOrFail($id);
+		return self::get($poll->status_id, $pid);
+	}
+
+	public static function del($id)
+	{
+		Cache::forget(self::CACHE_KEY . $id);
+	}
+
+	public static function voted($id, $profileId = false)
+	{
+		return !$profileId ? false : PollVote::whereStatusId($id)
+			->whereProfileId($profileId)
+			->exists();
+	}
+
+	public static function votedStory($id, $profileId = false)
+	{
+		return !$profileId ? false : PollVote::whereStoryId($id)
+			->whereProfileId($profileId)
+			->exists();
+	}
+
+	public static function storyResults($sid)
+	{
+		$key = self::CACHE_KEY . 'story_poll_results:' . $sid;
+		return Cache::remember($key, 60, function() use($sid) {
+			return Poll::whereStoryId($sid)
+			->firstOrFail()
+			->cached_tallies;
+		});
+	}
+
+	public static function storyChoice($id, $profileId = false)
+	{
+		return !$profileId ? false : PollVote::whereStoryId($id)
+			->whereProfileId($profileId)
+			->pluck('choice')
+			->first();
+	}
+
+	public static function ownVotes($id, $profileId = false)
+	{
+		return !$profileId ? [] : PollVote::whereStatusId($id)
+			->whereProfileId($profileId)
+			->pluck('choice') ?? [];
+	}
+}

+ 7 - 23
app/Services/ProfileService.php

@@ -2,31 +2,15 @@
 
 namespace App\Services;
 
-use Cache;
-use Illuminate\Support\Facades\Redis;
-use App\Transformer\Api\AccountTransformer;
-use League\Fractal;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Profile;
-
-class ProfileService {
-
+class ProfileService
+{
 	public static function get($id)
 	{
-		$key = 'profile:model:' . $id;
-		$ttl = now()->addHours(4);
-		$res = Cache::remember($key, $ttl, function() use($id) {
-			$profile = Profile::find($id);
-			if(!$profile) {
-				return false;
-			}
-			$fractal = new Fractal\Manager();
-			$fractal->setSerializer(new ArraySerializer());
-			$resource = new Fractal\Resource\Item($profile, new AccountTransformer());
-			return $fractal->createData($resource)->toArray();
-		});
-		return $res;
+		return AccountService::get($id);
 	}
 
+	public static function del($id)
+	{
+		return AccountService::del($id);
+	}
 }

+ 32 - 4
app/Services/SnowflakeService.php

@@ -3,16 +3,44 @@
 namespace App\Services;
 
 use Illuminate\Support\Carbon;
+use Cache;
 
 class SnowflakeService {
 
 	public static function byDate(Carbon $ts = null)
 	{
-		$ts = $ts ? now()->parse($ts)->timestamp : microtime(true);
+		if($ts instanceOf Carbon) {
+			$ts = now()->parse($ts)->timestamp;
+		} else {
+			return self::next();
+		}
+
 		return ((round($ts * 1000) - 1549756800000) << 22)
-		| (1 << 17)
-		| (1 << 12)
+		| (random_int(1,31) << 17)
+		| (random_int(1,31) << 12)
 		| 0;
 	}
 
-}
+	public static function next()
+	{
+		$seq = Cache::get('snowflake:seq');
+
+		if(!$seq) {
+			Cache::put('snowflake:seq', 1);
+			$seq = 1;
+		} else {
+			Cache::increment('snowflake:seq');
+		}
+
+		if($seq >= 4095) {
+			Cache::put('snowflake:seq', 0);
+			$seq = 0;
+		}
+
+		return ((round(microtime(true) * 1000) - 1549756800000) << 22)
+		| (random_int(1,31) << 17)
+		| (random_int(1,31) << 12)
+		| $seq;
+	}
+
+}

+ 18 - 0
app/Services/StatusHashtagService.php

@@ -6,6 +6,7 @@ use Cache;
 use Illuminate\Support\Facades\Redis;
 use App\{Status, StatusHashtag};
 use App\Transformer\Api\StatusHashtagTransformer;
+use App\Transformer\Api\HashtagTransformer;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
@@ -78,4 +79,21 @@ class StatusHashtagService {
 	{
 		return ['status' => StatusService::get($statusId)];
 	}
+
+	public static function statusTags($statusId)
+	{
+		$key = 'pf:services:sh:id:' . $statusId;
+
+		return Cache::remember($key, 604800, function() use($statusId) {
+			$status = Status::find($statusId);
+			if(!$status) {
+				return [];
+			}
+
+			$fractal = new Fractal\Manager();
+			$fractal->setSerializer(new ArraySerializer());
+			$resource = new Fractal\Resource\Collection($status->hashtags, new HashtagTransformer());
+			return $fractal->createData($resource)->toArray();
+		});
+	}
 }

+ 20 - 5
app/Services/StatusService.php

@@ -16,15 +16,20 @@ class StatusService {
 
 	const CACHE_KEY = 'pf:services:status:';
 
-	public static function key($id)
+	public static function key($id, $publicOnly = true)
 	{
-		return self::CACHE_KEY . $id;
+		$p = $publicOnly ? '' : 'all:';
+		return self::CACHE_KEY . $p . $id;
 	}
 
-	public static function get($id)
+	public static function get($id, $publicOnly = true)
 	{
-		return Cache::remember(self::key($id), now()->addDays(7), function() use($id) {
-			$status = Status::whereScope('public')->find($id);
+		return Cache::remember(self::key($id, $publicOnly), now()->addDays(7), function() use($id, $publicOnly) {
+			if($publicOnly) {
+				$status = Status::whereScope('public')->find($id);
+			} else {
+				$status = Status::whereIn('scope', ['public', 'private', 'unlisted', 'group'])->find($id);
+			}
 			if(!$status) {
 				return null;
 			}
@@ -37,7 +42,17 @@ class StatusService {
 
 	public static function del($id)
 	{
+		$status = self::get($id);
+		if($status && isset($status['account']) && isset($status['account']['id'])) {
+			Cache::forget('profile:embed:' . $status['account']['id']);
+		}
+		Cache::forget('status:transformer:media:attachments:' . $id);
+		MediaService::del($id);
+		Cache::forget('status:thumb:nsfw0' . $id);
+		Cache::forget('status:thumb:nsfw1' . $id);
+		Cache::forget('pf:services:sh:id:' . $id);
 		PublicTimelineService::rem($id);
+		Cache::forget(self::key($id, false));
 		return Cache::forget(self::key($id));
 	}
 }

+ 162 - 0
app/Services/StoryService.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Storage;
+use App\Story;
+use App\StoryView;
+
+class StoryService
+{
+	const STORY_KEY = 'pf:services:stories:v1:';
+
+	public static function get($id) 
+	{
+		$account = AccountService::get($id);
+		if(!$account) {
+			return false;
+		}
+
+		$res = [
+			'profile' => [
+				'id' => (string) $account['id'],
+				'avatar' => $account['avatar'],
+				'username' => $account['username'],
+				'url' => $account['url']
+			]
+		];
+
+		$res['stories'] = self::getStories($id);
+		return $res;
+	}
+
+	public static function getById($id)
+	{
+		return Cache::remember(self::STORY_KEY . 'by-id:id-' . $id, 3600, function() use ($id) {
+			return Story::find($id);
+		});
+	}
+
+	public static function delById($id)
+	{
+		return Cache::forget(self::STORY_KEY . 'by-id:id-' . $id);
+	}
+
+	public static function getStories($id, $pid)
+	{
+		return Story::whereProfileId($id)
+			->latest()
+			->get()
+			->map(function($s) use($pid) {
+				return [
+					'id' => (string) $s->id,
+					'type' => $s->type,
+					'duration' => 10,
+					'seen' => in_array($pid, self::views($s->id)),
+					'created_at' => $s->created_at->toAtomString(),
+					'expires_at' => $s->expires_at->toAtomString(),
+					'media' => url(Storage::url($s->path)),
+					'can_reply' => (bool) $s->can_reply,
+					'can_react' => (bool) $s->can_react,
+					'poll' => $s->type == 'poll' ? PollService::storyPoll($s->id) : null
+				];
+			})
+			->toArray();
+	}
+
+	public static function views($id)
+	{
+		return StoryView::whereStoryId($id)
+			->pluck('profile_id')
+			->toArray();
+	}
+
+	public static function hasSeen($pid, $sid)
+	{
+		$key = self::STORY_KEY . 'seen:' . $pid . ':' . $sid;
+		return Cache::remember($key, 3600, function() use($pid, $sid) {
+			return StoryView::whereStoryId($sid)
+			->whereProfileId($pid)
+			->exists();
+		});
+	}
+
+	public static function latest($pid)
+	{
+		return Cache::remember(self::STORY_KEY . 'latest:pid-' . $pid, 3600, function() use ($pid) {
+			return Story::whereProfileId($pid)
+				->latest()
+				->first()
+				->id;
+		});
+	}
+
+	public static function delLatest($pid)
+	{
+		return Cache::forget(self::STORY_KEY . 'latest:pid-' . $pid);
+	}
+
+	public static function addSeen($pid, $sid)
+	{
+		return Cache::put(self::STORY_KEY . 'seen:' . $pid . ':' . $sid, true, 86400);
+	}
+
+	public static function adminStats()
+	{
+		return Cache::remember('pf:admin:stories:stats', 300, function() {
+			$total = Story::count();
+			return [
+				'active' => [
+					'today' => Story::whereDate('created_at', now()->today())->count(),
+					'month' => Story::whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count()
+				],
+				'total' => $total,
+				'remote' => [
+					'today' => Story::whereLocal(false)->whereDate('created_at', now()->today())->count(),
+					'month' => Story::whereLocal(false)->whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count()
+				],
+				'storage' => [
+					'sum' => (int) Story::sum('size'),
+					'average' => (int) Story::avg('size')
+				],
+				'avg_spu' => (int) ($total / Story::groupBy('profile_id')->pluck('profile_id')->count()),
+				'avg_duration' => (int) floor(Story::avg('duration')),
+				'avg_type' => Story::selectRaw('type, count(id) as count')->groupBy('type')->orderByDesc('count')->first()->type
+			];
+		});
+	}
+
+	public static function rotateQueue()
+	{
+		return Redis::smembers('pf:stories:rotate-queue');
+	}
+
+	public static function addRotateQueue($id)
+	{
+		return Redis::sadd('pf:stories:rotate-queue', $id);
+	}
+
+	public static function removeRotateQueue($id)
+	{
+		self::delById($id);
+		return Redis::srem('pf:stories:rotate-queue', $id);
+	}
+
+	public static function reactIncrement($storyId, $profileId)
+	{
+		$key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId;
+		if(Redis::get($key) == null) {
+			Redis::setex($key, 86400, 1);
+		} else {
+			return Redis::incr($key);
+		}
+	}
+
+	public static function reactCounter($storyId, $profileId)
+	{
+		$key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId;
+		return (int) Redis::get($key) ?? 0;
+	}
+}

+ 410 - 405
app/Status.php

@@ -4,414 +4,419 @@ namespace App;
 
 use Auth, Cache, Hashids, Storage;
 use Illuminate\Database\Eloquent\Model;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
+use App\HasSnowflakePrimary;
 use App\Http\Controllers\StatusController;
 use Illuminate\Database\Eloquent\SoftDeletes;
+use App\Models\Poll;
 
 class Status extends Model
 {
-    use HasSnowflakePrimary, SoftDeletes;
-
-    /**
-     * Indicates if the IDs are auto-incrementing.
-     *
-     * @var bool
-     */
-    public $incrementing = false;
-
-    /**
-     * The attributes that should be mutated to dates.
-     *
-     * @var array
-     */
-    protected $dates = ['deleted_at'];
-
-    protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type'];
-
-    const STATUS_TYPES = [
-        'text',
-        'photo',
-        'photo:album',
-        'video',
-        'video:album',
-        'photo:video:album',
-        'share',
-        'reply',
-        'story',
-        'story:reply',
-        'story:reaction',
-        'story:live',
-        'loop'
-    ];
-
-    const MAX_MENTIONS = 5;
-
-    const MAX_HASHTAGS = 30;
-
-    const MAX_LINKS = 0;
-
-    public function profile()
-    {
-        return $this->belongsTo(Profile::class);
-    }
-
-    public function media()
-    {
-        return $this->hasMany(Media::class);
-    }
-
-    public function firstMedia()
-    {
-        return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
-    }
-
-    public function viewType()
-    {
-        if($this->type) {
-            return $this->type;
-        }
-        return $this->setType();
-    }
-
-    public function setType()
-    {
-        if(in_array($this->type, self::STATUS_TYPES)) {
-            return $this->type;
-        }
-        $mimes = $this->media->pluck('mime')->toArray();
-        $type = StatusController::mimeTypeCheck($mimes);
-        if($type) {
-            $this->type = $type;
-            $this->save();
-            return $type;
-        }
-    }
-
-    public function thumb($showNsfw = false)
-    {
-        $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'])) {
-                return url(Storage::url('public/no-preview.png'));
-            }
-
-            return url(Storage::url($this->firstMedia()->thumbnail_path));
-        });
-    }
-
-    public function url()
-    {
-        if($this->uri) {
-            return $this->uri;
-        } else {
-            $id = $this->id;
-            $username = $this->profile->username;
-            $path = url(config('app.url')."/p/{$username}/{$id}");
-            return $path;
-        }
-    }
-
-    public function permalink($suffix = '/activity')
-    {
-        $id = $this->id;
-        $username = $this->profile->username;
-        $path = config('app.url')."/p/{$username}/{$id}{$suffix}";
-
-        return url($path);
-    }
-
-    public function editUrl()
-    {
-        return $this->url().'/edit';
-    }
-
-    public function mediaUrl()
-    {
-        $media = $this->firstMedia();
-        $path = $media->media_path;
-        $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
-        $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
-
-        return $url;
-    }
-
-    public function likes()
-    {
-        return $this->hasMany(Like::class);
-    }
-
-    public function liked() : bool
-    {
-        if(!Auth::check()) {
-            return false;
-        }
-
-        $pid = Auth::user()->profile_id;
-
-        return Like::select('status_id', 'profile_id')
-            ->whereStatusId($this->id)
-            ->whereProfileId($pid)
-            ->exists();
-    }
-
-    public function likedBy()
-    {
-        return $this->hasManyThrough(
-            Profile::class,
-            Like::class,
-            'status_id',
-            'id',
-            'id',
-            'profile_id'
-        );
-    }
-
-    public function comments()
-    {
-        return $this->hasMany(self::class, 'in_reply_to_id');
-    }
-
-    public function bookmarked()
-    {
-        if (!Auth::check()) {
-            return false;
-        }
-        $profile = Auth::user()->profile;
-
-        return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
-    }
-
-    public function shares()
-    {
-        return $this->hasMany(self::class, 'reblog_of_id');
-    }
-
-    public function shared() : bool
-    {
-        if(!Auth::check()) {
-            return false;
-        }
-        $pid = Auth::user()->profile_id;
-
-        return $this->select('profile_id', 'reblog_of_id')
-            ->whereProfileId($pid)
-            ->whereReblogOfId($this->id)
-            ->exists();
-    }
-
-    public function sharedBy()
-    {
-        return $this->hasManyThrough(
-            Profile::class,
-            Status::class,
-            'reblog_of_id',
-            'id',
-            'id',
-            'profile_id'
-        );
-    }
-
-    public function parent()
-    {
-        $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
-        if (!empty($parent)) {
-            return $this->findOrFail($parent);
-        } else {
-            return false;
-        }
-    }
-
-    public function conversation()
-    {
-        return $this->hasOne(Conversation::class);
-    }
-
-    public function hashtags()
-    {
-        return $this->hasManyThrough(
-        Hashtag::class,
-        StatusHashtag::class,
-        'status_id',
-        'id',
-        'id',
-        'hashtag_id'
-      );
-    }
-
-    public function mentions()
-    {
-        return $this->hasManyThrough(
-        Profile::class,
-        Mention::class,
-        'status_id',
-        'id',
-        'id',
-        'profile_id'
-      );
-    }
-
-    public function reportUrl()
-    {
-        return route('report.form')."?type=post&id={$this->id}";
-    }
-
-    public function toActivityStream()
-    {
-        $media = $this->media;
-        $mediaCollection = [];
-        foreach ($media as $image) {
-            $mediaCollection[] = [
-          'type'      => 'Link',
-          'href'      => $image->url(),
-          'mediaType' => $image->mime,
-        ];
-        }
-        $obj = [
-        '@context' => 'https://www.w3.org/ns/activitystreams',
-        'type'     => 'Image',
-        'name'     => null,
-        'url'      => $mediaCollection,
-      ];
-
-        return $obj;
-    }
-
-    public function replyToText()
-    {
-        $actorName = $this->profile->username;
-
-        return "{$actorName} ".__('notification.commented');
-    }
-
-    public function replyToHtml()
-    {
-        $actorName = $this->profile->username;
-        $actorUrl = $this->profile->url();
-
-        return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
-          __('notification.commented');
-    }
-
-    public function shareToText()
-    {
-        $actorName = $this->profile->username;
-
-        return "{$actorName} ".__('notification.shared');
-    }
-
-    public function shareToHtml()
-    {
-        $actorName = $this->profile->username;
-        $actorUrl = $this->profile->url();
-
-        return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
-          __('notification.shared');
-    }
-
-    public function recentComments()
-    {
-        return $this->comments()->orderBy('created_at', 'desc')->take(3);
-    }
-
-    public function toActivityPubObject()
-    {
-        if($this->local == false) {
-            return;
-        }
-        $profile = $this->profile;
-        $to = $this->scopeToAudience('to');
-        $cc = $this->scopeToAudience('cc');
-        return [
-            '@context' => 'https://www.w3.org/ns/activitystreams',
-            'id'    => $this->permalink(),
-            'type'  => 'Create',
-            'actor' => $profile->permalink(),
-            'published' => $this->created_at->format('c'),
-            'to' => $to,
-            'cc' => $cc,
-            'object' => [
-                'id' => $this->url(),
-                'type' => 'Note',
-                'summary' => null,
-                'inReplyTo' => null,
-                'published' => $this->created_at->format('c'),
-                'url' => $this->url(),
-                'attributedTo' => $this->profile->url(),
-                'to' => $to,
-                'cc' => $cc,
-                'sensitive' => (bool) $this->is_nsfw,
-                'content' => $this->rendered,
-                'attachment' => $this->media->map(function($media) {
-                    return [
-                        'type' => 'Document',
-                        'mediaType' => $media->mime,
-                        'url' => $media->url(),
-                        'name' => null
-                    ];
-                })->toArray()
-            ]
-        ];
-    }
-
-    public function scopeToAudience($audience)
-    {
-        if(!in_array($audience, ['to', 'cc']) || $this->local == false) { 
-            return;
-        }
-        $res = [];
-        $res['to'] = [];
-        $res['cc'] = [];
-        $scope = $this->scope;
-        $mentions = $this->mentions->map(function ($mention) {
-            return $mention->permalink();
-        })->toArray();
-
-        if($this->in_reply_to_id != null) {
-            $parent = $this->parent();
-            if($parent) {
-                $mentions = array_merge([$parent->profile->permalink()], $mentions);
-            }
-        }
-
-        switch ($scope) {
-            case 'public':
-                $res['to'] = [
-                    "https://www.w3.org/ns/activitystreams#Public"
-                ];
-                $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-                break;
-
-            case 'unlisted':
-                $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-                $res['cc'] = [
-                    "https://www.w3.org/ns/activitystreams#Public"
-                ];
-                break;
-
-            case 'private':
-                $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-                $res['cc'] = [];
-                break;
-
-            // TODO: Update scope when DMs are supported
-            case 'direct':
-                $res['to'] = [];
-                $res['cc'] = [];
-                break;
-        }
-        return $res[$audience];
-    }
-
-    public function place()
-    {
-        return $this->belongsTo(Place::class);
-    }
-
-    public function directMessage()
-    {
-        return $this->hasOne(DirectMessage::class);
-    }
-
+	use HasSnowflakePrimary, SoftDeletes;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+	/**
+	 * The attributes that should be mutated to dates.
+	 *
+	 * @var array
+	 */
+	protected $dates = ['deleted_at'];
+
+	protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type'];
+
+	const STATUS_TYPES = [
+		'text',
+		'photo',
+		'photo:album',
+		'video',
+		'video:album',
+		'photo:video:album',
+		'share',
+		'reply',
+		'story',
+		'story:reply',
+		'story:reaction',
+		'story:live',
+		'loop'
+	];
+
+	const MAX_MENTIONS = 5;
+
+	const MAX_HASHTAGS = 30;
+
+	const MAX_LINKS = 2;
+
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
+
+	public function media()
+	{
+		return $this->hasMany(Media::class);
+	}
+
+	public function firstMedia()
+	{
+		return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
+	}
+
+	public function viewType()
+	{
+		if($this->type) {
+			return $this->type;
+		}
+		return $this->setType();
+	}
+
+	public function setType()
+	{
+		if(in_array($this->type, self::STATUS_TYPES)) {
+			return $this->type;
+		}
+		$mimes = $this->media->pluck('mime')->toArray();
+		$type = StatusController::mimeTypeCheck($mimes);
+		if($type) {
+			$this->type = $type;
+			$this->save();
+			return $type;
+		}
+	}
+
+	public function thumb($showNsfw = false)
+	{
+		$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'])) {
+				return url(Storage::url('public/no-preview.png'));
+			}
+
+			return url(Storage::url($this->firstMedia()->thumbnail_path));
+		});
+	}
+
+	public function url($forceLocal = false)
+	{
+		if($this->uri) {
+			return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri;
+		} else {
+			$id = $this->id;
+			$username = $this->profile->username;
+			$path = url(config('app.url')."/p/{$username}/{$id}");
+			return $path;
+		}
+	}
+
+	public function permalink($suffix = '/activity')
+	{
+		$id = $this->id;
+		$username = $this->profile->username;
+		$path = config('app.url')."/p/{$username}/{$id}{$suffix}";
+
+		return url($path);
+	}
+
+	public function editUrl()
+	{
+		return $this->url().'/edit';
+	}
+
+	public function mediaUrl()
+	{
+		$media = $this->firstMedia();
+		$path = $media->media_path;
+		$hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
+		$url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
+
+		return $url;
+	}
+
+	public function likes()
+	{
+		return $this->hasMany(Like::class);
+	}
+
+	public function liked() : bool
+	{
+		if(!Auth::check()) {
+			return false;
+		}
+
+		$pid = Auth::user()->profile_id;
+
+		return Like::select('status_id', 'profile_id')
+			->whereStatusId($this->id)
+			->whereProfileId($pid)
+			->exists();
+	}
+
+	public function likedBy()
+	{
+		return $this->hasManyThrough(
+			Profile::class,
+			Like::class,
+			'status_id',
+			'id',
+			'id',
+			'profile_id'
+		);
+	}
+
+	public function comments()
+	{
+		return $this->hasMany(self::class, 'in_reply_to_id');
+	}
+
+	public function bookmarked()
+	{
+		if (!Auth::check()) {
+			return false;
+		}
+		$profile = Auth::user()->profile;
+
+		return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+	}
+
+	public function shares()
+	{
+		return $this->hasMany(self::class, 'reblog_of_id');
+	}
+
+	public function shared() : bool
+	{
+		if(!Auth::check()) {
+			return false;
+		}
+		$pid = Auth::user()->profile_id;
+
+		return $this->select('profile_id', 'reblog_of_id')
+			->whereProfileId($pid)
+			->whereReblogOfId($this->id)
+			->exists();
+	}
+
+	public function sharedBy()
+	{
+		return $this->hasManyThrough(
+			Profile::class,
+			Status::class,
+			'reblog_of_id',
+			'id',
+			'id',
+			'profile_id'
+		);
+	}
+
+	public function parent()
+	{
+		$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
+		if (!empty($parent)) {
+			return $this->findOrFail($parent);
+		} else {
+			return false;
+		}
+	}
+
+	public function conversation()
+	{
+		return $this->hasOne(Conversation::class);
+	}
+
+	public function hashtags()
+	{
+		return $this->hasManyThrough(
+		Hashtag::class,
+		StatusHashtag::class,
+		'status_id',
+		'id',
+		'id',
+		'hashtag_id'
+	  );
+	}
+
+	public function mentions()
+	{
+		return $this->hasManyThrough(
+		Profile::class,
+		Mention::class,
+		'status_id',
+		'id',
+		'id',
+		'profile_id'
+	  );
+	}
+
+	public function reportUrl()
+	{
+		return route('report.form')."?type=post&id={$this->id}";
+	}
+
+	public function toActivityStream()
+	{
+		$media = $this->media;
+		$mediaCollection = [];
+		foreach ($media as $image) {
+			$mediaCollection[] = [
+		  'type'      => 'Link',
+		  'href'      => $image->url(),
+		  'mediaType' => $image->mime,
+		];
+		}
+		$obj = [
+		'@context' => 'https://www.w3.org/ns/activitystreams',
+		'type'     => 'Image',
+		'name'     => null,
+		'url'      => $mediaCollection,
+	  ];
+
+		return $obj;
+	}
+
+	public function replyToText()
+	{
+		$actorName = $this->profile->username;
+
+		return "{$actorName} ".__('notification.commented');
+	}
+
+	public function replyToHtml()
+	{
+		$actorName = $this->profile->username;
+		$actorUrl = $this->profile->url();
+
+		return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
+		  __('notification.commented');
+	}
+
+	public function shareToText()
+	{
+		$actorName = $this->profile->username;
+
+		return "{$actorName} ".__('notification.shared');
+	}
+
+	public function shareToHtml()
+	{
+		$actorName = $this->profile->username;
+		$actorUrl = $this->profile->url();
+
+		return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
+		  __('notification.shared');
+	}
+
+	public function recentComments()
+	{
+		return $this->comments()->orderBy('created_at', 'desc')->take(3);
+	}
+
+	public function toActivityPubObject()
+	{
+		if($this->local == false) {
+			return;
+		}
+		$profile = $this->profile;
+		$to = $this->scopeToAudience('to');
+		$cc = $this->scopeToAudience('cc');
+		return [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id'    => $this->permalink(),
+			'type'  => 'Create',
+			'actor' => $profile->permalink(),
+			'published' => $this->created_at->format('c'),
+			'to' => $to,
+			'cc' => $cc,
+			'object' => [
+				'id' => $this->url(),
+				'type' => 'Note',
+				'summary' => null,
+				'inReplyTo' => null,
+				'published' => $this->created_at->format('c'),
+				'url' => $this->url(),
+				'attributedTo' => $this->profile->url(),
+				'to' => $to,
+				'cc' => $cc,
+				'sensitive' => (bool) $this->is_nsfw,
+				'content' => $this->rendered,
+				'attachment' => $this->media->map(function($media) {
+					return [
+						'type' => 'Document',
+						'mediaType' => $media->mime,
+						'url' => $media->url(),
+						'name' => null
+					];
+				})->toArray()
+			]
+		];
+	}
+
+	public function scopeToAudience($audience)
+	{
+		if(!in_array($audience, ['to', 'cc']) || $this->local == false) { 
+			return;
+		}
+		$res = [];
+		$res['to'] = [];
+		$res['cc'] = [];
+		$scope = $this->scope;
+		$mentions = $this->mentions->map(function ($mention) {
+			return $mention->permalink();
+		})->toArray();
+
+		if($this->in_reply_to_id != null) {
+			$parent = $this->parent();
+			if($parent) {
+				$mentions = array_merge([$parent->profile->permalink()], $mentions);
+			}
+		}
+
+		switch ($scope) {
+			case 'public':
+				$res['to'] = [
+					"https://www.w3.org/ns/activitystreams#Public"
+				];
+				$res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+				break;
+
+			case 'unlisted':
+				$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+				$res['cc'] = [
+					"https://www.w3.org/ns/activitystreams#Public"
+				];
+				break;
+
+			case 'private':
+				$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+				$res['cc'] = [];
+				break;
+
+			// TODO: Update scope when DMs are supported
+			case 'direct':
+				$res['to'] = [];
+				$res['cc'] = [];
+				break;
+		}
+		return $res[$audience];
+	}
+
+	public function place()
+	{
+		return $this->belongsTo(Place::class);
+	}
+
+	public function directMessage()
+	{
+		return $this->hasOne(DirectMessage::class);
+	}
+
+	public function poll()
+	{
+		return $this->hasOne(Poll::class);
+	}
 }

+ 44 - 9
app/Story.php

@@ -3,8 +3,10 @@
 namespace App;
 
 use Auth;
+use Storage;
 use Illuminate\Database\Eloquent\Model;
-use Pixelfed\Snowflake\HasSnowflakePrimary;
+use App\HasSnowflakePrimary;
+use App\Util\Lexer\Bearcap;
 
 class Story extends Model
 {
@@ -19,14 +21,11 @@ class Story extends Model
      */
     public $incrementing = false;
 
-    /**
-     * The attributes that should be mutated to dates.
-     *
-     * @var array
-     */
-    protected $dates = ['published_at', 'expires_at'];
+    protected $casts = [
+    	'expires_at' => 'datetime'
+    ];
 
-    protected $fillable = ['profile_id'];
+    protected $fillable = ['profile_id', 'view_count'];
 
 	protected $visible = ['id'];
 
@@ -51,6 +50,42 @@ class Story extends Model
 
 	public function permalink()
 	{
-		return url("/story/$this->id");
+		$username = $this->profile->username;
+		return url("/stories/{$username}/{$this->id}/activity");
+	}
+
+	public function url()
+	{
+		$username = $this->profile->username;
+		return url("/stories/{$username}/{$this->id}");
+	}
+
+	public function mediaUrl()
+	{
+		return url(Storage::url($this->path));
+	}
+
+	public function bearcapUrl()
+	{
+		return Bearcap::encode($this->url(), $this->bearcap_token);
+	}
+
+	public function scopeToAudience($scope)
+	{
+		$res = [];
+
+		switch ($scope) {
+			case 'to':
+				$res = [
+					$this->profile->permalink('/followers')
+				];
+				break;
+
+			default:
+				$res = [];
+				break;
+		}
+
+		return $res;
 	}
 }

+ 2 - 9
app/Transformer/ActivityPub/StatusTransformer.php

@@ -4,6 +4,7 @@ namespace App\Transformer\ActivityPub;
 
 use App\Status;
 use League\Fractal;
+use App\Services\MediaService;
 
 class StatusTransformer extends Fractal\TransformerAbstract
 {
@@ -45,15 +46,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
           'sensitive'        => (bool) $status->is_nsfw,
           'atomUri'          => $status->url(),
           'inReplyToAtomUri' => null,
-          'attachment'       => $status->media->map(function ($media) {
-              return [
-              'type'      => 'Document',
-              'mediaType' => $media->mime,
-              'url'       => $media->url(),
-              'name'      => $media->caption,
-              'blurhash'  => $media->blurhash
-            ];
-          }),
+          'attachment'       => MediaService::activitypub($status->id),
           'tag' => [],
           'location' => $status->place_id ? [
               'type' => 'Place',

+ 46 - 0
app/Transformer/ActivityPub/Verb/CreateQuestion.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class CreateQuestion extends Fractal\TransformerAbstract
+{
+	protected $defaultIncludes = [
+        'object',
+    ];
+
+	public function transform(Status $status)
+	{
+		return [
+			'@context' => [
+				'https://www.w3.org/ns/activitystreams',
+				'https://w3id.org/security/v1',
+				[
+					'sc'				=> 'http://schema.org#',
+					'Hashtag' 			=> 'as:Hashtag',
+					'sensitive' 		=> 'as:sensitive',
+					'commentsEnabled' 	=> 'sc:Boolean',
+					'capabilities'		=> [
+						'announce'		=> ['@type' => '@id'],
+						'like'			=> ['@type' => '@id'],
+						'reply'			=> ['@type' => '@id']
+					]
+				]
+			],
+			'id' 					=> $status->permalink(),
+			'type' 					=> 'Create',
+			'actor' 				=> $status->profile->permalink(),
+			'published' 			=> $status->created_at->toAtomString(),
+			'to' 					=> $status->scopeToAudience('to'),
+			'cc' 					=> $status->scopeToAudience('cc'),
+		];
+	}
+
+	public function includeObject(Status $status)
+	{
+		return $this->item($status, new Question());
+	}
+}

+ 29 - 0
app/Transformer/ActivityPub/Verb/CreateStory.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use Storage;
+use App\Story;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class CreateStory extends Fractal\TransformerAbstract
+{
+	public function transform(Story $story)
+	{
+		return [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $story->permalink(),
+			'type' => 'Add',
+			'actor' => $story->profile->permalink(),
+			'to' => [
+				$story->profile->permalink('/followers')
+			],
+			'object' => [
+				'id' => $story->url(),
+				'type' => 'Story',
+				'object' => $story->bearcapUrl(),
+			]
+		];
+	}
+}

+ 25 - 0
app/Transformer/ActivityPub/Verb/DeleteStory.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use Storage;
+use App\Story;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class DeleteStory extends Fractal\TransformerAbstract
+{
+	public function transform(Story $story)
+	{
+		return [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $story->url() . '#delete',
+			'type' => 'Delete',
+			'actor' => $story->profile->permalink(),
+			'object' => [
+				'id' => $story->url(),
+				'type' => 'Story',
+			],
+		];
+	}
+}

+ 89 - 0
app/Transformer/ActivityPub/Verb/Question.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class Question extends Fractal\TransformerAbstract
+{
+	public function transform(Status $status)
+	{
+		$mentions = $status->mentions->map(function ($mention) {
+			$webfinger = $mention->emailUrl();
+			$name = Str::startsWith($webfinger, '@') ?
+				$webfinger :
+				'@' . $webfinger;
+			return [
+				'type' => 'Mention',
+				'href' => $mention->permalink(),
+				'name' => $name
+			];
+		})->toArray();
+
+		$hashtags = $status->hashtags->map(function ($hashtag) {
+			return [
+				'type' => 'Hashtag',
+				'href' => $hashtag->url(),
+				'name' => "#{$hashtag->name}",
+			];
+		})->toArray();
+		$tags = array_merge($mentions, $hashtags);
+
+		return [
+				'@context' => [
+					'https://www.w3.org/ns/activitystreams',
+					'https://w3id.org/security/v1',
+					[
+						'sc'				=> 'http://schema.org#',
+						'Hashtag' 			=> 'as:Hashtag',
+						'sensitive' 		=> 'as:sensitive',
+						'commentsEnabled' 	=> 'sc:Boolean',
+						'capabilities'		=> [
+							'announce'		=> ['@type' => '@id'],
+							'like'			=> ['@type' => '@id'],
+							'reply'			=> ['@type' => '@id']
+						]
+					]
+				],
+				'id' 				=> $status->url(),
+				'type' 				=> 'Question',
+				'summary'   		=> null,
+				'content'   		=> $status->rendered ?? $status->caption,
+				'inReplyTo' 		=> $status->in_reply_to_id ? $status->parent()->url() : null,
+				'published'    		=> $status->created_at->toAtomString(),
+				'url'          		=> $status->url(),
+				'attributedTo' 		=> $status->profile->permalink(),
+				'to'           		=> $status->scopeToAudience('to'),
+				'cc' 				=> $status->scopeToAudience('cc'),
+				'sensitive'       	=> (bool) $status->is_nsfw,
+				'attachment'      	=> [],
+				'tag' 				=> $tags,
+				'commentsEnabled'  => (bool) !$status->comments_disabled,
+				'capabilities' => [
+					'announce' => 'https://www.w3.org/ns/activitystreams#Public',
+					'like' => 'https://www.w3.org/ns/activitystreams#Public',
+					'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public'
+				],
+				'location' => $status->place_id ? [
+						'type' => 'Place',
+						'name' => $status->place->name,
+						'longitude' => $status->place->long,
+						'latitude' => $status->place->lat,
+						'country' => $status->place->country
+					] : null,
+				'endTime' => $status->poll->expires_at->toAtomString(),
+				'oneOf' => collect($status->poll->poll_options)->map(function($option, $index) use($status) {
+					return [
+						'type' => 'Note',
+						'name' => $option,
+						'replies' => [
+							'type' => 'Collection',
+							'totalItems' => $status->poll->cached_tallies[$index]
+						]
+					];
+				})
+			];
+	}
+}

+ 39 - 0
app/Transformer/ActivityPub/Verb/StoryVerb.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use Storage;
+use App\Story;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class StoryVerb extends Fractal\TransformerAbstract
+{
+	public function transform(Story $story)
+	{
+		$type = $story->type == 'photo' ? 'Image' :
+			( $story->type == 'video' ? 'Video' :
+			'Document' );
+
+		return [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $story->url(),
+			'type' => 'Story',
+			'to' => [
+				$story->profile->permalink('/followers')
+			],
+			'cc' => [],
+			'attributedTo' => $story->profile->permalink(),
+			'published' => $story->created_at->toAtomString(),
+			'expiresAt' => $story->expires_at->toAtomString(),
+			'duration' => $story->duration,
+			'can_reply' => (bool) $story->can_reply,
+			'can_react' => (bool) $story->can_react,
+			'attachment' => [
+				'type' => $type,
+				'url' => url(Storage::url($story->path)),
+				'mediaType' => $story->mime,
+			],
+		];
+	}
+}

+ 41 - 75
app/Transformer/Api/Mastodon/v1/StatusTransformer.php

@@ -5,81 +5,47 @@ namespace App\Transformer\Api\Mastodon\v1;
 use App\Status;
 use League\Fractal;
 use Cache;
+use App\Services\MediaService;
+use App\Services\ProfileService;
+use App\Services\StatusHashtagService;
 
 class StatusTransformer extends Fractal\TransformerAbstract
 {
-    protected $defaultIncludes = [
-        'account',
-        'media_attachments',
-        'mentions',
-        'tags',
-    ];
-
-    public function transform(Status $status)
-    {
-        return [
-            'id'                        => (string) $status->id,
-            'created_at'                => $status->created_at->toJSON(),
-            'in_reply_to_id'            => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null,
-            'in_reply_to_account_id'    => $status->in_reply_to_profile_id,
-            'sensitive'                 => (bool) $status->is_nsfw,
-            'spoiler_text'              => $status->cw_summary ?? '',
-            'visibility'                => $status->visibility ?? $status->scope,
-            'language'                  => 'en',
-            'uri'                       => $status->url(),
-            'url'                       => $status->url(),
-            'replies_count'             => 0,
-            'reblogs_count'             => $status->reblogs_count ?? 0,
-            'favourites_count'          => $status->likes_count ?? 0,
-            'reblogged'                 => $status->shared(),
-            'favourited'                => $status->liked(),
-            'muted'                     => false,
-            'bookmarked'                => false,
-            'pinned'                    => false,
-            'content'                   => $status->rendered ?? $status->caption ?? '',
-            'reblog'                    => null,
-            'application'               => [
-                'name'      => 'web',
-                'website'   => null
-             ],
-            'mentions'                  => [],
-            'tags'                      => [],
-            'emojis'                    => [],
-            'card'                      => null,
-            'poll'                      => null,
-        ];
-    }
-
-    public function includeAccount(Status $status)
-    {
-        $account = $status->profile;
-
-        return $this->item($account, new AccountTransformer());
-    }
-
-    public function includeMediaAttachments(Status $status)
-    {
-        return Cache::remember('mastoapi:status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
-            if(in_array($status->type, ['photo', 'video', 'photo:album', 'loop', 'photo:video:album'])) {
-                $media = $status->media()->orderBy('order')->get();
-                return $this->collection($media, new MediaTransformer());
-            } else {
-                return $this->collection([], new MediaTransformer());
-            }
-        });
-    }
-
-    public function includeMentions(Status $status)
-    {
-        $mentions = $status->mentions;
-
-        return $this->collection($mentions, new MentionTransformer());
-    }
-
-    public function includeTags(Status $status)
-    {
-        $hashtags = $status->hashtags;
-
-        return $this->collection($hashtags, new HashtagTransformer());
-    }
-}
+	public function transform(Status $status)
+	{
+		return [
+			'id'                        => (string) $status->id,
+			'created_at'                => $status->created_at->toJSON(),
+			'in_reply_to_id'            => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null,
+			'in_reply_to_account_id'    => $status->in_reply_to_profile_id,
+			'sensitive'                 => (bool) $status->is_nsfw,
+			'spoiler_text'              => $status->cw_summary ?? '',
+			'visibility'                => $status->visibility ?? $status->scope,
+			'language'                  => 'en',
+			'uri'                       => $status->url(),
+			'url'                       => $status->url(),
+			'replies_count'             => 0,
+			'reblogs_count'             => $status->reblogs_count ?? 0,
+			'favourites_count'          => $status->likes_count ?? 0,
+			'reblogged'                 => $status->shared(),
+			'favourited'                => $status->liked(),
+			'muted'                     => false,
+			'bookmarked'                => false,
+			'pinned'                    => false,
+			'content'                   => $status->rendered ?? $status->caption ?? '',
+			'reblog'                    => null,
+			'application'               => [
+				'name'      => 'web',
+				'website'   => null
+			 ],
+			'mentions'                  => [],
+			'tags'                      => [],
+			'emojis'                    => [],
+			'card'                      => null,
+			'poll'                      => null,
+			'media_attachments'         => MediaService::get($status->id),
+			'account'                   => ProfileService::get($status->profile_id),
+			'tags'                      => StatusHashtagService::statusTags($status->id),
+		];
+	}
+}

+ 4 - 2
app/Transformer/Api/NotificationTransformer.php

@@ -59,7 +59,10 @@ class NotificationTransformer extends Fractal\TransformerAbstract
 			'like' => 'favourite',
 			'comment' => 'comment',
 			'admin.user.modlog.comment' => 'modlog',
-			'tagged' => 'tagged'
+			'tagged' => 'tagged',
+			'group:comment' => 'group:comment',
+			'story:react' => 'story:react',
+			'story:comment' => 'story:comment'
 		];
 		return $verbs[$verb];
 	}
@@ -90,7 +93,6 @@ class NotificationTransformer extends Fractal\TransformerAbstract
 		}
 	}
 
-
 	public function includeTagged(Notification $notification)
 	{
 		$n = $notification;

+ 12 - 34
app/Transformer/Api/StatusStatelessTransformer.php

@@ -7,21 +7,19 @@ use League\Fractal;
 use Cache;
 use App\Services\HashidService;
 use App\Services\LikeService;
+use App\Services\MediaService;
 use App\Services\MediaTagService;
+use App\Services\StatusHashtagService;
 use App\Services\StatusLabelService;
 use App\Services\ProfileService;
+use App\Services\PollService;
 
 class StatusStatelessTransformer extends Fractal\TransformerAbstract
 {
-	protected $defaultIncludes = [
-		'account',
-		'tags',
-		'media_attachments',
-	];
-
 	public function transform(Status $status)
 	{
 		$taggedPeople = MediaTagService::get($status->id);
+		$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
 
 		return [
 			'_v'                        => 1,
@@ -29,8 +27,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
 			'shortcode'                 => HashidService::encode($status->id),
 			'uri'                       => $status->url(),
 			'url'                       => $status->url(),
-			'in_reply_to_id'            => $status->in_reply_to_id,
-			'in_reply_to_account_id'    => $status->in_reply_to_profile_id,
+			'in_reply_to_id'            => (string) $status->in_reply_to_id,
+			'in_reply_to_account_id'    => (string) $status->in_reply_to_profile_id,
 			'reblog'                    => null,
 			'content'                   => $status->rendered ?? $status->caption,
 			'content_text'              => $status->caption,
@@ -43,7 +41,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
 			'muted'                     => null,
 			'sensitive'                 => (bool) $status->is_nsfw,
 			'spoiler_text'              => $status->cw_summary ?? '',
-			'visibility'                => $status->visibility ?? $status->scope,
+			'visibility'                => $status->scope ?? $status->visibility,
 			'application'               => [
 				'name'      => 'web',
 				'website'   => null
@@ -62,31 +60,11 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
 			'local'                     => (bool) $status->local,
 			'taggedPeople'              => $taggedPeople,
 			'label'                     => StatusLabelService::get($status),
-			'liked_by'                  => LikeService::likedBy($status)
+			'liked_by'                  => LikeService::likedBy($status),
+			'media_attachments'			=> MediaService::get($status->id),
+			'account'					=> ProfileService::get($status->profile_id),
+			'tags'						=> StatusHashtagService::statusTags($status->id),
+			'poll'						=> $poll
 		];
 	}
-
-	public function includeAccount(Status $status)
-	{
-		$account = $status->profile;
-
-		return $this->item($account, new AccountTransformer());
-	}
-
-	public function includeTags(Status $status)
-	{
-		$tags = $status->hashtags;
-
-		return $this->collection($tags, new HashtagTransformer());
-	}
-
-	public function includeMediaAttachments(Status $status)
-	{
-		return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
-			if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
-				$media = $status->media()->orderBy('order')->get();
-				return $this->collection($media, new MediaTransformer());
-			}
-		});
-	}
 }

+ 10 - 32
app/Transformer/Api/StatusTransformer.php

@@ -8,22 +8,20 @@ use League\Fractal;
 use Cache;
 use App\Services\HashidService;
 use App\Services\LikeService;
+use App\Services\MediaService;
 use App\Services\MediaTagService;
+use App\Services\StatusHashtagService;
 use App\Services\StatusLabelService;
 use App\Services\ProfileService;
 use Illuminate\Support\Str;
+use App\Services\PollService;
 
 class StatusTransformer extends Fractal\TransformerAbstract
 {
-	protected $defaultIncludes = [
-		'account',
-		'tags',
-		'media_attachments',
-	];
-
 	public function transform(Status $status)
 	{
 		$taggedPeople = MediaTagService::get($status->id);
+		$poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null;
 
 		return [
 			'_v'                        => 1,
@@ -45,7 +43,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
 			'muted'                     => null,
 			'sensitive'                 => (bool) $status->is_nsfw,
 			'spoiler_text'              => $status->cw_summary ?? '',
-			'visibility'                => $status->visibility ?? $status->scope,
+			'visibility'                => $status->scope ?? $status->visibility,
 			'application'               => [
 				'name'      => 'web',
 				'website'   => null
@@ -64,31 +62,11 @@ class StatusTransformer extends Fractal\TransformerAbstract
 			'local'                     => (bool) $status->local,
 			'taggedPeople'              => $taggedPeople,
 			'label'                     => StatusLabelService::get($status),
-			'liked_by'                  => LikeService::likedBy($status)
+			'liked_by'                  => LikeService::likedBy($status),
+			'media_attachments'			=> MediaService::get($status->id),
+			'account'					=> ProfileService::get($status->profile_id),
+			'tags'						=> StatusHashtagService::statusTags($status->id),
+			'poll'						=> $poll,
 		];
 	}
-
-	public function includeAccount(Status $status)
-	{
-		$account = $status->profile;
-
-		return $this->item($account, new AccountTransformer());
-	}
-
-	public function includeTags(Status $status)
-	{
-		$tags = $status->hashtags;
-
-		return $this->collection($tags, new HashtagTransformer());
-	}
-
-	public function includeMediaAttachments(Status $status)
-	{
-		return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(14), function() use($status) {
-			if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
-				$media = $status->media()->orderBy('order')->get();
-				return $this->collection($media, new MediaTransformer());
-			}
-		});
-	}
 }

+ 74 - 3
app/Util/ActivityPub/Helpers.php

@@ -32,6 +32,8 @@ use App\Services\MediaPathService;
 use App\Services\MediaStorageService;
 use App\Jobs\MediaPipeline\MediaStoragePipeline;
 use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
+use App\Util\Media\License;
+use App\Models\Poll;
 
 class Helpers {
 
@@ -269,7 +271,7 @@ class Helpers {
 
 		$res = self::fetchFromUrl($url);
 
-		if(!$res || empty($res) || isset($res['error']) ) {
+		if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
 			return;
 		}
 
@@ -330,7 +332,6 @@ class Helpers {
 		$idDomain = parse_url($id, PHP_URL_HOST);
 		$urlDomain = parse_url($url, PHP_URL_HOST);
 
-
 		if(!self::validateUrl($id)) {
 			return;
 		}
@@ -367,6 +368,7 @@ class Helpers {
 			$cw = true;
 		}
 
+
 		$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
 		$status = Cache::lock($statusLockKey)
 			->get(function () use(
@@ -379,6 +381,19 @@ class Helpers {
 				$scope,
 				$id
 		) {
+			if($res['type'] === 'Question') {
+				$status = self::storePoll(
+					$profile,
+					$res,
+					$url,
+					$ts,
+					$reply_to,
+					$cw,
+					$scope,
+					$id
+				);
+				return $status;
+			}
 			return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
 				$status = new Status;
 				$status->profile_id = $profile->id;
@@ -408,6 +423,55 @@ class Helpers {
 		return $status;
 	}
 
+	private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id)
+	{
+		if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) {
+			return;
+		}
+
+		$options = collect($res['oneOf'])->map(function($option) {
+			return $option['name'];
+		})->toArray();
+
+		$cachedTallies = collect($res['oneOf'])->map(function($option) {
+			return $option['replies']['totalItems'] ?? 0;
+		})->toArray();
+
+		$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($res['content']);
+		$status->rendered = Purify::clean($res['content']);
+		$status->created_at = Carbon::parse($ts);
+		$status->in_reply_to_id = null;
+		$status->local = false;
+		$status->is_nsfw = $cw;
+		$status->scope = 'draft';
+		$status->visibility = 'draft';
+		$status->cw_summary = $cw == true && isset($res['summary']) ?
+			Purify::clean(strip_tags($res['summary'])) : null;
+		$status->save();
+
+		$poll = new Poll;
+		$poll->status_id = $status->id;
+		$poll->profile_id = $status->profile_id;
+		$poll->poll_options = $options;
+		$poll->cached_tallies = $cachedTallies;
+		$poll->votes_count = array_sum($cachedTallies);
+		$poll->expires_at = now()->parse($res['endTime']);
+		$poll->last_fetched_at = now();
+		$poll->save();
+
+		$status->type = 'poll';
+		$status->scope = $scope;
+		$status->visibility = $scope;
+		$status->save();
+
+		return $status;
+	}
+
 	public static function statusFetch($url)
 	{
 		return self::statusFirstOrFetch($url);
@@ -428,6 +492,7 @@ class Helpers {
 			$type = $media['mediaType'];
 			$url = $media['url'];
 			$blurhash = isset($media['blurhash']) ? $media['blurhash'] : null;
+			$license = isset($media['license']) ? License::nameToId($media['license']) : null;
 			$valid = self::validateUrl($url);
 			if(in_array($type, $allowed) == false || $valid == false) {
 				continue;
@@ -441,6 +506,9 @@ class Helpers {
 			$media->user_id = null;
 			$media->media_path = $url;
 			$media->remote_url = $url;
+			if($license) {
+				$media->license = $license;
+			}
 			$media->mime = $type;
 			$media->version = 3;
 			$media->save();
@@ -495,9 +563,12 @@ class Helpers {
 
 			$profile = Profile::whereRemoteUrl($res['id'])->first();
 			if(!$profile) {
-				Instance::firstOrCreate([
+				$instance = Instance::firstOrCreate([
 					'domain' => $domain
 				]);
+				if($instance->wasRecentlyCreated == true) {
+					\App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
+				}
 				$profileLockKey = 'helpers:profile-lock:' . hash('sha256', $res['id']);
 				$profile = Cache::lock($profileLockKey)->get(function () use($domain, $webfinger, $res, $runJobs) {
 					return DB::transaction(function() use($domain, $webfinger, $res, $runJobs) {

+ 373 - 15
app/Util/ActivityPub/Inbox.php

@@ -2,7 +2,7 @@
 
 namespace App\Util\ActivityPub;
 
-use Cache, DB, Log, Purify, Redis, Validator;
+use Cache, DB, Log, Purify, Redis, Storage, Validator;
 use App\{
 	Activity,
 	DirectMessage,
@@ -14,6 +14,8 @@ use App\{
 	Profile,
 	Status,
 	StatusHashtag,
+	Story,
+	StoryView,
 	UserFilter
 };
 use Carbon\Carbon;
@@ -22,6 +24,8 @@ use Illuminate\Support\Str;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\FollowPipeline\FollowPipeline;
 use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
+use App\Jobs\StoryPipeline\StoryExpire;
+use App\Jobs\StoryPipeline\StoryFetch;
 
 use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
 use App\Util\ActivityPub\Validator\Add as AddValidator;
@@ -30,6 +34,9 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator;
 use App\Util\ActivityPub\Validator\Like as LikeValidator;
 use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
 
+use App\Services\PollService;
+use App\Services\FollowerService;
+
 class Inbox
 {
 	protected $headers;
@@ -47,16 +54,7 @@ class Inbox
 	public function handle()
 	{
 		$this->handleVerb();
-
-		// if(!Activity::where('data->id', $this->payload['id'])->exists()) {
-		//     (new Activity())->create([
-		//         'to_id' => $this->profile->id,
-		//         'data' => json_encode($this->payload)
-		//     ]);
-		// }
-
 		return;
-
 	}
 
 	public function handleVerb()
@@ -105,6 +103,18 @@ class Inbox
 				$this->handleUndoActivity();
 				break;
 
+			case 'View':
+				$this->handleViewActivity();
+				break;
+
+			case 'Story:Reaction':
+				$this->handleStoryReactionActivity();
+				break;
+
+			case 'Story:Reply':
+				$this->handleStoryReplyActivity();
+				break;
+
 			default:
 				// TODO: decide how to handle invalid verbs.
 				break;
@@ -136,6 +146,30 @@ class Inbox
 	public function handleAddActivity()
 	{
 		// stories ;)
+
+		if(!isset(
+			$this->payload['actor'],
+			$this->payload['object']
+		)) {
+			return;
+		}
+
+		$actor = $this->payload['actor'];
+		$obj = $this->payload['object'];
+
+		if(!Helpers::validateUrl($actor)) {
+			return;
+		}
+
+		if(!isset($obj['type'])) {
+			return;
+		}
+
+		switch($obj['type']) {
+			case 'Story':
+				StoryFetch::dispatch($this->payload)->onQueue('story');
+			break;
+		}
 	}
 
 	public function handleCreateActivity()
@@ -147,6 +181,12 @@ class Inbox
 		}
 		$to = $activity['to'];
 		$cc = isset($activity['cc']) ? $activity['cc'] : [];
+
+		if($activity['type'] == 'Question') {
+			$this->handlePollCreate();
+			return;
+		}
+
 		if(count($to) == 1 &&
 			count($cc) == 0 &&
 			parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
@@ -154,10 +194,11 @@ class Inbox
 			$this->handleDirectMessage();
 			return;
 		}
+
 		if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
 			$this->handleNoteReply();
 
-		} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
+		} elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) {
 			if(!$this->verifyNoteAttachment()) {
 				return;
 			}
@@ -180,6 +221,18 @@ class Inbox
 		return;
 	}
 
+	public function handlePollCreate()
+	{
+		$activity = $this->payload['object'];
+		$actor = $this->actorFirstOrCreate($this->payload['actor']);
+		if(!$actor || $actor->domain == null) {
+			return;
+		}
+		$url = isset($activity['url']) ? $activity['url'] : $activity['id'];
+		Helpers::statusFirstOrFetch($url);
+		return;
+	}
+
 	public function handleNoteCreate()
 	{
 		$activity = $this->payload['object'];
@@ -188,6 +241,16 @@ class Inbox
 			return;
 		}
 
+		if( isset($activity['inReplyTo']) &&
+			isset($activity['name']) &&
+			!isset($activity['content']) &&
+			!isset($activity['attachment']) &&
+			Helpers::validateLocalUrl($activity['inReplyTo'])
+		) {
+			$this->handlePollVote();
+			return;
+		}
+
 		if($actor->followers()->count() == 0) {
 			return;
 		}
@@ -200,6 +263,51 @@ class Inbox
 		return;
 	}
 
+	public function handlePollVote()
+	{
+		$activity = $this->payload['object'];
+		$actor = $this->actorFirstOrCreate($this->payload['actor']);
+		$status = Helpers::statusFetch($activity['inReplyTo']);
+		$poll = $status->poll;
+
+		if(!$status || !$poll) {
+			return;
+		}
+
+		if(now()->gt($poll->expires_at)) {
+			return;
+		}
+
+		$choices = $poll->poll_options;
+		$choice = array_search($activity['name'], $choices);
+
+		if($choice === false) {
+			return;
+		}
+
+		if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) {
+			return;
+		}
+
+		$vote = new PollVote;
+		$vote->status_id = $status->id;
+		$vote->profile_id = $actor->id;
+		$vote->poll_id = $poll->id;
+		$vote->choice = $choice;
+		$vote->uri = isset($activity['id']) ? $activity['id'] : null;
+		$vote->save();
+
+		$tallies = $poll->cached_tallies;
+		$tallies[$choice] = $tallies[$choice] + 1;
+		$poll->cached_tallies = $tallies;
+		$poll->votes_count = array_sum($tallies);
+		$poll->save();
+
+		PollService::del($status->id);
+
+		return;
+	}
+
 	public function handleDirectMessage()
 	{
 		$activity = $this->payload['object'];
@@ -420,7 +528,6 @@ class Inbox
 
 	public function handleAcceptActivity()
 	{
-
 		$actor = $this->payload['object']['actor'];
 		$obj = $this->payload['object']['object'];
 		$type = $this->payload['object']['type'];
@@ -480,7 +587,7 @@ class Inbox
 			return;
 		} else {
 			$type = $this->payload['object']['type'];
-			$typeCheck = in_array($type, ['Person', 'Tombstone']);
+			$typeCheck = in_array($type, ['Person', 'Tombstone', 'Story']);
 			if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) {
 				return;
 			}
@@ -520,6 +627,13 @@ class Inbox
 						return;
 					break;
 
+				case 'Story':
+					$story = Story::whereObjectId($id)
+						->first();
+					if($story) {
+						StoryExpire::dispatch($story)->onQueue('story');
+					}
+
 				default:
 					return;
 					break;
@@ -558,10 +672,8 @@ class Inbox
 		return;
 	}
 
-
 	public function handleRejectActivity()
 	{
-
 	}
 
 	public function handleUndoActivity()
@@ -631,4 +743,250 @@ class Inbox
 		}
 		return;
 	}
+
+	public function handleViewActivity()
+	{
+		if(!isset(
+			$this->payload['actor'],
+			$this->payload['object']
+		)) {
+			return;
+		}
+
+		$actor = $this->payload['actor'];
+		$obj = $this->payload['object'];
+
+		if(!Helpers::validateUrl($actor)) {
+			return;
+		}
+
+		if(!$obj || !is_array($obj)) {
+			return;
+		}
+
+		if(!isset($obj['type']) || !isset($obj['object']) || $obj['type'] != 'Story') {
+			return;
+		}
+
+		if(!Helpers::validateLocalUrl($obj['object'])) {
+			return;
+		}
+
+		$profile = Helpers::profileFetch($actor);
+		$storyId = Str::of($obj['object'])->explode('/')->last();
+
+		$story = Story::whereActive(true)
+			->whereLocal(true)
+			->find($storyId);
+
+		if(!$story) {
+			return;
+		}
+
+		if(!FollowerService::follows($profile->id, $story->profile_id)) {
+			return;
+		}
+
+		$view = StoryView::firstOrCreate([
+			'story_id' => $story->id,
+			'profile_id' => $profile->id
+		]);
+
+		if($view->wasRecentlyCreated == true) {
+			$story->view_count++;
+			$story->save();
+		}
+	}
+
+	public function handleStoryReactionActivity()
+	{
+		if(!isset(
+			$this->payload['actor'],
+			$this->payload['id'],
+			$this->payload['inReplyTo'],
+			$this->payload['content']
+		)) {
+			return;
+		}
+
+		$id = $this->payload['id'];
+		$actor = $this->payload['actor'];
+		$storyUrl = $this->payload['inReplyTo'];
+		$to = $this->payload['to'];
+		$text = Purify::clean($this->payload['content']);
+
+		if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
+			return;
+		}
+
+		if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) {
+			return;
+		}
+
+		if(!Helpers::validateLocalUrl($storyUrl)) {
+			return;
+		}
+
+		if(!Helpers::validateLocalUrl($to)) {
+			return;
+		}
+
+		if(Status::whereObjectUrl($id)->exists()) {
+			return;
+		}
+
+		$storyId = Str::of($storyUrl)->explode('/')->last();
+		$targetProfile = Helpers::profileFetch($to);
+
+		$story = Story::whereProfileId($targetProfile->id)
+			->find($storyId);
+
+		if(!$story) {
+			return;
+		}
+
+		if($story->can_react == false) {
+			return;
+		}
+
+		$actorProfile = Helpers::profileFetch($actor);
+
+		if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
+			return;
+		}
+
+		$status = new Status;
+		$status->profile_id = $actorProfile->id;
+		$status->type = 'story:reaction';
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id,
+			'reaction' => $text
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $actorProfile->id;
+		$dm->type = 'story:react';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $targetProfile->username,
+			'story_actor_username' => $actorProfile->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'reaction' => $text
+		]);
+		$dm->save();
+
+		$n = new Notification;
+		$n->profile_id = $dm->to_id;
+		$n->actor_id = $dm->from_id;
+		$n->item_id = $dm->id;
+		$n->item_type = 'App\DirectMessage';
+		$n->action = 'story:react';
+		$n->message = "{$actorProfile->username} reacted to your story";
+		$n->rendered = "{$actorProfile->username} reacted to your story";
+		$n->save();
+	}
+
+	public function handleStoryReplyActivity()
+	{
+		if(!isset(
+			$this->payload['actor'],
+			$this->payload['id'],
+			$this->payload['inReplyTo'],
+			$this->payload['content']
+		)) {
+			return;
+		}
+
+		$id = $this->payload['id'];
+		$actor = $this->payload['actor'];
+		$storyUrl = $this->payload['inReplyTo'];
+		$to = $this->payload['to'];
+		$text = Purify::clean($this->payload['content']);
+
+		if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
+			return;
+		}
+
+		if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) {
+			return;
+		}
+
+		if(!Helpers::validateLocalUrl($storyUrl)) {
+			return;
+		}
+
+		if(!Helpers::validateLocalUrl($to)) {
+			return;
+		}
+
+		if(Status::whereObjectUrl($id)->exists()) {
+			return;
+		}
+
+		$storyId = Str::of($storyUrl)->explode('/')->last();
+		$targetProfile = Helpers::profileFetch($to);
+
+		$story = Story::whereProfileId($targetProfile->id)
+			->find($storyId);
+
+		if(!$story) {
+			return;
+		}
+
+		if($story->can_react == false) {
+			return;
+		}
+
+		$actorProfile = Helpers::profileFetch($actor);
+
+		if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
+			return;
+		}
+
+		$status = new Status;
+		$status->profile_id = $actorProfile->id;
+		$status->type = 'story:reply';
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id,
+			'caption' => $text
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $actorProfile->id;
+		$dm->type = 'story:comment';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $targetProfile->username,
+			'story_actor_username' => $actorProfile->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'caption' => $text
+		]);
+		$dm->save();
+
+		$n = new Notification;
+		$n->profile_id = $dm->to_id;
+		$n->actor_id = $dm->from_id;
+		$n->item_id = $dm->id;
+		$n->item_type = 'App\DirectMessage';
+		$n->action = 'story:comment';
+		$n->message = "{$actorProfile->username} commented on story";
+		$n->rendered = "{$actorProfile->username} commented on story";
+		$n->save();
+	}
 }

+ 34 - 0
app/Util/ActivityPub/Validator/StoryValidator.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Util\ActivityPub\Validator;
+
+use Validator;
+use Illuminate\Validation\Rule;
+
+class StoryValidator {
+
+	public static function validate($payload)
+	{
+		$valid = Validator::make($payload, [
+			'@context' => 'required',
+			'id' => 'required|string',
+			'type' => [
+				'required',
+				Rule::in(['Story'])
+			],
+			'to' => 'required',
+			'attributedTo' => 'required|url',
+			'published' => 'required|date',
+			'expiresAt' => 'required|date',
+			'duration' => 'required|integer|min:1|max:300',
+			'can_react' => 'required|boolean',
+			'can_reply' => 'required|boolean',
+			'attachment' => 'required',
+			'attachment.type' => 'required|in:Image,Video',
+			'attachment.url' => 'required|url',
+			'attachment.mediaType' => 'required'
+		])->passes();
+
+		return $valid;
+	}
+}

+ 57 - 0
app/Util/Lexer/Bearcap.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Util\Lexer;
+
+use Illuminate\Support\Str;
+use App\Util\ActivityPub\Helpers;
+
+class Bearcap
+{
+	public static function encode($url, $token)
+	{
+		return "bear:?t={$token}&u={$url}";
+	}
+
+	public static function decode($str)
+	{
+		if(!Str::startsWith($str, 'bear:')) {
+			return false;
+		}
+
+		$query = parse_url($str, PHP_URL_QUERY);
+
+		if(!$query) {
+			return false;
+		}
+
+		$res = [];
+
+		$parts = Str::of($str)->substr(6)->explode('&')->toArray();
+
+		foreach($parts as $part) {
+			if(Str::startsWith($part, 't=')) {
+				$res['token'] = substr($part, 2);
+			}
+
+			if(Str::startsWith($part, 'u=')) {
+				$res['url'] = substr($part, 2);
+			}
+		}
+
+		if( !isset($res['token']) ||
+			!isset($res['url'])
+		) {
+			return false;
+		}
+
+		$url = $res['url'];
+		if(mb_substr($url, 0, 8) !== 'https://') {
+			return false;
+		}
+		$valid = filter_var($url, FILTER_VALIDATE_URL);
+		if(!$valid) {
+			return false;
+		}
+		return $res;
+	}
+}

+ 15 - 0
app/Util/Media/License.php

@@ -120,4 +120,19 @@ class License {
             ->values()
             ->toArray();
     }
+
+    public static function nameToId($name)
+    {
+    	$license = collect(self::get())
+    		->filter(function($l) use($name) {
+    			return $l['title'] == $name;
+    		})
+    		->first();
+
+    	if(!$license || $license['id'] < 2) {
+    		return null;
+    	}
+
+    	return $license['id'];
+    }
 }

+ 3 - 2
app/Util/Site/Config.php

@@ -7,7 +7,7 @@ use Illuminate\Support\Str;
 
 class Config {
 
-	const CACHE_KEY = 'api:site:configuration:_v0.3';
+	const CACHE_KEY = 'api:site:configuration:_v0.4';
 
 	public static function get() {
 		return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
@@ -37,7 +37,8 @@ class Config {
 					'lc' => config('exp.lc'),
 					'rec' => config('exp.rec'),
 					'loops' => config('exp.loops'),
-					'top' => config('exp.top')
+					'top' => config('exp.top'),
+					'polls' => config('exp.polls')
 				],
 
 				'site' => [

+ 7 - 1
config/database.php

@@ -1,7 +1,8 @@
 <?php
 
-return [
+use Illuminate\Database\DBAL\TimestampType;
 
+return [
     /*
     |--------------------------------------------------------------------------
     | Default Database Connection Name
@@ -119,4 +120,9 @@ return [
 
     ],
 
+	'dbal' => [
+	    'types' => [
+	        'timestamp' => TimestampType::class,
+	    ],
+	],
 ];

+ 1 - 0
config/exp.php

@@ -6,4 +6,5 @@ return [
 	'rec' => false,
 	'loops' => false,
 	'top' => env('EXP_TOP', false),
+	'polls' => env('EXP_POLLS', false)
 ];

+ 3 - 2
config/horizon.php

@@ -81,6 +81,7 @@ return [
 	'waits' => [
 		'redis:feed' => 30,
 		'redis:default' => 30,
+		'redis:low' => 30,
 		'redis:high' => 30,
 		'redis:delete' => 30
 	],
@@ -166,7 +167,7 @@ return [
 		'production' => [
 			'supervisor-1' => [
 				'connection'    => 'redis',
-				'queue'         => ['high', 'default', 'feed', 'delete'],
+				'queue'         => ['high', 'default', 'feed', 'low', 'story', 'delete'],
 				'balance'       => 'auto',
 				'maxProcesses'  => 20,
 				'memory'        => 128,
@@ -178,7 +179,7 @@ return [
 		'local' => [
 			'supervisor-1' => [
 				'connection'    => 'redis',
-				'queue'         => ['high', 'default', 'feed', 'delete'],
+				'queue'         => ['high', 'default', 'feed', 'low', 'story', 'delete'],
 				'balance'       => 'auto',
 				'maxProcesses'  => 20,
 				'memory'        => 128,

+ 1 - 1
config/image-optimizer.php

@@ -14,7 +14,7 @@ return [
     'optimizers' => [
 
         Jpegoptim::class => [
-            '-m75', // set maximum quality to 75%
+            '-m' . (int) env('IMAGE_QUALITY', 80),
             '--strip-all',  // this strips out all text information such as comments and EXIF data
             '--all-progressive',  // this will make sure the resulting image is a progressive one
         ],

+ 4 - 0
config/instance.php

@@ -47,6 +47,10 @@ return [
 		]
 	],
 
+	'polls' => [
+		'enabled' => false
+	],
+
 	'stories' => [
 		'enabled' => env('STORIES_ENABLED', false),
 	],

+ 2 - 0
config/pixelfed.php

@@ -278,4 +278,6 @@ return [
 	|
 	*/
 	'media_fast_process' => env('PF_MEDIA_FAST_PROCESS', true),
+
+	'max_altext_length' => env('PF_MEDIA_MAX_ALTTEXT_LENGTH', 1000),
 ];

+ 46 - 0
database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php

@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddComposeSettingsToUserSettingsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('user_settings', function (Blueprint $table) {
+            $table->json('compose_settings')->nullable();
+        });
+
+        Schema::table('media', function (Blueprint $table) {
+        	$table->text('caption')->change();
+        	$table->index('profile_id');
+        	$table->index('mime');
+        	$table->index('license');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('user_settings', function (Blueprint $table) {
+            $table->dropColumn('compose_settings');
+        });
+
+        Schema::table('media', function (Blueprint $table) {
+            $table->string('caption')->change();
+            $table->dropIndex('profile_id');
+            $table->dropIndex('mime');
+            $table->dropIndex('license');
+        });
+    }
+}

+ 42 - 0
database/migrations/2021_07_29_014835_create_polls_table.php

@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePollsTable extends Migration
+{
+	/**
+	 * Run the migrations.
+	 *
+	 * @return void
+	 */
+	public function up()
+	{
+		Schema::create('polls', function (Blueprint $table) {
+			$table->bigInteger('id')->unsigned()->primary();
+			$table->bigInteger('story_id')->unsigned()->nullable()->index();
+			$table->bigInteger('status_id')->unsigned()->nullable()->index();
+			$table->bigInteger('group_id')->unsigned()->nullable()->index();
+			$table->bigInteger('profile_id')->unsigned()->index();
+			$table->json('poll_options')->nullable();
+			$table->json('cached_tallies')->nullable();
+			$table->boolean('multiple')->default(false);
+			$table->boolean('hide_totals')->default(false);
+			$table->unsignedInteger('votes_count')->default(0);
+			$table->timestamp('last_fetched_at')->nullable();
+			$table->timestamp('expires_at')->nullable();
+			$table->timestamps();
+		});
+	}
+
+	/**
+	 * Reverse the migrations.
+	 *
+	 * @return void
+	 */
+	public function down()
+	{
+		Schema::dropIfExists('polls');
+	}
+}

+ 37 - 0
database/migrations/2021_07_29_014849_create_poll_votes_table.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePollVotesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('poll_votes', function (Blueprint $table) {
+            $table->id();
+            $table->bigInteger('story_id')->unsigned()->nullable()->index();
+            $table->bigInteger('status_id')->unsigned()->nullable()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->bigInteger('poll_id')->unsigned()->index();
+            $table->unsignedInteger('choice')->default(0)->index();
+            $table->string('uri')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('poll_votes');
+    }
+}

+ 54 - 0
database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php

@@ -0,0 +1,54 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class UpdateStoriesTableFixExpiresAtColumn extends Migration
+{
+	/**
+	 * Run the migrations.
+	 *
+	 * @return void
+	 */
+	public function up()
+	{
+		Schema::table('stories', function (Blueprint $table) {
+			$sm = Schema::getConnection()->getDoctrineSchemaManager();
+			$doctrineTable = $sm->listTableDetails('stories');
+
+			if($doctrineTable->hasIndex('stories_expires_at_index')) {
+				$table->dropIndex('stories_expires_at_index');
+			}
+			$table->timestamp('expires_at')->default(null)->index()->nullable()->change();
+			$table->boolean('can_reply')->default(true);
+			$table->boolean('can_react')->default(true);
+			$table->string('object_id')->nullable()->unique();
+			$table->string('object_uri')->nullable()->unique();
+			$table->string('bearcap_token')->nullable();
+		});
+	}
+
+	/**
+	 * Reverse the migrations.
+	 *
+	 * @return void
+	 */
+	public function down()
+	{
+		Schema::table('stories', function (Blueprint $table) {
+			$sm = Schema::getConnection()->getDoctrineSchemaManager();
+			$doctrineTable = $sm->listTableDetails('stories');
+
+			if($doctrineTable->hasIndex('stories_expires_at_index')) {
+				$table->dropIndex('stories_expires_at_index');
+			}
+			$table->timestamp('expires_at')->default(null)->index()->nullable()->change();
+			$table->dropColumn('can_reply');
+			$table->dropColumn('can_react');
+			$table->dropColumn('object_id');
+			$table->dropColumn('object_uri');
+			$table->dropColumn('bearcap_token');
+		});
+	}
+}

+ 46 - 0
database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php

@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Jobs\InstancePipeline\InstanceCrawlPipeline;
+
+class AddSoftwareColumnToInstancesTable extends Migration
+{
+	/**
+	 * Run the migrations.
+	 *
+	 * @return void
+	 */
+	public function up()
+	{
+		Schema::table('instances', function (Blueprint $table) {
+			$table->string('software')->nullable()->index();
+			$table->unsignedInteger('user_count')->nullable();
+			$table->unsignedInteger('status_count')->nullable();
+			$table->timestamp('last_crawled_at')->nullable();
+		});
+
+		$this->runPostMigration();
+	}
+
+	/**
+	 * Reverse the migrations.
+	 *
+	 * @return void
+	 */
+	public function down()
+	{
+		Schema::table('instances', function (Blueprint $table) {
+			$table->dropColumn('software');
+			$table->dropColumn('user_count');
+			$table->dropColumn('status_count');
+			$table->dropColumn('last_crawled_at');
+		});
+	}
+
+	protected function runPostMigration()
+	{
+		InstanceCrawlPipeline::dispatch();
+	}
+}

BIN
public/css/app.css


BIN
public/css/appdark.css


BIN
public/css/landing.css


BIN
public/fonts/fa-light-300.eot


BIN
public/fonts/fa-light-300.svg


BIN
public/fonts/fa-light-300.ttf


BIN
public/fonts/fa-light-300.woff


BIN
public/fonts/fa-light-300.woff2


BIN
public/fonts/fa-regular-400.eot


BIN
public/fonts/fa-regular-400.svg


BIN
public/fonts/fa-regular-400.ttf


BIN
public/fonts/fa-regular-400.woff


BIN
public/fonts/fa-regular-400.woff2


BIN
public/fonts/fa-solid-900.eot


BIN
public/fonts/fa-solid-900.svg


BIN
public/fonts/fa-solid-900.ttf


BIN
public/fonts/fa-solid-900.woff


BIN
public/fonts/fa-solid-900.woff2


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است