Jelajahi Sumber

Merge pull request #547 from pixelfed/frontend-ui-refactor

[WIP] The big one.
daniel 6 tahun lalu
induk
melakukan
a3d80971bc
100 mengubah file dengan 3554 tambahan dan 477 penghapusan
  1. 1 1
      .circleci/config.yml
  2. 5 2
      app/Follower.php
  3. 4 1
      app/Http/Controllers/AccountController.php
  4. 32 14
      app/Http/Controllers/Api/BaseApiController.php
  5. 10 0
      app/Http/Controllers/CollectionController.php
  6. 10 0
      app/Http/Controllers/CollectionItemController.php
  7. 55 0
      app/Http/Controllers/DirectMessageController.php
  8. 6 48
      app/Http/Controllers/DiscoverController.php
  9. 51 1
      app/Http/Controllers/FederationController.php
  10. 3 1
      app/Http/Controllers/HomeController.php
  11. 151 0
      app/Http/Controllers/Import/Instagram.php
  12. 13 0
      app/Http/Controllers/Import/Mastodon.php
  13. 16 0
      app/Http/Controllers/ImportController.php
  14. 120 55
      app/Http/Controllers/InternalApiController.php
  15. 2 0
      app/Http/Controllers/ProfileController.php
  16. 103 0
      app/Http/Controllers/PublicApiController.php
  17. 5 2
      app/Http/Controllers/SearchController.php
  18. 5 4
      app/Http/Controllers/Settings/HomeSettings.php
  19. 1 0
      app/Http/Controllers/SettingsController.php
  20. 22 22
      app/Http/Controllers/SiteController.php
  21. 6 33
      app/Http/Controllers/StatusController.php
  22. 14 29
      app/Http/Controllers/TimelineController.php
  23. 1 0
      app/Http/Kernel.php
  24. 23 0
      app/Http/Middleware/Localization.php
  25. 115 0
      app/Jobs/ImportPipeline/ImportInstagram.php
  26. 4 4
      app/Jobs/InboxPipeline/InboxWorker.php
  27. 2 2
      app/Jobs/LikePipeline/LikePipeline.php
  28. 1 0
      app/Jobs/RemoteFollowPipeline/RemoteFollowImportRecent.php
  29. 1 1
      app/Jobs/RemoteFollowPipeline/RemoteFollowPipeline.php
  30. 79 0
      app/Jobs/SharePipeline/SharePipeline.php
  31. 1 1
      app/Jobs/StatusPipeline/NewStatusPipeline.php
  32. 16 0
      app/Jobs/StatusPipeline/StatusActivityPubDeliver.php
  33. 9 3
      app/Jobs/StatusPipeline/StatusDelete.php
  34. 1 1
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  35. 34 0
      app/Jobs/VideoPipeline/VideoOptimize.php
  36. 34 0
      app/Jobs/VideoPipeline/VideoPostProcess.php
  37. 62 0
      app/Jobs/VideoPipeline/VideoThumbnail.php
  38. 1 0
      app/Like.php
  39. 17 2
      app/Media.php
  40. 43 0
      app/Profile.php
  41. 1 2
      app/Providers/RouteServiceProvider.php
  42. 9 8
      app/Report.php
  43. 24 17
      app/Status.php
  44. 10 0
      app/Story.php
  45. 10 0
      app/StoryReaction.php
  46. 19 0
      app/Transformer/ActivityPub/Verb/Announce.php
  47. 70 0
      app/Transformer/ActivityPub/Verb/CreateNote.php
  48. 19 0
      app/Transformer/ActivityPub/Verb/Follow.php
  49. 19 0
      app/Transformer/ActivityPub/Verb/Like.php
  50. 6 1
      app/Transformer/Api/MediaTransformer.php
  51. 25 0
      app/Transformer/Api/RelationshipTransformer.php
  52. 1 1
      app/Transformer/Api/StatusTransformer.php
  53. 266 27
      app/Util/ActivityPub/Inbox.php
  54. 34 0
      app/Util/HTTPSignatures/Algorithm.php
  55. 7 0
      app/Util/HTTPSignatures/AlgorithmException.php
  56. 19 0
      app/Util/HTTPSignatures/AlgorithmInterface.php
  57. 119 0
      app/Util/HTTPSignatures/Context.php
  58. 7 0
      app/Util/HTTPSignatures/Exception.php
  59. 41 0
      app/Util/HTTPSignatures/GuzzleHttpSignatures.php
  60. 48 0
      app/Util/HTTPSignatures/HeaderList.php
  61. 36 0
      app/Util/HTTPSignatures/HmacAlgorithm.php
  62. 260 0
      app/Util/HTTPSignatures/Key.php
  63. 7 0
      app/Util/HTTPSignatures/KeyException.php
  64. 36 0
      app/Util/HTTPSignatures/KeyStore.php
  65. 7 0
      app/Util/HTTPSignatures/KeyStoreException.php
  66. 15 0
      app/Util/HTTPSignatures/KeyStoreInterface.php
  67. 64 0
      app/Util/HTTPSignatures/RsaAlgorithm.php
  68. 38 0
      app/Util/HTTPSignatures/Signature.php
  69. 49 0
      app/Util/HTTPSignatures/SignatureParameters.php
  70. 111 0
      app/Util/HTTPSignatures/SignatureParametersParser.php
  71. 7 0
      app/Util/HTTPSignatures/SignatureParseException.php
  72. 7 0
      app/Util/HTTPSignatures/SignedHeaderNotPresentException.php
  73. 104 0
      app/Util/HTTPSignatures/Signer.php
  74. 89 0
      app/Util/HTTPSignatures/SigningString.php
  75. 202 0
      app/Util/HTTPSignatures/Verification.php
  76. 31 0
      app/Util/HTTPSignatures/Verifier.php
  77. 3 1
      app/Util/Media/Image.php
  78. 4 4
      composer.json
  79. 3 2
      config/app.php
  80. 2 2
      config/debugbar.php
  81. 1 1
      config/pixelfed.php
  82. 141 0
      config/purify.php
  83. 1 1
      config/queue.php
  84. 4 3
      database/factories/UserFactory.php
  85. 186 8
      package-lock.json
  86. 3 1
      package.json
  87. TEMPAT SAMPAH
      public/css/app.css
  88. 0 0
      public/img/help/what_is_the_fediverse.svg
  89. TEMPAT SAMPAH
      public/img/pixelfed-icon-black.svg
  90. TEMPAT SAMPAH
      public/img/pixelfed-icon-grey.svg
  91. TEMPAT SAMPAH
      public/js/app.js
  92. TEMPAT SAMPAH
      public/js/components.js
  93. TEMPAT SAMPAH
      public/js/timeline.js
  94. TEMPAT SAMPAH
      public/mix-manifest.json
  95. TEMPAT SAMPAH
      public/static/beep.mp3
  96. 5 101
      resources/assets/js/bootstrap.js
  97. 117 0
      resources/assets/js/components.js
  98. 33 0
      resources/assets/js/components/CirclePanel.vue
  99. 95 0
      resources/assets/js/components/DiscoverComponent.vue
  100. 60 70
      resources/assets/js/components/PostComments.vue

+ 1 - 1
.circleci/config.yml

@@ -22,7 +22,7 @@ jobs:
       - checkout
 
       - run: sudo apt update && sudo apt install zlib1g-dev libsqlite3-dev
-      - run: sudo docker-php-ext-install bcmath pcntl zip
+      - run: sudo docker-php-ext-install pcntl
 
       # Download and cache dependencies
 

+ 5 - 2
app/Follower.php

@@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Model;
 
 class Follower extends Model
 {
+
+    protected $fillable = ['profile_id', 'following_id', 'local_profile'];
+
     public function actor()
     {
         return $this->belongsTo(Profile::class, 'profile_id', 'id');
@@ -21,9 +24,9 @@ class Follower extends Model
         return $this->belongsTo(Profile::class, 'following_id', 'id');
     }
 
-    public function permalink()
+    public function permalink($append = null)
     {
-        $path = $this->actor->permalink("/follow/{$this->id}");
+        $path = $this->actor->permalink("/follow/{$this->id}{$append}");
         return url($path);
     }
 

+ 4 - 1
app/Http/Controllers/AccountController.php

@@ -64,10 +64,13 @@ class AccountController extends Controller
       ]);
         $profile = Auth::user()->profile;
         $action = $request->input('a');
+        $allowed = ['like', 'follow'];
         $timeago = Carbon::now()->subMonths(3);
         $following = $profile->following->pluck('id');
         $notifications = Notification::whereIn('actor_id', $following)
-          ->where('profile_id', '!=', $profile->id)
+          ->whereIn('action', $allowed)
+          ->where('actor_id', '<>', $profile->id)
+          ->where('profile_id', '<>', $profile->id)
           ->whereDate('created_at', '>', $timeago)
           ->orderBy('notifications.created_at', 'desc')
           ->simplePaginate(30);

+ 32 - 14
app/Http/Controllers/Api/BaseApiController.php

@@ -2,22 +2,27 @@
 
 namespace App\Http\Controllers\Api;
 
-use App\Avatar;
-use App\Http\Controllers\AvatarController;
-use App\Http\Controllers\Controller;
-use App\Jobs\AvatarPipeline\AvatarOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Media;
-use App\Profile;
-use App\Transformer\Api\AccountTransformer;
-use App\Transformer\Api\MediaTransformer;
-use App\Transformer\Api\StatusTransformer;
-use Auth;
-use Cache;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\URL;
+use App\Http\Controllers\{
+    Controller,
+    AvatarController
+};
+use Auth, Cache, URL;
+use App\{Avatar,Media,Profile};
+use App\Transformer\Api\{
+    AccountTransformer,
+    MediaTransformer,
+    StatusTransformer
+};
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
+use App\Jobs\AvatarPipeline\AvatarOptimize;
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
+use App\Jobs\VideoPipeline\{
+    VideoOptimize,
+    VideoPostProcess,
+    VideoThumbnail
+};
 
 class BaseApiController extends Controller
 {
@@ -187,7 +192,20 @@ class BaseApiController extends Controller
         $url = URL::temporarySignedRoute(
             'temp-media', now()->addHours(1), ['profileId' => $profile->id, 'mediaId' => $media->id]
         );
-        ImageOptimize::dispatch($media);
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+                ImageOptimize::dispatch($media);
+                break;
+
+            case 'video/mp4':
+                VideoThumbnail::dispatch($media);
+                break;
+            
+            default:
+                break;
+        }
 
         $res = [
             'id'          => $media->id,

+ 10 - 0
app/Http/Controllers/CollectionController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CollectionController extends Controller
+{
+    //
+}

+ 10 - 0
app/Http/Controllers/CollectionItemController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CollectionItemController extends Controller
+{
+    //
+}

+ 55 - 0
app/Http/Controllers/DirectMessageController.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Auth;
+use Illuminate\Http\Request;
+use App\{
+	DirectMessage,
+	Profile,
+	Status
+};
+
+class DirectMessageController extends Controller
+{
+    public function __construct()
+    {
+    	$this->middleware('auth');
+    }
+
+    public function inbox(Request $request)
+    {
+    	$profile = Auth::user()->profile;
+    	$inbox = DirectMessage::whereToId($profile->id)
+    		->with(['author','status'])
+    		->orderBy('created_at', 'desc')
+    		->groupBy('from_id')
+    		->paginate(10);
+    	return view('account.messages', compact('inbox'));
+
+    }
+
+    public function show(Request $request, int $pid, $mid)
+    {
+    	$profile = Auth::user()->profile;
+
+    	if($pid !== $profile->id) { 
+    		abort(403); 
+    	}
+
+    	$msg = DirectMessage::whereToId($profile->id)
+    		->findOrFail($mid);
+
+    	$thread = DirectMessage::whereToId($profile->id)
+    		->orWhere([['from_id', $profile->id],['to_id', $msg->from_id]])
+    		->orderBy('created_at', 'desc')
+    		->paginate(10);
+
+    	return view('account.message', compact('msg', 'profile', 'thread'));
+    }
+
+    public function compose(Request $request)
+    {
+        $profile = Auth::user()->profile;
+    }
+}

+ 6 - 48
app/Http/Controllers/DiscoverController.php

@@ -21,53 +21,7 @@ class DiscoverController extends Controller
 
     public function home(Request $request)
     {
-        $this->validate($request, [
-          'page' => 'nullable|integer|max:50'
-        ]);
-
-        $pid = Auth::user()->profile->id;
-
-        $following = Cache::remember('feature:discover:following:'.$pid, 720, function() use($pid) {
-          $following = Follower::select('following_id')
-                      ->whereProfileId($pid)
-                      ->pluck('following_id');
-          $filtered = UserFilter::select('filterable_id')
-                    ->whereUserId($pid)
-                    ->whereFilterableType('App\Profile')
-                    ->whereIn('filter_type', ['mute', 'block'])
-                    ->pluck('filterable_id');
-          $following->push($pid);
-          
-          if($filtered->count() > 0) {
-            $following->push($filtered);
-          }
-          return $following;
-        });
-
-        $people = Cache::remember('feature:discover:people:'.$pid, 15, function() use($following) {
-            return Profile::select('id', 'name', 'username')->inRandomOrder()
-                ->whereHas('statuses')
-                ->whereNull('domain')
-                ->whereNotIn('id', $following)
-                ->whereIsPrivate(false)
-                ->take(3)
-                ->get();
-        });
-
-        $posts = Status::select('id', 'caption', 'profile_id')
-          ->whereHas('media')
-          ->whereHas('profile', function($q) {
-            $q->where('is_private', false);
-          })
-          ->whereIsNsfw(false)
-          ->whereVisibility('public')
-          ->where('profile_id', '<>', $pid)
-          ->whereNotIn('profile_id', $following)
-          ->withCount(['comments', 'likes'])
-          ->orderBy('created_at', 'desc')
-          ->simplePaginate(21);
-
-        return view('discover.home', compact('people', 'posts'));
+        return view('discover.home');
     }
 
     public function showTags(Request $request, $hashtag)
@@ -82,13 +36,17 @@ class DiscoverController extends Controller
           ->firstOrFail();
 
         $posts = $tag->posts()
+          ->whereHas('media')
           ->withCount(['likes', 'comments'])
           ->whereIsNsfw(false)
           ->whereVisibility('public')
-          ->has('media')
           ->orderBy('id', 'desc')
           ->simplePaginate(12);
 
+        if($posts->count() == 0) {
+          abort(404);
+        }
+        
         return view('discover.tags.show', compact('tag', 'posts'));
     }
 }

+ 51 - 1
app/Http/Controllers/FederationController.php

@@ -13,6 +13,7 @@ use Cache;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use League\Fractal;
+use App\Util\ActivityPub\Helpers;
 
 class FederationController extends Controller
 {
@@ -133,6 +134,19 @@ class FederationController extends Controller
         return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
     }
 
+    public function hostMeta(Request $request)
+    {
+        $path = route('well-known.webfinger');
+        $xml = <<<XML
+<?xml version="1.0" encoding="UTF-8"?>
+<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
+  <Link rel="lrdd" type="application/xrd+xml" template="{$path}?resource={uri}"/>
+</XRD>
+XML;
+
+        return response($xml)->header('Content-Type', 'application/xrd+xml');
+    }
+
     public function userOutbox(Request $request, $username)
     {
         if (config('pixelfed.activitypub_enabled') == false) {
@@ -153,6 +167,42 @@ class FederationController extends Controller
 
     public function userInbox(Request $request, $username)
     {
-        return;
+        // todo
+    }
+
+    public function userFollowing(Request $request, $username)
+    {
+        if (config('pixelfed.activitypub_enabled') == false) {
+            abort(403);
+        }
+        $profile = Profile::whereNull('remote_url')->whereUsername($username)->firstOrFail();
+        $obj = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id'       => $request->getUri(),
+            'type'     => 'OrderedCollectionPage',
+            'totalItems' => $profile->following()->count(),
+            'orderedItems' => $profile->following->map(function($f) {
+                return $f->permalink();
+            })
+        ];
+        return response()->json($obj); 
+    }
+
+    public function userFollowers(Request $request, $username)
+    {
+        if (config('pixelfed.activitypub_enabled') == false) {
+            abort(403);
+        }
+        $profile = Profile::whereNull('remote_url')->whereUsername($username)->firstOrFail();
+        $obj = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id'       => $request->getUri(),
+            'type'     => 'OrderedCollectionPage',
+            'totalItems' => $profile->followers()->count(),
+            'orderedItems' => $profile->followers->map(function($f) {
+                return $f->permalink();
+            })
+        ];
+        return response()->json($obj); 
     }
 }

+ 3 - 1
app/Http/Controllers/HomeController.php

@@ -2,6 +2,8 @@
 
 namespace App\Http\Controllers;
 
+use Illuminate\Http\Request;
+
 class HomeController extends Controller
 {
     /**
@@ -19,7 +21,7 @@ class HomeController extends Controller
      *
      * @return \Illuminate\Http\Response
      */
-    public function index()
+    public function index(Request $request)
     {
         return view('home');
     }

+ 151 - 0
app/Http/Controllers/Import/Instagram.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace App\Http\Controllers\Import;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Auth, DB;
+use App\{
+	ImportData,
+	ImportJob,
+	Profile, 
+	User
+};
+
+trait Instagram
+{
+    public function instagram()
+    {
+      return view('settings.import.instagram.home');
+    }
+
+    public function instagramStart(Request $request)
+    {	
+    	$job = $this->instagramRedirectOrNew();
+    	return redirect($job->url());
+    }
+
+    protected function instagramRedirectOrNew()
+    {
+    	$profile = Auth::user()->profile;
+    	$exists = ImportJob::whereProfileId($profile->id)
+    		->whereService('instagram')
+    		->whereNull('completed_at')
+    		->exists();
+    	if($exists) {
+    		$job = ImportJob::whereProfileId($profile->id)
+    		->whereService('instagram')
+    		->whereNull('completed_at')
+    		->first();
+    	} else {
+    		$job = new ImportJob;
+    		$job->profile_id = $profile->id;
+    		$job->service = 'instagram';
+    		$job->uuid = (string) Str::uuid();
+    		$job->stage = 1;
+    		$job->save();
+    	}
+    	return $job;
+    }
+
+    public function instagramStepOne(Request $request, $uuid)
+    {
+    	$profile = Auth::user()->profile;
+    	$job = ImportJob::whereProfileId($profile->id)
+    		->whereNull('completed_at')
+    		->whereUuid($uuid)
+    		->whereStage(1)
+    		->firstOrFail();
+    	return view('settings.import.instagram.step-one', compact('profile', 'job'));
+    }
+
+    public function instagramStepOneStore(Request $request, $uuid)
+    {
+    	$this->validate($request, [
+    		'media.*' => 'required|mimes:bin,jpeg,png,gif|max:500',
+    		//'mediajson' => 'required|file|mimes:json'
+    	]);
+    	$media = $request->file('media');
+
+    	$profile = Auth::user()->profile;
+    	$job = ImportJob::whereProfileId($profile->id)
+    		->whereNull('completed_at')
+    		->whereUuid($uuid)
+    		->whereStage(1)
+    		->firstOrFail();
+    		
+        foreach ($media as $k => $v) {
+        	$original = $v->getClientOriginalName();
+    		if(strlen($original) < 32 || $k > 100) {
+    			continue;
+    		}
+            $storagePath = "import/{$job->uuid}";
+            $path = $v->store($storagePath);
+            DB::transaction(function() use ($profile, $job, $path, $original) {
+		        $data = new ImportData;
+		        $data->profile_id = $profile->id;
+		        $data->job_id = $job->id;
+		        $data->service = 'instagram';
+		        $data->path = $path;
+		        $data->stage = $job->stage;
+		        $data->original_name = $original;
+		        $data->save();
+            });
+        }
+        DB::transaction(function() use ($profile, $job) {
+        	$job->stage = 2;
+        	$job->save();
+    	});
+        return redirect($job->url());
+    	return view('settings.import.instagram.step-one', compact('profile', 'job'));
+    }
+
+    public function instagramStepTwo(Request $request, $uuid)
+    {
+    	$profile = Auth::user()->profile;
+    	$job = ImportJob::whereProfileId($profile->id)
+    		->whereNull('completed_at')
+    		->whereUuid($uuid)
+    		->whereStage(2)
+    		->firstOrFail();
+    	return view('settings.import.instagram.step-two', compact('profile', 'job'));
+    }
+
+    public function instagramStepTwoStore(Request $request, $uuid)
+    {
+    	$this->validate($request, [
+    		'media' => 'required|file|max:1000'
+    	]);
+    	$profile = Auth::user()->profile;
+    	$job = ImportJob::whereProfileId($profile->id)
+    		->whereNull('completed_at')
+    		->whereUuid($uuid)
+    		->whereStage(2)
+    		->firstOrFail();
+    	$media = $request->file('media');
+    	$file = file_get_contents($media);
+		$json = json_decode($file, true);
+		if(!$json || !isset($json['photos'])) {
+			return abort(500);
+		}
+		$storagePath = "import/{$job->uuid}";
+        $path = $media->store($storagePath);
+        $job->media_json = $path;
+        $job->stage = 3;
+        $job->save();
+        return redirect($job->url());
+		return $json;
+
+    }
+
+    public function instagramStepThree(Request $request, $uuid)
+    {
+    	$profile = Auth::user()->profile;
+    	$job = ImportJob::whereProfileId($profile->id)
+    		->whereNull('completed_at')
+    		->whereUuid($uuid)
+    		->whereStage(3)
+    		->firstOrFail();
+    	return view('settings.import.instagram.step-three', compact('profile', 'job'));
+    }
+}

+ 13 - 0
app/Http/Controllers/Import/Mastodon.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Http\Controllers\Import;
+
+use Illuminate\Http\Request;
+
+trait Mastodon
+{
+    public function mastodon()
+    {
+      return view('settings.import.mastodon.home');
+    }
+}

+ 16 - 0
app/Http/Controllers/ImportController.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class ImportController extends Controller
+{
+    use Import\Instagram, Import\Mastodon;
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+}

+ 120 - 55
app/Http/Controllers/InternalApiController.php

@@ -4,12 +4,17 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use App\{
+    DirectMessage,
+    Hashtag,
     Like,
     Media,
+    Notification,
     Profile,
+    StatusHashtag,
     Status,
 };
 use Auth,Cache;
+use Carbon\Carbon;
 use League\Fractal;
 use App\Transformer\Api\{
     AccountTransformer,
@@ -30,60 +35,6 @@ class InternalApiController extends Controller
         $this->fractal->setSerializer(new ArraySerializer());
     }
 
-    public function status(Request $request, $username, int $postid)
-    {
-        $auth = Auth::user()->profile;
-        $profile = Profile::whereUsername($username)->first();
-        $status = Status::whereProfileId($profile->id)->find($postid);
-        $status = new Fractal\Resource\Item($status, new StatusTransformer());
-        $user = new Fractal\Resource\Item($auth, new AccountTransformer());
-        $res = [];
-        $res['status'] = $this->fractal->createData($status)->toArray();
-        $res['user'] = $this->fractal->createData($user)->toArray();
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
-    }
-
-    public function statusComments(Request $request, $username, int $postId)
-    {
-        $this->validate($request, [
-            'min_id'    => 'nullable|integer|min:1',
-            'max_id'    => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
-            'limit'     => 'nullable|integer|min:5|max:50'
-        ]);
-        $limit = $request->limit ?? 10;
-        $auth = Auth::user()->profile;
-        $profile = Profile::whereUsername($username)->first();
-        $status = Status::whereProfileId($profile->id)->find($postId);
-        if($request->filled('min_id') || $request->filled('max_id')) {
-            $q = false;
-            $limit = 50;
-            if($request->filled('min_id')) {
-                $replies = $status->comments()
-                ->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
-                ->where('id', '>=', $request->min_id)
-                ->orderBy('id', 'desc')
-                ->paginate($limit);
-            }
-            if($request->filled('max_id')) {
-                $replies = $status->comments()
-                ->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
-                ->where('id', '<=', $request->max_id)
-                ->orderBy('id', 'desc')
-                ->paginate($limit);
-            }
-        } else {
-            $replies = $status->comments()
-            ->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
-            ->orderBy('id', 'desc')
-            ->paginate($limit);
-        }
-
-        $resource = new Fractal\Resource\Collection($replies, new StatusTransformer(), 'data');
-        $resource->setPaginator(new IlluminatePaginatorAdapter($replies));
-        $res = $this->fractal->createData($resource)->toArray();
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
-    }
-
     public function compose(Request $request)
     {
         $this->validate($request, [
@@ -101,13 +52,15 @@ class InternalApiController extends Controller
         $attachments = [];
         $status = new Status;
 
-        foreach($medias as $media) {
+        foreach($medias as $k => $media) {
             $m = Media::findOrFail($media['id']);
             if($m->profile_id !== $profile->id || $m->status_id) {
                 abort(403, 'Invalid media id');
             }
             $m->filter_class = $media['filter'];
             $m->license = $media['license'];
+            $m->caption = strip_tags($media['alt']);
+            $m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
             if($media['cw'] == true) {
                 $m->is_nsfw = true;
                 $status->is_nsfw = true;
@@ -135,4 +88,116 @@ class InternalApiController extends Controller
 
         return $status->url();
     }
+
+    public function notifications(Request $request)
+    {
+        $this->validate($request, [
+          'page' => 'nullable|min:1|max:3',
+        ]);
+        $profile = Auth::user()->profile;
+        $timeago = Carbon::now()->subMonths(6);
+        $notifications = Notification::with('actor')
+        ->whereProfileId($profile->id)
+        ->whereDate('created_at', '>', $timeago)
+        ->orderBy('id', 'desc')
+        ->simplePaginate(30);
+        $notifications = $notifications->map(function($k, $v) {
+            return [
+                'id' => $k->id,
+                'action' => $k->action,
+                'message' => $k->message,
+                'rendered' => $k->rendered,
+                'actor' => [
+                    'avatar' => $k->actor->avatarUrl(),
+                    'username' => $k->actor->username,
+                    'url' => $k->actor->url(),
+                ],
+                'url' => $k->item->url(),
+                'read_at' => $k->read_at,
+            ];
+        });
+        return response()->json($notifications, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function discover(Request $request)
+    {
+        $profile = Auth::user()->profile;
+        
+        $following = Cache::get('feature:discover:following:'.$profile->id, []);
+        $people = Profile::select('id', 'name', 'username')
+            ->with('avatar')
+            ->inRandomOrder()
+            ->whereHas('statuses')
+            ->whereNull('domain')
+            ->whereNotIn('id', $following)
+            ->whereIsPrivate(false)
+            ->take(3)
+            ->get();
+
+        $posts = Status::select('id', 'caption', 'profile_id')
+          ->whereHas('media')
+          ->whereHas('profile', function($q) {
+            $q->where('is_private', false);
+          })
+          ->whereIsNsfw(false)
+          ->whereVisibility('public')
+          ->where('profile_id', '<>', $profile->id)
+          ->whereNotIn('profile_id', $following)
+          ->withCount(['comments', 'likes'])
+          ->orderBy('created_at', 'desc')
+          ->take(21)
+          ->get();
+
+        $res = [
+            'people' => $people->map(function($profile) {
+                return [
+                    'avatar' => $profile->avatarUrl(),
+                    'name' => $profile->name,
+                    'username' => $profile->username,
+                    'url'   => $profile->url(),
+                ];
+            }),
+            'posts' => $posts->map(function($post) {
+                return [
+                    'url' => $post->url(),
+                    'thumb' => $post->thumb(),
+                    'comments_count' => $post->comments_count,
+                    'likes_count' => $post->likes_count,
+                ];
+            })
+        ];
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+    public function directMessage(Request $request, $profileId, $threadId)
+    {
+        $profile = Auth::user()->profile;
+
+        if($profileId != $profile->id) { 
+            abort(403); 
+        }
+
+        $msg = DirectMessage::whereToId($profile->id)
+            ->orWhere('from_id',$profile->id)
+            ->findOrFail($threadId);
+
+        $thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
+            ->whereIn('from_id', [$profile->id,$msg->from_id])
+            ->orderBy('created_at', 'asc')
+            ->paginate(30);
+
+        return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function notificationMarkAllRead(Request $request)
+    {
+        $profile = Auth::user()->profile;
+
+        $notifications = Notification::whereProfileId($profile->id)->get();
+        foreach($notifications as $n) {
+            $n->read_at = Carbon::now();
+            $n->save();
+        }
+
+        return;
+    }
 }

+ 2 - 0
app/Http/Controllers/ProfileController.php

@@ -34,6 +34,8 @@ class ProfileController extends Controller
         if ($user->remote_url) {
             $settings = new \StdClass;
             $settings->crawlable = false;
+            $settings->show_profile_follower_count = true;
+            $settings->show_profile_following_count = true;
         } else {
             $settings = User::whereUsername($username)->firstOrFail()->settings;
         }

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

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\{
+    Hashtag,
+    Like,
+    Media,
+    Notification,
+    Profile,
+    StatusHashtag,
+    Status,
+};
+use Auth,Cache;
+use Carbon\Carbon;
+use League\Fractal;
+use App\Transformer\Api\{
+    AccountTransformer,
+    StatusTransformer,
+};
+use App\Jobs\StatusPipeline\NewStatusPipeline;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+
+
+class PublicApiController extends Controller
+{
+    protected $fractal;
+
+    public function __construct()
+    {
+        $this->middleware('throttle:200, 15');
+        $this->fractal = new Fractal\Manager();
+        $this->fractal->setSerializer(new ArraySerializer());
+    }
+
+    protected function getUserData()
+    {
+    	if(false == Auth::check()) {
+    		return [];
+    	} else {
+	        $profile = Auth::user()->profile;
+	        $user = new Fractal\Resource\Item($profile, new AccountTransformer());
+        	return $this->fractal->createData($user)->toArray();
+    	}
+    }
+
+    public function status(Request $request, $username, int $postid)
+    {
+        $profile = Profile::whereUsername($username)->first();
+        $status = Status::whereProfileId($profile->id)->find($postid);
+        $item = new Fractal\Resource\Item($status, new StatusTransformer());
+        $res = [
+        	'status' => $this->fractal->createData($item)->toArray(),
+        	'user' => $this->getUserData(),
+            'reactions' => [
+                'liked' => $status->liked(),
+                'shared' => $status->shared(),
+                'bookmarked' => $status->bookmarked(),
+            ],
+        ];
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function statusComments(Request $request, $username, int $postId)
+    {
+        $this->validate($request, [
+            'min_id'    => 'nullable|integer|min:1',
+            'max_id'    => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
+            'limit'     => 'nullable|integer|min:5|max:50'
+        ]);
+        $limit = $request->limit ?? 10;
+        $profile = Profile::whereUsername($username)->first();
+        $status = Status::whereProfileId($profile->id)->find($postId);
+        if($request->filled('min_id') || $request->filled('max_id')) {
+            if($request->filled('min_id')) {
+                $replies = $status->comments()
+                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+                ->where('id', '>=', $request->min_id)
+                ->orderBy('id', 'desc')
+                ->paginate($limit);
+            }
+            if($request->filled('max_id')) {
+                $replies = $status->comments()
+                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+                ->where('id', '<=', $request->max_id)
+                ->orderBy('id', 'desc')
+                ->paginate($limit);
+            }
+        } else {
+            $replies = $status->comments()
+            ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+            ->orderBy('id', 'desc')
+            ->paginate($limit);
+        }
+
+        $resource = new Fractal\Resource\Collection($replies, new StatusTransformer(), 'data');
+        $resource->setPaginator(new IlluminatePaginatorAdapter($replies));
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+}

+ 5 - 2
app/Http/Controllers/SearchController.php

@@ -38,7 +38,10 @@ class SearchController extends Controller
                 });
                 $tokens->push($tags);
             }
-            $users = Profile::select('username', 'name', 'id')->where('username', 'like', '%'.$tag.'%')->limit(20)->get();
+            $users = Profile::select('username', 'name', 'id')
+                ->where('username', 'like', '%'.$tag.'%')
+                ->limit(20)
+                ->get();
 
             if($users->count() > 0) {
                 $profiles = $users->map(function ($item, $key) {
@@ -71,7 +74,7 @@ class SearchController extends Controller
                     'count'  => 0,
                     'url'    => $item->url(),
                     'type'   => 'status',
-                    'value'  => 'Posted '.$item->created_at->diffForHumans(),
+                    'value'  => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
                     'tokens' => [$item->caption],
                     'name'   => $item->caption,
                     'thumb'  => $item->thumb(),

+ 5 - 4
app/Http/Controllers/Settings/HomeSettings.php

@@ -11,6 +11,7 @@ use App\UserFilter;
 use App\Util\Lexer\PrettyNumber;
 use Auth;
 use DB;
+use Purify;
 use Illuminate\Http\Request;
 
 trait HomeSettings
@@ -40,8 +41,8 @@ trait HomeSettings
       ]);
 
         $changes = false;
-        $name = $request->input('name');
-        $bio = $request->input('bio');
+        $name = strip_tags($request->input('name'));
+        $bio = $request->filled('bio') ? Purify::clean($request->input('bio')) : null;
         $website = $request->input('website');
         $email = $request->input('email');
         $user = Auth::user();
@@ -79,12 +80,12 @@ trait HomeSettings
                 $profile->name = $name;
             }
 
-            if (!$profile->website || $profile->website != $website) {
+            if ($profile->website != $website) {
                 $changes = true;
                 $profile->website = $website;
             }
 
-            if (!$profile->bio || !$profile->bio != $bio) {
+            if ($profile->bio != $bio) {
                 $changes = true;
                 $profile->bio = $bio;
             }

+ 1 - 0
app/Http/Controllers/SettingsController.php

@@ -8,6 +8,7 @@ use App\UserFilter;
 use Auth;
 use DB;
 use Cache;
+use Purify;
 use Illuminate\Http\Request;
 use App\Http\Controllers\Settings\{
     HomeSettings,

+ 22 - 22
app/Http/Controllers/SiteController.php

@@ -33,12 +33,19 @@ class SiteController extends Controller
     {
         $pid = Auth::user()->profile->id;
         // TODO: Use redis for timelines
-        $following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
-        $following->push(Auth::user()->profile->id);
-        $filtered = UserFilter::whereUserId($pid)
-                  ->whereFilterableType('App\Profile')
-                  ->whereIn('filter_type', ['mute', 'block'])
-                  ->pluck('filterable_id');
+        $following = Cache::rememberForever("user:following:list:$pid", function() use($pid) {
+          $following = Follower::whereProfileId($pid)->pluck('following_id');
+          $following->push($pid);
+          return $following->toArray();
+        });
+
+        $filtered = Cache::rememberForever("user:filter:list:$pid", function() use($pid) {
+          return UserFilter::whereUserId($pid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereIn('filter_type', ['mute', 'block'])
+                    ->pluck('filterable_id')->toArray();
+        });
+
         $timeline = Status::whereIn('profile_id', $following)
                   ->whereNotIn('profile_id', $filtered)
                   ->whereHas('media')
@@ -53,29 +60,22 @@ class SiteController extends Controller
 
     public function changeLocale(Request $request, $locale)
     {
-        if (!App::isLocale($locale)) {
-            return redirect()->back();
+        // todo: add other locales after pushing new l10n strings
+        $locales = ['en'];
+        if(in_array($locale, $locales)) {
+          session()->put('locale', $locale);
         }
-        App::setLocale($locale);
 
         return redirect()->back();
     }
 
     public function about()
     {
-        $res = Cache::remember('site:page:about', 15, function () {
-            $statuses = Status::whereHas('media')
-              ->whereNull('in_reply_to_id')
-              ->whereNull('reblog_of_id')
-              ->count();
-            $statusCount = PrettyNumber::convert($statuses);
-            $userCount = PrettyNumber::convert(User::count());
-            $remoteCount = PrettyNumber::convert(Profile::whereNotNull('remote_url')->count());
-            $adminContact = User::whereIsAdmin(true)->first();
-
-            return view('site.about')->with(compact('statusCount', 'userCount', 'remoteCount', 'adminContact'))->render();
-        });
+        return view('site.about');
+    }
 
-        return $res;
+    public function language()
+    {
+      return view('site.language');
     }
 }

+ 6 - 33
app/Http/Controllers/StatusController.php

@@ -22,8 +22,7 @@ class StatusController extends Controller
         $user = Profile::whereUsername($username)->firstOrFail();
 
         $status = Status::whereProfileId($user->id)
-                ->where('visibility', '!=', 'draft')
-                ->withCount(['likes', 'comments', 'media'])
+                ->whereNotIn('visibility',['draft','direct'])
                 ->findOrFail($id);
 
         if($status->visibility == 'private' || $user->is_private) {
@@ -40,34 +39,8 @@ class StatusController extends Controller
             return $this->showActivityPub($request, $status);
         }
 
-        $template = $this->detectTemplate($status);
-
-        $replies = Status::whereInReplyToId($status->id)->orderBy('created_at', 'desc')->simplePaginate(30);
-
-        return view($template, compact('user', 'status', 'replies'));
-    }
-
-    protected function detectTemplate($status)
-    {
-        $template = Cache::rememberForever('template:status:type:'.$status->id, function () use ($status) {
-            $template = 'status.show.photo';
-            if (!$status->media_path && $status->in_reply_to_id) {
-                $template = 'status.reply';
-            }
-            if ($status->media->count() > 1) {
-                $template = 'status.show.album';
-            }
-            if ($status->viewType() == 'video') {
-                $template = 'status.show.video';
-            }
-            if ($status->viewType() == 'video-album') {
-                $template = 'status.show.video-album';
-            }
-
-            return $template;
-        });
-
-        return $template;
+        $template = $status->in_reply_to_id ? 'status.reply' : 'status.show';
+        return view($template, compact('user', 'status'));
     }
 
     public function compose()
@@ -148,9 +121,7 @@ class StatusController extends Controller
 
     public function delete(Request $request)
     {
-        if (!Auth::check()) {
-            abort(403);
-        }
+        $this->authCheck();
 
         $this->validate($request, [
           'type'  => 'required|string',
@@ -168,6 +139,8 @@ class StatusController extends Controller
 
     public function storeShare(Request $request)
     {
+        $this->authCheck();
+        
         $this->validate($request, [
           'item'    => 'required|integer',
         ]);

+ 14 - 29
app/Http/Controllers/TimelineController.php

@@ -2,12 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use Auth, Cache;
 use App\Follower;
 use App\Profile;
 use App\Status;
 use App\User;
 use App\UserFilter;
-use Auth;
+use Illuminate\Http\Request;
 
 class TimelineController extends Controller
 {
@@ -17,39 +18,23 @@ class TimelineController extends Controller
         $this->middleware('twofactor');
     }
 
-    public function personal()
-    {
-        $pid = Auth::user()->profile->id;
-        // TODO: Use redis for timelines
-        $following = Follower::whereProfileId($pid)->pluck('following_id');
-        $following->push($pid);
-        $filtered = UserFilter::whereUserId($pid)
-                  ->whereFilterableType('App\Profile')
-                  ->whereIn('filter_type', ['mute', 'block'])
-                  ->pluck('filterable_id');
-        $timeline = Status::whereIn('profile_id', $following)
-                  ->whereNotIn('profile_id', $filtered)
-                  ->whereVisibility('public')
-                  ->orderBy('created_at', 'desc')
-                  ->withCount(['comments', 'likes'])
-                  ->simplePaginate(20);
-        $type = 'personal';
-
-        return view('timeline.template', compact('timeline', 'type'));
-    }
-
-    public function local()
+    public function local(Request $request)
     {
+        $this->validate($request,[
+          'page' => 'nullable|integer|max:20'
+        ]);
         // TODO: Use redis for timelines
         // $timeline = Timeline::build()->local();
         $pid = Auth::user()->profile->id;
 
-        $filtered = UserFilter::whereUserId($pid)
-                  ->whereFilterableType('App\Profile')
-                  ->whereIn('filter_type', ['mute', 'block'])
-                  ->pluck('filterable_id');
+        $filtered = Cache::rememberForever("user:filter:list:$pid", function() use($pid) {
+          return UserFilter::whereUserId($pid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereIn('filter_type', ['mute', 'block'])
+                    ->pluck('filterable_id')->toArray();
+        });
         $private = Profile::whereIsPrivate(true)->pluck('id');
-        $filtered = $filtered->merge($private);
+        $filtered = array_merge($private->toArray(), $filtered);
         $timeline = Status::whereHas('media')
                   ->whereNotIn('profile_id', $filtered)
                   ->whereNull('in_reply_to_id')
@@ -57,7 +42,7 @@ class TimelineController extends Controller
                   ->whereVisibility('public')
                   ->withCount(['comments', 'likes'])
                   ->orderBy('created_at', 'desc')
-                  ->simplePaginate(20);
+                  ->simplePaginate(10);
         $type = 'local';
 
         return view('timeline.template', compact('timeline', 'type'));

+ 1 - 0
app/Http/Kernel.php

@@ -58,6 +58,7 @@ class Kernel extends HttpKernel
         'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
         'can'           => \Illuminate\Auth\Middleware\Authorize::class,
         'dangerzone'    => \App\Http\Middleware\DangerZone::class,
+        'localization'  => \App\Http\Middleware\Localization::class,
         'guest'         => \App\Http\Middleware\RedirectIfAuthenticated::class,
         'signed'        => \Illuminate\Routing\Middleware\ValidateSignature::class,
         'throttle'      => \Illuminate\Routing\Middleware\ThrottleRequests::class,

+ 23 - 0
app/Http/Middleware/Localization.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure, Session;
+
+class Localization
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \Closure  $next
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        if(Session::has('locale')) {
+            app()->setLocale(Session::get('locale'));
+        }
+        return $next($request);
+    }
+}

+ 115 - 0
app/Jobs/ImportPipeline/ImportInstagram.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Jobs\ImportPipeline;
+
+use DB;
+use Carbon\Carbon;
+use Illuminate\Bus\Queueable;
+use Illuminate\Filesystem\Filesystem;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
+use App\Jobs\StatusPipeline\NewStatusPipeline;
+use App\{
+    ImportJob,
+    ImportData,
+    Media,
+    Profile,
+    Status,
+};
+
+class ImportInstagram implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+    
+    protected $job;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(ImportJob $job)
+    {
+        $this->job = $job;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $job = $this->job;
+        $profile = $this->job->profile;
+        $json = $job->mediaJson();
+        $collection = $json['photos'];
+        $files = $job->files;
+        $monthHash = hash('sha1', date('Y').date('m'));
+        $userHash = hash('sha1', $profile->id . (string) $profile->created_at);
+        $fs = new Filesystem;
+
+        foreach($collection as $import)
+        {
+            $caption = $import['caption'];
+            try {
+                $min = Carbon::create(2010, 10, 6, 0, 0, 0);
+                $taken_at = Carbon::parse($import['taken_at']);
+                if(!$min->lt($taken_at)) {
+                    $taken_at = Carbon::now();
+                }
+            } catch (Exception $e) {
+                
+            }
+            $filename = last( explode('/', $import['path']) );
+            $importData = ImportData::whereJobId($job->id)
+                ->whereOriginalName($filename)
+                ->firstOrFail();
+
+            if(is_file(storage_path("app/$importData->path")) == false) {
+                continue;
+            }
+
+            DB::transaction(function() use(
+                $fs, $job, $profile, $caption, $taken_at, $filename,
+                $monthHash, $userHash, $importData
+            ) {
+                $status = new Status();
+                $status->profile_id = $profile->id;
+                $status->caption = strip_tags($caption);
+                $status->is_nsfw = false;
+                $status->visibility = 'public';
+                $status->created_at = $taken_at;
+                $status->save();
+
+
+                $path = storage_path("app/$importData->path");
+                $storagePath = "public/m/{$monthHash}/{$userHash}";
+                $newPath = "app/$storagePath/$filename";
+                $fs->move($path,storage_path($newPath));
+                $path = $newPath;
+                $hash = \hash_file('sha256', storage_path($path));
+                $media = new Media();
+                $media->status_id = $status->id;
+                $media->profile_id = $profile->id;
+                $media->user_id = $profile->user->id;
+                $media->media_path = "$storagePath/$filename";
+                $media->original_sha256 = $hash;
+                $media->size = $fs->size(storage_path($path));
+                $media->mime = $fs->mimeType(storage_path($path));
+                $media->filter_class = null;
+                $media->filter_name = null;
+                $media->order = 1;
+                $media->save();
+                ImageOptimize::dispatch($media);
+                NewStatusPipeline::dispatch($status);
+            });
+        }
+
+        $job->completed_at = Carbon::now();
+        $job->save();
+    }
+}

+ 4 - 4
app/Jobs/InboxPipeline/InboxWorker.php

@@ -14,7 +14,7 @@ class InboxWorker implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
-    protected $request;
+    protected $headers;
     protected $profile;
     protected $payload;
 
@@ -23,9 +23,9 @@ class InboxWorker implements ShouldQueue
      *
      * @return void
      */
-    public function __construct($request, Profile $profile, $payload)
+    public function __construct($headers, $profile, $payload)
     {
-        $this->request = $request;
+        $this->headers = $headers;
         $this->profile = $profile;
         $this->payload = $payload;
     }
@@ -37,6 +37,6 @@ class InboxWorker implements ShouldQueue
      */
     public function handle()
     {
-        (new Inbox($this->request, $this->profile, $this->payload))->handle();
+        (new Inbox($this->headers, $this->profile, $this->payload))->handle();
     }
 }

+ 2 - 2
app/Jobs/LikePipeline/LikePipeline.php

@@ -41,8 +41,8 @@ class LikePipeline implements ShouldQueue
         $status = $this->like->status;
         $actor = $this->like->actor;
 
-        if ($status->url !== null) {
-            // Ignore notifications to remote statuses
+        if (!$status || $status->url !== null) {
+            // Ignore notifications to remote statuses, or deleted statuses
             return;
         }
 

+ 1 - 0
app/Jobs/RemoteFollowPipeline/RemoteFollowImportRecent.php

@@ -207,6 +207,7 @@ class RemoteFollowImportRecent implements ShouldQueue
 
         try {
             $info = pathinfo($url);
+            $url = str_replace(' ', '%20', $url);
             $img = file_get_contents($url);
             $file = '/tmp/'.str_random(12).$info['basename'];
             file_put_contents($file, $img);

+ 1 - 1
app/Jobs/RemoteFollowPipeline/RemoteFollowPipeline.php

@@ -83,7 +83,7 @@ class RemoteFollowPipeline implements ShouldQueue
         $profile->domain = $domain;
         $profile->username = $remoteUsername;
         $profile->name = $res['name'];
-        $profile->bio = str_limit($res['summary'], 125);
+        $profile->bio = Purify::clean($res['summary']);
         $profile->sharedInbox = $res['endpoints']['sharedInbox'];
         $profile->remote_url = $res['url'];
         $profile->save();

+ 79 - 0
app/Jobs/SharePipeline/SharePipeline.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Jobs\SharePipeline;
+
+use App\Status;
+use App\Notification;
+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 Log;
+use Redis;
+
+class SharePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $like;
+
+    /**
+     * 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;
+        $actor = $this->status->profile;
+        $target = $this->status->parent()->profile;
+
+        if ($status->url !== null) {
+            // Ignore notifications to remote statuses
+            return;
+        }
+
+        $exists = Notification::whereProfileId($status->profile_id)
+                  ->whereActorId($actor->id)
+                  ->whereAction('like')
+                  ->whereItemId($status->id)
+                  ->whereItemType('App\Status')
+                  ->count();
+
+        if ($actor->id === $status->profile_id || $exists !== 0) {
+            return true;
+        }
+
+        try {
+            $notification = new Notification();
+            $notification->profile_id = $status->profile_id;
+            $notification->actor_id = $actor->id;
+            $notification->action = 'like';
+            $notification->message = $like->toText();
+            $notification->rendered = $like->toHtml();
+            $notification->item_id = $status->id;
+            $notification->item_type = "App\Status";
+            $notification->save();
+
+            Cache::forever('notification.'.$notification->id, $notification);
+
+            $redis = Redis::connection();
+            $key = config('cache.prefix').':user.'.$status->profile_id.'.notifications';
+            $redis->lpush($key, $notification->id);
+        } catch (Exception $e) {
+            Log::error($e);
+        }
+    }
+}

+ 1 - 1
app/Jobs/StatusPipeline/NewStatusPipeline.php

@@ -37,7 +37,7 @@ class NewStatusPipeline implements ShouldQueue
         $status = $this->status;
 
         StatusEntityLexer::dispatch($status);
-        //StatusActivityPubDeliver::dispatch($status);
+        StatusActivityPubDeliver::dispatch($status);
 
         Cache::forever('post.'.$status->id, $status);
 

+ 16 - 0
app/Jobs/StatusPipeline/StatusActivityPubDeliver.php

@@ -8,6 +8,10 @@ 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\CreateNote;
+use App\Util\ActivityPub\Helpers;
 
 class StatusActivityPubDeliver implements ShouldQueue
 {
@@ -34,6 +38,18 @@ class StatusActivityPubDeliver implements ShouldQueue
     {
         $status = $this->status;
 
+        $audience = $status->profile->getAudienceInbox();
+        $profile = $status->profile;
+
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Item($status, new CreateNote());
+        $activity = $fractal->createData($resource)->toArray();
+
+        foreach($audience as $url) {
+            Helpers::sendSignedObject($profile, $url, $activity);
+        }
+
         // todo: fanout on write
     }
 }

+ 9 - 3
app/Jobs/StatusPipeline/StatusDelete.php

@@ -2,9 +2,12 @@
 
 namespace App\Jobs\StatusPipeline;
 
-use App\Notification;
-use App\Status;
-use App\StatusHashtag;
+use App\{
+    Notification,
+    Report,
+    Status,
+    StatusHashtag,
+};
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -73,6 +76,9 @@ class StatusDelete implements ShouldQueue
             ->whereItemId($status->id)
             ->delete();
         StatusHashtag::whereStatusId($status->id)->delete();
+        Report::whereObjectType('App\Status')
+            ->whereObjectId($status->id)
+            ->delete();
         $status->delete();
 
         return true;

+ 1 - 1
app/Jobs/StatusPipeline/StatusEntityLexer.php

@@ -69,7 +69,7 @@ class StatusEntityLexer implements ShouldQueue
         $this->storeMentions();
         DB::transaction(function () {
             $status = $this->status;
-            $status->rendered = $this->autolink;
+            $status->rendered = nl2br($this->autolink);
             $status->entities = json_encode($this->entities);
             $status->save();
         });

+ 34 - 0
app/Jobs/VideoPipeline/VideoOptimize.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Jobs\VideoPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class VideoOptimize 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()
+    {
+        //
+    }
+}

+ 34 - 0
app/Jobs/VideoPipeline/VideoPostProcess.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Jobs\VideoPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class VideoPostProcess 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()
+    {
+        //
+    }
+}

+ 62 - 0
app/Jobs/VideoPipeline/VideoThumbnail.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Jobs\VideoPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use FFMpeg;
+use App\Media;
+
+class VideoThumbnail implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Media $media)
+    {
+        $this->media = $media;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $media = $this->media;
+        $base = $media->media_path;
+        $path = explode('/', $base);
+        $name = last($path);
+        try {
+            $t = explode('.', $name);
+            $t = $t[0].'_thumb.png';
+            $i = count($path) - 1;
+            $path[$i] = $t;
+            $save = implode('/', $path);
+            $video = FFMpeg::open($base);
+            if($video->getDurationInSeconds() < 1) {
+                $video->getFrameFromSeconds(0);
+            } elseif($video->getDurationInSeconds() < 5) {
+                $video->getFrameFromSeconds(4);
+            }
+                $video->export()
+                ->save($save);
+
+            $media->thumbnail_path = $save;
+            $media->save();
+
+        } catch (Exception $e) {
+            
+        }
+    }
+}

+ 1 - 0
app/Like.php

@@ -15,6 +15,7 @@ class Like extends Model
      * @var array
      */
     protected $dates = ['deleted_at'];
+    protected $fillable = ['profile_id', 'status_id'];
 
     public function actor()
     {

+ 17 - 2
app/Media.php

@@ -19,8 +19,12 @@ class Media extends Model
 
     public function url()
     {
-        $path = $this->media_path;
-        $url = Storage::url($path);
+        if(!empty($this->remote_media) && $this->remote_url) {
+            $url = $this->remote_url;
+        } else {
+            $path = $this->media_path;
+            $url = Storage::url($path);
+        }
 
         return url($url);
     }
@@ -60,4 +64,15 @@ class Media extends Model
     {
         return json_decode($this->metadata, true, 3);
     }
+
+    public function getModel()
+    {
+        if(empty($this->metadata)) {
+            return false;
+        }
+        $meta = $this->getMetadata();
+        if($meta && isset($meta['Model'])) {
+            return $meta['Model'];
+        }
+    }
 }

+ 43 - 0
app/Profile.php

@@ -46,6 +46,9 @@ class Profile extends Model
 
     public function permalink($suffix = '')
     {
+        if($this->remote_url) {
+            return $this->remote_url;
+        }
         return url('users/'.$this->username.$suffix);
     }
 
@@ -248,4 +251,44 @@ class Profile extends Model
     {
         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();
+    }
 }

+ 1 - 2
app/Providers/RouteServiceProvider.php

@@ -65,8 +65,7 @@ class RouteServiceProvider extends ServiceProvider
      */
     protected function mapApiRoutes()
     {
-        Route::prefix('api')
-             ->middleware('api')
+        Route::middleware('api')
              ->namespace($this->namespace)
              ->group(base_path('routes/api.php'));
     }

+ 9 - 8
app/Report.php

@@ -22,14 +22,15 @@ class Report extends Model
     {
         $class = $this->object_type;
         switch ($class) {
-        case 'App\Status':
-         $column = 'id';
-          break;
-
-        default:
-         $column = 'id';
-          break;
-      }
+            case 'App\Status':
+             $column = 'id';
+              break;
+
+            default:
+             $class = 'App\Status';
+             $column = 'id';
+              break;
+        }
 
         return (new $class())->where($column, $this->object_id)->firstOrFail();
     }

+ 24 - 17
app/Status.php

@@ -18,7 +18,7 @@ class Status extends Model
      */
     protected $dates = ['deleted_at'];
 
-    protected $fillable = ['profile_id', 'visibility'];
+    protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id'];
 
     public function profile()
     {
@@ -52,7 +52,7 @@ class Status extends Model
     {
         $type = $this->viewType();
         $is_nsfw = !$showNsfw ? $this->is_nsfw : false;
-        if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['image', 'album'])) {
+        if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['image', 'album', 'video'])) {
             return 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
         }
 
@@ -64,11 +64,6 @@ class Status extends Model
         $id = $this->id;
         $username = $this->profile->username;
         $path = config('app.url')."/p/{$username}/{$id}";
-        if (!is_null($this->in_reply_to_id)) {
-            $pid = $this->in_reply_to_id;
-            $path = config('app.url')."/p/{$username}/{$pid}/c/{$id}";
-        }
-
         return url($path);
     }
 
@@ -103,8 +98,10 @@ class Status extends Model
 
     public function liked() : bool
     {
+        if(Auth::check() == false) {
+            return false;
+        }
         $profile = Auth::user()->profile;
-
         return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count();
     }
 
@@ -116,7 +113,7 @@ class Status extends Model
     public function bookmarked()
     {
         if (!Auth::check()) {
-            return 0;
+            return false;
         }
         $profile = Auth::user()->profile;
 
@@ -130,6 +127,9 @@ class Status extends Model
 
     public function shared() : bool
     {
+        if(Auth::check() == false) {
+            return false;
+        }
         $profile = Auth::user()->profile;
 
         return self::whereProfileId($profile->id)->whereReblogOfId($this->id)->count();
@@ -139,7 +139,7 @@ class Status extends Model
     {
         $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
         if (!empty($parent)) {
-            return self::findOrFail($parent);
+            return $this->findOrFail($parent);
         }
     }
 
@@ -254,7 +254,7 @@ class Status extends Model
                         'url' => $media->url(),
                         'name' => null
                     ];
-                })
+                })->toArray()
             ]
         ];
     }
@@ -268,18 +268,25 @@ class Status extends Model
         $res['to'] = [];
         $res['cc'] = [];
         $scope = $this->scope;
+        $mentions = $this->mentions->map(function ($mention) {
+            return $mention->permalink();
+        })->toArray();
+
         switch ($scope) {
             case 'public':
                 $res['to'] = [
                     "https://www.w3.org/ns/activitystreams#Public"
                 ];
-                $res['cc'] = [
-                    $this->profile->permalink('/followers')
-                ];
+                $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+                break;
+
+            case 'unlisted':
                 break;
-            
-            default:
-                # code...
+
+            case 'private':
+                break;
+
+            case 'direct':
                 break;
         }
         return $res[$audience];

+ 10 - 0
app/Story.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Story extends Model
+{
+    //
+}

+ 10 - 0
app/StoryReaction.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class StoryReaction extends Model
+{
+    //
+}

+ 19 - 0
app/Transformer/ActivityPub/Verb/Announce.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+
+class Announce extends Fractal\TransformerAbstract
+{
+    public function transform(Status $status)
+    {
+    	return [
+    		'@context'  => 'https://www.w3.org/ns/activitystreams',
+    		'type' 		=> 'Announce',
+    		'actor'		=> $status->profile->permalink(),
+    		'object'	=> $status->parent()->url()
+    	];
+    }
+}

+ 70 - 0
app/Transformer/ActivityPub/Verb/CreateNote.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+
+class CreateNote extends Fractal\TransformerAbstract
+{
+	public function transform(Status $status)
+	{
+
+		$mentions = $status->mentions->map(function ($mention) {
+			return [
+				'type' => 'Mention',
+				'href' => $mention->permalink(),
+				'name' => $mention->emailUrl()
+			];
+		})->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',
+				[
+					'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+					'featured'                  => [
+						'https://pixelfed.org/ns#featured' => ['@type' => '@id'],
+					],
+				],
+			],
+			'id' 					=> $status->permalink(),
+			'type' 					=> 'Create',
+			'actor' 				=> $status->profile->permalink(),
+			'published' 			=> $status->created_at->toAtomString(),
+			'to' 					=> $status->scopeToAudience('to'),
+			'cc' 					=> $status->scopeToAudience('cc'),
+			'object' => [
+				'id' 				=> $status->url(),
+				'type' 				=> 'Note',
+				'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'      	=> $status->media->map(function ($media) {
+					return [
+						'type'      => 'Document',
+						'mediaType' => $media->mime,
+						'url'       => $media->url(),
+						'name'      => null,
+					];
+				})->toArray(),
+				'tag' 				=> $tags,
+			]
+		];
+	}
+}

+ 19 - 0
app/Transformer/ActivityPub/Verb/Follow.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Follower;
+use League\Fractal;
+
+class Follow extends Fractal\TransformerAbstract
+{
+    public function transform(Follower $follower)
+    {
+    	return [
+    		'@context'  => 'https://www.w3.org/ns/activitystreams',
+    		'type' 		=> 'Follow',
+    		'actor'		=> $follower->actor->permalink(),
+    		'object'	=> $follower->target->permalink()
+    	];
+    }
+}

+ 19 - 0
app/Transformer/ActivityPub/Verb/Like.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Like as LikeModel;
+use League\Fractal;
+
+class Like extends Fractal\TransformerAbstract
+{
+    public function transform(LikeModel $like)
+    {
+    	return [
+    		'@context'  => 'https://www.w3.org/ns/activitystreams',
+    		'type' 		=> 'Like',
+    		'actor'		=> $like->actor->permalink(),
+    		'object'	=> $like->status->url()
+    	];
+    }
+}

+ 6 - 1
app/Transformer/Api/MediaTransformer.php

@@ -17,7 +17,12 @@ class MediaTransformer extends Fractal\TransformerAbstract
             'preview_url' => $media->thumbnailUrl(),
             'text_url'    => null,
             'meta'        => $media->metadata,
-            'description' => null,
+            'description' => $media->caption,
+            'license'     => $media->license,
+            'is_nsfw'     => $media->is_nsfw,
+            'orientation' => $media->orientation,
+            'filter_name' => $media->filter_name,
+            'filter_class' => $media->filter_class,
         ];
     }
 }

+ 25 - 0
app/Transformer/Api/RelationshipTransformer.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Profile;
+use League\Fractal;
+
+class RelationshipTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Profile $profile)
+    {
+        return [
+            'id' => $profile->id,
+            'following' => null,
+            'followed_by' => null,
+            'blocking' => null,
+            'muting' => null,
+            'muting_notifications' => null,
+            'requested' => null,
+            'domain_blocking' => null,
+            'showing_reblogs' => null,
+            'endorsed' => null
+        ];
+    }
+}

+ 1 - 1
app/Transformer/Api/StatusTransformer.php

@@ -59,7 +59,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
 
     public function includeMediaAttachments(Status $status)
     {
-        $media = $status->media;
+        $media = $status->media()->orderBy('order')->get();
 
         return $this->collection($media, new MediaTransformer());
     }

+ 266 - 27
app/Util/ActivityPub/Inbox.php

@@ -2,18 +2,30 @@
 
 namespace App\Util\ActivityPub;
 
-use App\Like;
-use App\Profile;
+use Cache, DB, Log, Redis, Validator;
+use App\{
+    Activity,
+    Follower,
+    FollowRequest,
+    Like,
+    Notification,
+    Profile,
+    Status
+};
+use Carbon\Carbon;
+use App\Util\ActivityPub\Helpers;
+use App\Jobs\LikePipeline\LikePipeline;
 
 class Inbox
 {
-    protected $request;
+    protected $headers;
     protected $profile;
     protected $payload;
+    protected $logger;
 
-    public function __construct($request, Profile $profile, $payload)
+    public function __construct($headers, $profile, $payload)
     {
-        $this->request = $request;
+        $this->headers = $headers;
         $this->profile = $profile;
         $this->payload = $payload;
     }
@@ -25,15 +37,31 @@ class Inbox
 
     public function authenticatePayload()
     {
-        // todo
+        try {
+           $signature = Helpers::validateSignature($this->headers, $this->payload);
+           $payload = Helpers::validateObject($this->payload);
+           if($signature == false) {
+            return;
+           }
+        } catch (Exception $e) {
+           return; 
+        }
+        $this->payloadLogger(); 
+    }
 
+    public function payloadLogger()
+    {
+        $logger = new Activity;
+        $logger->data = json_encode($this->payload);
+        $logger->save();
+        $this->logger = $logger;
+        Log::info('AP:inbox:activity:new:'.$this->logger->id);
         $this->handleVerb();
     }
 
     public function handleVerb()
     {
         $verb = $this->payload['type'];
-
         switch ($verb) {
             case 'Create':
                 $this->handleCreateActivity();
@@ -43,43 +71,254 @@ class Inbox
                 $this->handleFollowActivity();
                 break;
 
+            case 'Announce':
+                $this->handleAnnounceActivity();
+                break;
+
+            case 'Accept':
+                $this->handleAcceptActivity();
+                break;
+
+            case 'Delete':
+                $this->handleDeleteActivity();
+                break;
+
+            case 'Like':
+                $this->handleLikeActivity();
+                break;
+
+            case 'Reject':
+                $this->handleRejectActivity();
+                break;
+
+            case 'Undo':
+                $this->handleUndoActivity();
+                break;
+
             default:
                 // TODO: decide how to handle invalid verbs.
                 break;
         }
     }
 
+    public function verifyNoteAttachment()
+    {
+        $activity = $this->payload['object'];
+
+        if(isset($activity['inReplyTo']) && 
+            !empty($activity['inReplyTo']) && 
+            Helpers::validateUrl($activity['inReplyTo'])
+        ) {
+            // reply detected, skip attachment check
+            return true;
+        }
+
+        $valid = Helpers::verifyAttachments($activity);
+
+        return $valid;
+    }
+
+    public function actorFirstOrCreate($actorUrl)
+    {
+        return Helpers::profileFirstOrNew($actorUrl);
+    }
+
     public function handleCreateActivity()
     {
-        // todo
+        $activity = $this->payload['object'];
+        if(!$this->verifyNoteAttachment()) {
+            return;
+        }
+        if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
+            $this->handleNoteReply();
+
+        } elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
+            $this->handleNoteCreate();
+        }
+    }
+
+    public function handleNoteReply()
+    {
+        $activity = $this->payload['object'];
+        $actor = $this->actorFirstOrCreate($this->payload['actor']);
+        $inReplyTo = $activity['inReplyTo'];
+        
+        if(!Helpers::statusFirstOrFetch($activity['url'], true)) {
+            $this->logger->delete();
+            return;
+        }
+
+        $this->logger->to_id = $this->profile->id;
+        $this->logger->from_id = $actor->id;
+        $this->logger->processed_at = Carbon::now();
+        $this->logger->save();
+    }
+
+    public function handleNoteCreate()
+    {
+        $activity = $this->payload['object'];
+        $actor = $this->actorFirstOrCreate($this->payload['actor']);
+        if(!$actor || $actor->domain == null) {
+            return;
+        }
+
+        if(Helpers::userInAudience($this->profile, $this->payload) == false) {
+            //Log::error('AP:inbox:userInAudience:false - Activity#'.$this->logger->id);
+            $logger = Activity::find($this->logger->id);
+            $logger->delete();
+            return;
+        }
+
+        if(Status::whereUrl($activity['url'])->exists()) {
+            return;
+        }
+
+        $status = DB::transaction(function() use($activity, $actor) {
+            $status = new Status;
+            $status->profile_id = $actor->id;
+            $status->caption = strip_tags($activity['content']);
+            $status->visibility = $status->scope = 'public';
+            $status->url = $activity['url'];
+            $status->save();
+            return $status;
+        });
+
+        Helpers::importNoteAttachment($activity, $status);
+
+        $logger = Activity::find($this->logger->id);
+        $logger->to_id = $this->profile->id;
+        $logger->from_id = $actor->id;
+        $logger->processed_at = Carbon::now();
+        $logger->save();
     }
 
     public function handleFollowActivity()
     {
-        $actor = $this->payload['object'];
+        $actor = $this->actorFirstOrCreate($this->payload['actor']);
+        if(!$actor || $actor->domain == null) {
+            return;
+        }
         $target = $this->profile;
+        if($target->is_private == true) {
+            // make follow request
+            FollowRequest::firstOrCreate([
+                'follower_id' => $actor->id,
+                'following_id' => $target->id
+            ]);
+            // todo: send notification
+        } else {
+            // store new follower
+            $follower = Follower::firstOrCreate([
+                'profile_id' => $actor->id,
+                'following_id' => $target->id,
+                'local_profile' => empty($actor->domain)
+            ]);
+            if($follower->wasRecentlyCreated == false) {
+                $this->logger->delete();
+                return;
+            }
+            // send notification
+            $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();
+
+            \Cache::forever('notification.'.$notification->id, $notification);
+
+            $redis = Redis::connection();
+
+            $nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
+            $redis->lpush($nkey, $notification->id);
+            
+            // send Accept to remote profile
+            $accept = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'id'       => $follower->permalink('/accept'),
+                'type'     => 'Accept',
+                'actor'    => $target->permalink(),
+                'object'   => [
+                    'id' => $this->payload['id'],
+                    'type'  => 'Follow',
+                    'actor' => $target->permalink(),
+                    'object' => $actor->permalink()
+                ]
+            ];
+            Helpers::sendSignedObject($target, $actor->inbox_url, $accept);
+        }
+        $this->logger->to_id = $target->id;
+        $this->logger->from_id = $actor->id;
+        $this->logger->processed_at = Carbon::now();
+        $this->logger->save();
     }
 
-    public function actorFirstOrCreate($actorUrl)
+    public function handleAnnounceActivity()
+    {
+
+    }
+
+    public function handleAcceptActivity()
+    {
+
+    }
+
+    public function handleDeleteActivity()
+    {
+
+    }
+
+    public function handleLikeActivity()
     {
-        if (Profile::whereRemoteUrl($actorUrl)->count() !== 0) {
-            return Profile::whereRemoteUrl($actorUrl)->firstOrFail();
+        $actor = $this->payload['actor'];
+        $profile = self::actorFirstOrCreate($actor);
+        $obj = $this->payload['object'];
+        if(Helpers::validateLocalUrl($obj) == false) {
+            return;
+        }
+        $status = Helpers::statusFirstOrFetch($obj);
+        $like = Like::firstOrCreate([
+            'profile_id' => $profile->id,
+            'status_id' => $status->id
+        ]);
+
+        if($like->wasRecentlyCreated == false) {
+            return;
+        }
+        LikePipeline::dispatch($like);
+        $this->logger->to_id = $status->profile_id;
+        $this->logger->from_id = $profile->id;
+        $this->logger->processed_at = Carbon::now();
+        $this->logger->save();
+    }
+
+
+    public function handleRejectActivity()
+    {
+
+    }
+
+    public function handleUndoActivity()
+    {
+        $actor = $this->payload['actor'];
+        $profile = self::actorFirstOrCreate($actor);
+        $obj = $this->payload['object'];
+        $status = Helpers::statusFirstOrFetch($obj['object']);
+
+        switch ($obj['type']) {
+            case 'Like':
+                Like::whereProfileId($profile->id)
+                    ->whereStatusId($status->id)
+                    ->delete();
+                break;
         }
 
-        $res = (new DiscoverActor($url))->discover();
-
-        $domain = parse_url($res['url'], PHP_URL_HOST);
-        $username = $res['preferredUsername'];
-        $remoteUsername = "@{$username}@{$domain}";
-
-        $profile = new Profile();
-        $profile->user_id = null;
-        $profile->domain = $domain;
-        $profile->username = $remoteUsername;
-        $profile->name = $res['name'];
-        $profile->bio = str_limit($res['summary'], 125);
-        $profile->sharedInbox = $res['endpoints']['sharedInbox'];
-        $profile->remote_url = $res['url'];
-        $profile->save();
+        $this->logger->to_id = $status->profile_id;
+        $this->logger->from_id = $profile->id;
+        $this->logger->processed_at = Carbon::now();
+        $this->logger->save();
     }
 }

+ 34 - 0
app/Util/HTTPSignatures/Algorithm.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+abstract class Algorithm
+{
+    /**
+     * @param string $name
+     *
+     * @return HmacAlgorithm
+     *
+     * @throws Exception
+     */
+    public static function create($name)
+    {
+        switch ($name) {
+        case 'hmac-sha1':
+            return new HmacAlgorithm('sha1');
+            break;
+        case 'hmac-sha256':
+            return new HmacAlgorithm('sha256');
+            break;
+        case 'rsa-sha1':
+            return new RsaAlgorithm('sha1');
+            break;
+        case 'rsa-sha256':
+            return new RsaAlgorithm('sha256');
+            break;
+        default:
+            throw new AlgorithmException("No algorithm named '$name'");
+            break;
+        }
+    }
+}

+ 7 - 0
app/Util/HTTPSignatures/AlgorithmException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class AlgorithmException extends Exception
+{
+}

+ 19 - 0
app/Util/HTTPSignatures/AlgorithmInterface.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+interface AlgorithmInterface
+{
+    /**
+     * @return string
+     */
+    public function name();
+
+    /**
+     * @param string $key
+     * @param string $data
+     *
+     * @return string
+     */
+    public function sign($key, $data);
+}

+ 119 - 0
app/Util/HTTPSignatures/Context.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class Context
+{
+    /** @var array */
+    private $headers;
+
+    /** @var KeyStoreInterface */
+    private $keyStore;
+
+    /** @var array */
+    private $keys;
+
+    /** @var string */
+    private $signingKeyId;
+
+    /** @var AlgorithmInterface */
+    private $algorithm;
+
+    /**
+     * @param array $args
+     *
+     * @throws Exception
+     */
+    public function __construct($args)
+    {
+        if (isset($args['keys']) && isset($args['keyStore'])) {
+            throw new Exception(__CLASS__.' accepts keys or keyStore but not both');
+        } elseif (isset($args['keys'])) {
+            // array of keyId => keySecret
+            $this->keys = $args['keys'];
+        } elseif (isset($args['keyStore'])) {
+            $this->setKeyStore($args['keyStore']);
+        }
+
+        // algorithm for signing; not necessary for verifying.
+        if (isset($args['algorithm'])) {
+            $this->algorithm = Algorithm::create($args['algorithm']);
+        }
+        // headers list for signing; not necessary for verifying.
+        if (isset($args['headers'])) {
+            $this->headers = $args['headers'];
+        }
+
+        // signingKeyId specifies the key used for signing messages.
+        if (isset($args['signingKeyId'])) {
+            $this->signingKeyId = $args['signingKeyId'];
+        } elseif (isset($args['keys']) && 1 === count($args['keys'])) {
+            list($this->signingKeyId) = array_keys($args['keys']); // first key
+        }
+    }
+
+    /**
+     * @return Signer
+     *
+     * @throws Exception
+     */
+    public function signer()
+    {
+        return new Signer(
+            $this->signingKey(),
+            $this->algorithm,
+            $this->headerList()
+        );
+    }
+
+    /**
+     * @return Verifier
+     */
+    public function verifier()
+    {
+        return new Verifier($this->keyStore());
+    }
+
+    /**
+     * @return Key
+     *
+     * @throws Exception
+     * @throws KeyStoreException
+     */
+    private function signingKey()
+    {
+        if (isset($this->signingKeyId)) {
+            return $this->keyStore()->fetch($this->signingKeyId);
+        } else {
+            throw new Exception('no implicit or specified signing key');
+        }
+    }
+
+    /**
+     * @return HeaderList
+     */
+    private function headerList()
+    {
+        return new HeaderList($this->headers);
+    }
+
+    /**
+     * @return KeyStore
+     */
+    private function keyStore()
+    {
+        if (empty($this->keyStore)) {
+            $this->keyStore = new KeyStore($this->keys);
+        }
+
+        return $this->keyStore;
+    }
+
+    /**
+     * @param KeyStoreInterface $keyStore
+     */
+    private function setKeyStore(KeyStoreInterface $keyStore)
+    {
+        $this->keyStore = $keyStore;
+    }
+}

+ 7 - 0
app/Util/HTTPSignatures/Exception.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class Exception extends \Exception
+{
+}

+ 41 - 0
app/Util/HTTPSignatures/GuzzleHttpSignatures.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Request;
+use App\Util\HttpSignatures\Context;
+
+class GuzzleHttpSignatures
+{
+    /**
+     * @param Context $context
+     * @return HandlerStack
+     */
+    public static function defaultHandlerFromContext(Context $context)
+    {
+        $stack = HandlerStack::create();
+        $stack->push(self::middlewareFromContext($context));
+
+        return $stack;
+    }
+
+    /**
+     * @param Context $context
+     * @return \Closure
+     */
+    public static function middlewareFromContext(Context $context)
+    {
+        return function (callable $handler) use ($context)
+        {
+            return function (
+                Request $request,
+                array $options
+            ) use ($handler, $context)
+            {
+                $request = $context->signer()->sign($request);
+                return $handler($request, $options);
+            };
+        };
+    }
+}

+ 48 - 0
app/Util/HTTPSignatures/HeaderList.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class HeaderList
+{
+    /** @var array */
+    public $names;
+
+    /**
+     * @param array $names
+     */
+    public function __construct(array $names)
+    {
+        $this->names = array_map(
+            [$this, 'normalize'],
+            $names
+        );
+    }
+
+    /**
+     * @param $string
+     *
+     * @return HeaderList
+     */
+    public static function fromString($string)
+    {
+        return new static(explode(' ', $string));
+    }
+
+    /**
+     * @return string
+     */
+    public function string()
+    {
+        return implode(' ', $this->names);
+    }
+
+    /**
+     * @param $name
+     *
+     * @return string
+     */
+    private function normalize($name)
+    {
+        return strtolower($name);
+    }
+}

+ 36 - 0
app/Util/HTTPSignatures/HmacAlgorithm.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class HmacAlgorithm implements AlgorithmInterface
+{
+    /** @var string */
+    private $digestName;
+
+    /**
+     * @param string $digestName
+     */
+    public function __construct($digestName)
+    {
+        $this->digestName = $digestName;
+    }
+
+    /**
+     * @return string
+     */
+    public function name()
+    {
+        return sprintf('hmac-%s', $this->digestName);
+    }
+
+    /**
+     * @param string $key
+     * @param string $data
+     *
+     * @return string
+     */
+    public function sign($secret, $data)
+    {
+        return hash_hmac($this->digestName, $data, $secret, true);
+    }
+}

+ 260 - 0
app/Util/HTTPSignatures/Key.php

@@ -0,0 +1,260 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class Key
+{
+    /** @var string */
+    private $id;
+
+    /** @var string */
+    private $secret;
+
+    /** @var resource */
+    private $certificate;
+
+    /** @var resource */
+    private $publicKey;
+
+    /** @var resource */
+    private $privateKey;
+
+    /** @var string */
+    private $type;
+
+    /**
+     * @param string       $id
+     * @param string|array $secret
+     */
+    public function __construct($id, $item)
+    {
+        $this->id = $id;
+        if (Key::hasX509Certificate($item) || Key::hasPublicKey($item)) {
+            $publicKey = Key::getPublicKey($item);
+        } else {
+            $publicKey = null;
+        }
+        if (Key::hasPrivateKey($item)) {
+            $privateKey = Key::getPrivateKey($item);
+        } else {
+            $privateKey = null;
+        }
+        if (($publicKey || $privateKey)) {
+            $this->type = 'asymmetric';
+            if ($publicKey && $privateKey) {
+                $publicKeyPEM = openssl_pkey_get_details($publicKey)['key'];
+                $privateKeyPublicPEM = openssl_pkey_get_details($privateKey)['key'];
+                if ($privateKeyPublicPEM != $publicKeyPEM) {
+                    throw new KeyException('Supplied Certificate and Key are not related');
+                }
+            }
+            $this->privateKey = $privateKey;
+            $this->publicKey = $publicKey;
+            $this->secret = null;
+        } else {
+            $this->type = 'secret';
+            $this->secret = $item;
+            $this->publicKey = null;
+            $this->privateKey = null;
+        }
+    }
+
+    /**
+     * Retrieves private key resource from a input string or
+     * array of strings.
+     *
+     * @param string|array $object PEM-format Private Key or file path to same
+     *
+     * @return resource|false
+     */
+    public static function getPrivateKey($object)
+    {
+        if (is_array($object)) {
+            foreach ($object as $candidateKey) {
+                $privateKey = Key::getPrivateKey($candidateKey);
+                if ($privateKey) {
+                    return $privateKey;
+                }
+            }
+        } else {
+            // OpenSSL libraries don't have detection methods, so try..catch
+            try {
+                $privateKey = openssl_get_privatekey($object);
+
+                return $privateKey;
+            } catch (\Exception $e) {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Retrieves public key resource from a input string or
+     * array of strings.
+     *
+     * @param string|array $object PEM-format Public Key or file path to same
+     *
+     * @return resource|false
+     */
+    public static function getPublicKey($object)
+    {
+        if (is_array($object)) {
+            // If we implement key rotation in future, this should add to a collection
+            foreach ($object as $candidateKey) {
+                $publicKey = Key::getPublicKey($candidateKey);
+                if ($publicKey) {
+                    return $publicKey;
+                }
+            }
+        } else {
+            // OpenSSL libraries don't have detection methods, so try..catch
+            try {
+                $publicKey = openssl_get_publickey($object);
+
+                return $publicKey;
+            } catch (\Exception $e) {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Signing HTTP Messages 'keyId' field.
+     *
+     * @return string
+     *
+     * @throws KeyException
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * Retrieve Verifying Key - Public Key for Asymmetric/PKI, or shared secret for HMAC.
+     *
+     * @return string Shared Secret or PEM-format Public Key
+     *
+     * @throws KeyException
+     */
+    public function getVerifyingKey()
+    {
+        switch ($this->type) {
+        case 'asymmetric':
+            if ($this->publicKey) {
+                return openssl_pkey_get_details($this->publicKey)['key'];
+            } else {
+                return null;
+            }
+            break;
+        case 'secret':
+            return $this->secret;
+        default:
+            throw new KeyException("Unknown key type $this->type");
+        }
+    }
+
+    /**
+     * Retrieve Signing Key - Private Key for Asymmetric/PKI, or shared secret for HMAC.
+     *
+     * @return string Shared Secret or PEM-format Private Key
+     *
+     * @throws KeyException
+     */
+    public function getSigningKey()
+    {
+        switch ($this->type) {
+        case 'asymmetric':
+            if ($this->privateKey) {
+                openssl_pkey_export($this->privateKey, $pem);
+
+                return $pem;
+            } else {
+                return null;
+            }
+            break;
+        case 'secret':
+            return $this->secret;
+        default:
+            throw new KeyException("Unknown key type $this->type");
+        }
+    }
+
+    /**
+     * @return string 'secret' for HMAC or 'asymmetric'
+     */
+    public function getType()
+    {
+        return $this->type;
+    }
+
+    /**
+     * Test if $object is, points to or contains, X.509 PEM-format certificate.
+     *
+     * @param string|array $object PEM Format X.509 Certificate or file path to one
+     *
+     * @return bool
+     */
+    public static function hasX509Certificate($object)
+    {
+        if (is_array($object)) {
+            foreach ($object as $candidateCertificate) {
+                $result = Key::hasX509Certificate($candidateCertificate);
+                if ($result) {
+                    return $result;
+                }
+            }
+        } else {
+            // OpenSSL libraries don't have detection methods, so try..catch
+            try {
+                openssl_x509_export($object, $null);
+
+                return true;
+            } catch (\Exception $e) {
+                return false;
+            }
+        }
+    }
+
+    /**
+     * Test if $object is, points to or contains, PEM-format Public Key.
+     *
+     * @param string|array $object PEM-format Public Key or file path to one
+     *
+     * @return bool
+     */
+    public static function hasPublicKey($object)
+    {
+        if (is_array($object)) {
+            foreach ($object as $candidatePublicKey) {
+                $result = Key::hasPublicKey($candidatePublicKey);
+                if ($result) {
+                    return $result;
+                }
+            }
+        } else {
+            return false == !openssl_pkey_get_public($object);
+        }
+    }
+
+    /**
+     * Test if $object is, points to or contains, PEM-format Private Key.
+     *
+     * @param string|array $object PEM-format Private Key or file path to one
+     *
+     * @return bool
+     */
+    public static function hasPrivateKey($object)
+    {
+        if (is_array($object)) {
+            foreach ($object as $candidatePrivateKey) {
+                $result = Key::hasPrivateKey($candidatePrivateKey);
+                if ($result) {
+                    return $result;
+                }
+            }
+        } else {
+            return  false != openssl_pkey_get_private($object);
+        }
+    }
+}

+ 7 - 0
app/Util/HTTPSignatures/KeyException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class KeyException extends Exception
+{
+}

+ 36 - 0
app/Util/HTTPSignatures/KeyStore.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class KeyStore implements KeyStoreInterface
+{
+    /** @var Key[] */
+    private $keys;
+
+    /**
+     * @param array $keys
+     */
+    public function __construct($keys)
+    {
+        $this->keys = [];
+        foreach ($keys as $id => $key) {
+            $this->keys[$id] = new Key($id, $key);
+        }
+    }
+
+    /**
+     * @param string $keyId
+     *
+     * @return Key
+     *
+     * @throws KeyStoreException
+     */
+    public function fetch($keyId)
+    {
+        if (isset($this->keys[$keyId])) {
+            return $this->keys[$keyId];
+        } else {
+            throw new KeyStoreException("Key '$keyId' not found");
+        }
+    }
+}

+ 7 - 0
app/Util/HTTPSignatures/KeyStoreException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class KeyStoreException extends Exception
+{
+}

+ 15 - 0
app/Util/HTTPSignatures/KeyStoreInterface.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+interface KeyStoreInterface
+{
+    /**
+     * return the secret for the specified $keyId.
+     *
+     * @param string $keyId
+     *
+     * @return Key
+     */
+    public function fetch($keyId);
+}

+ 64 - 0
app/Util/HTTPSignatures/RsaAlgorithm.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class RsaAlgorithm implements AlgorithmInterface
+{
+    /** @var string */
+    private $digestName;
+
+    /**
+     * @param string $digestName
+     */
+    public function __construct($digestName)
+    {
+        $this->digestName = $digestName;
+    }
+
+    /**
+     * @return string
+     */
+    public function name()
+    {
+        return sprintf('rsa-%s', $this->digestName);
+    }
+
+    /**
+     * @param string $key
+     * @param string $data
+     *
+     * @return string
+     *
+     * @throws \HttpSignatures\AlgorithmException
+     */
+    public function sign($signingKey, $data)
+    {
+        $algo = $this->getRsaHashAlgo($this->digestName);
+        if (!openssl_get_privatekey($signingKey)) {
+            throw new AlgorithmException("OpenSSL doesn't understand the supplied key (not valid or not found)");
+        }
+        $signature = '';
+        openssl_sign($data, $signature, $signingKey, $algo);
+
+        return $signature;
+    }
+
+    public function verify($message, $signature, $verifyingKey)
+    {
+        $algo = $this->getRsaHashAlgo($this->digestName);
+
+        return openssl_verify($message, base64_decode($signature), $verifyingKey, $algo);
+    }
+
+    private function getRsaHashAlgo($digestName)
+    {
+        switch ($digestName) {
+        case 'sha256':
+            return OPENSSL_ALGO_SHA256;
+        case 'sha1':
+            return OPENSSL_ALGO_SHA1;
+        default:
+            throw new HttpSignatures\AlgorithmException($digestName.' is not a supported hash format');
+      }
+    }
+}

+ 38 - 0
app/Util/HTTPSignatures/Signature.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use Psr\Http\Message\RequestInterface;
+
+class Signature
+{
+    /** @var Key */
+    private $key;
+
+    /** @var AlgorithmInterface */
+    private $algorithm;
+
+    /** @var SigningString */
+    private $signingString;
+
+    /**
+     * @param RequestInterface   $message
+     * @param Key                $key
+     * @param AlgorithmInterface $algorithm
+     * @param HeaderList         $headerList
+     */
+    public function __construct($message, Key $key, AlgorithmInterface $algorithm, HeaderList $headerList)
+    {
+        $this->key = $key;
+        $this->algorithm = $algorithm;
+        $this->signingString = new SigningString($headerList, $message);
+    }
+
+    public function string()
+    {
+        return $this->algorithm->sign(
+            $this->key->getSigningKey(),
+            $this->signingString->string()
+          );
+    }
+}

+ 49 - 0
app/Util/HTTPSignatures/SignatureParameters.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class SignatureParameters
+{
+    /**
+     * @param Key                $key
+     * @param AlgorithmInterface $algorithm
+     * @param HeaderList         $headerList
+     * @param Signature          $signature
+     */
+    public function __construct($key, $algorithm, $headerList, $signature)
+    {
+        $this->key = $key;
+        $this->algorithm = $algorithm;
+        $this->headerList = $headerList;
+        $this->signature = $signature;
+    }
+
+    /**
+     * @return string
+     */
+    public function string()
+    {
+        return implode(',', $this->parameterComponents());
+    }
+
+    /**
+     * @return array
+     */
+    private function parameterComponents()
+    {
+        return [
+            sprintf('keyId="%s"', $this->key->getId()),
+            sprintf('algorithm="%s"', $this->algorithm->name()),
+            sprintf('headers="%s"', $this->headerList->string()),
+            sprintf('signature="%s"', $this->signatureBase64()),
+        ];
+    }
+
+    /**
+     * @return string
+     */
+    private function signatureBase64()
+    {
+        return base64_encode($this->signature->string());
+    }
+}

+ 111 - 0
app/Util/HTTPSignatures/SignatureParametersParser.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class SignatureParametersParser
+{
+    /** @var string */
+    private $input;
+
+    /**
+     * @param string $input
+     */
+    public function __construct($input)
+    {
+        $this->input = $input;
+    }
+
+    /**
+     * @return array
+     */
+    public function parse()
+    {
+        $result = $this->pairsToAssociative(
+            $this->arrayOfPairs()
+        );
+        $this->validate($result);
+
+        return $result;
+    }
+
+    /**
+     * @param array $pairs
+     *
+     * @return array
+     */
+    private function pairsToAssociative($pairs)
+    {
+        $result = [];
+        foreach ($pairs as $pair) {
+            $result[$pair[0]] = $pair[1];
+        }
+
+        return $result;
+    }
+
+    /**
+     * @return array
+     */
+    private function arrayOfPairs()
+    {
+        return array_map(
+            [$this, 'pair'],
+            $this->segments()
+        );
+    }
+
+    /**
+     * @return array
+     */
+    private function segments()
+    {
+        return explode(',', $this->input);
+    }
+
+    /**
+     * @param $segment
+     *
+     * @return array
+     *
+     * @throws SignatureParseException
+     */
+    private function pair($segment)
+    {
+        $segmentPattern = '/\A(keyId|algorithm|headers|signature)="(.*)"\z/';
+        $matches = [];
+        $result = preg_match($segmentPattern, $segment, $matches);
+        if (1 !== $result) {
+            throw new SignatureParseException("Signature parameters segment '$segment' invalid");
+        }
+        array_shift($matches);
+
+        return $matches;
+    }
+
+    /**
+     * @param $result
+     *
+     * @throws SignatureParseException
+     */
+    private function validate($result)
+    {
+        $this->validateAllKeysArePresent($result);
+    }
+
+    /**
+     * @param $result
+     *
+     * @throws SignatureParseException
+     */
+    private function validateAllKeysArePresent($result)
+    {
+        // Regexp in pair() ensures no unwanted keys exist.
+        // Ensure that all wanted keys exist.
+        $wanted = ['keyId', 'algorithm', 'headers', 'signature'];
+        $missing = array_diff($wanted, array_keys($result));
+        if (!empty($missing)) {
+            $csv = implode(', ', $missing);
+            throw new SignatureParseException("Missing keys $csv");
+        }
+    }
+}

+ 7 - 0
app/Util/HTTPSignatures/SignatureParseException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class SignatureParseException extends Exception
+{
+}

+ 7 - 0
app/Util/HTTPSignatures/SignedHeaderNotPresentException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+class SignedHeaderNotPresentException extends Exception
+{
+}

+ 104 - 0
app/Util/HTTPSignatures/Signer.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use Psr\Http\Message\RequestInterface;
+
+class Signer
+{
+    /** @var Key */
+    private $key;
+
+    /** @var HmacAlgorithm */
+    private $algorithm;
+
+    /** @var HeaderList */
+    private $headerList;
+
+    /**
+     * @param Key           $key
+     * @param HmacAlgorithm $algorithm
+     * @param HeaderList    $headerList
+     */
+    public function __construct($key, $algorithm, $headerList)
+    {
+        $this->key = $key;
+        $this->algorithm = $algorithm;
+        $this->headerList = $headerList;
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return RequestInterface
+     */
+    public function sign($message)
+    {
+        $signatureParameters = $this->signatureParameters($message);
+        $message = $message->withAddedHeader('Signature', $signatureParameters->string());
+        $message = $message->withAddedHeader('Authorization', 'Signature '.$signatureParameters->string());
+
+        return $message;
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return RequestInterface
+     */
+    public function signWithDigest($message)
+    {
+        $message = $this->addDigest($message);
+
+        return $this->sign($message);
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return RequestInterface
+     */
+    private function addDigest($message)
+    {
+        if (!array_search('digest', $this->headerList->names)) {
+            $this->headerList->names[] = 'digest';
+        }
+        $message = $message->withoutHeader('Digest')
+            ->withHeader(
+                'Digest',
+                'SHA-256='.base64_encode(hash('sha256', $message->getBody(), true))
+            );
+
+        return $message;
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return SignatureParameters
+     */
+    private function signatureParameters($message)
+    {
+        return new SignatureParameters(
+            $this->key,
+            $this->algorithm,
+            $this->headerList,
+            $this->signature($message)
+        );
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return Signature
+     */
+    private function signature($message)
+    {
+        return new Signature(
+            $message,
+            $this->key,
+            $this->algorithm,
+            $this->headerList
+        );
+    }
+}

+ 89 - 0
app/Util/HTTPSignatures/SigningString.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use Psr\Http\Message\RequestInterface;
+
+class SigningString
+{
+    /** @var HeaderList */
+    private $headerList;
+
+    /** @var RequestInterface */
+    private $message;
+
+    /**
+     * @param HeaderList       $headerList
+     * @param RequestInterface $message
+     */
+    public function __construct(HeaderList $headerList, $message)
+    {
+        $this->headerList = $headerList;
+        $this->message = $message;
+    }
+
+    /**
+     * @return string
+     */
+    public function string()
+    {
+        return implode("\n", $this->lines());
+    }
+
+    /**
+     * @return array
+     */
+    private function lines()
+    {
+        return array_map(
+            [$this, 'line'],
+            $this->headerList->names
+        );
+    }
+
+    /**
+     * @param string $name
+     *
+     * @return string
+     *
+     * @throws SignedHeaderNotPresentException
+     */
+    private function line($name)
+    {
+        if ('(request-target)' == $name) {
+            return $this->requestTargetLine();
+        } else {
+            return sprintf('%s: %s', $name, $this->headerValue($name));
+        }
+    }
+
+    /**
+     * @param string $name
+     *
+     * @return string
+     *
+     * @throws SignedHeaderNotPresentException
+     */
+    private function headerValue($name)
+    {
+        if ($this->message->hasHeader($name)) {
+            $header = $this->message->getHeader($name);
+
+            return end($header);
+        } else {
+            throw new SignedHeaderNotPresentException("Header '$name' not in message");
+        }
+    }
+
+    /**
+     * @return string
+     */
+    private function requestTargetLine()
+    {
+        return sprintf(
+            '(request-target): %s %s',
+            strtolower($this->message->getMethod()),
+            $this->message->getRequestTarget()
+        );
+    }
+}

+ 202 - 0
app/Util/HTTPSignatures/Verification.php

@@ -0,0 +1,202 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use Psr\Http\Message\RequestInterface;
+
+class Verification
+{
+    /** @var RequestInterface */
+    private $message;
+
+    /** @var KeyStoreInterface */
+    private $keyStore;
+
+    /** @var array */
+    private $_parameters;
+
+    /**
+     * @param RequestInterface  $message
+     * @param KeyStoreInterface $keyStore
+     */
+    public function __construct($message, KeyStoreInterface $keyStore)
+    {
+        $this->message = $message;
+        $this->keyStore = $keyStore;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isValid()
+    {
+        return $this->hasSignatureHeader() && $this->signatureMatches();
+    }
+
+    /**
+     * @return bool
+     */
+    private function signatureMatches()
+    {
+        try {
+            $key = $this->key();
+            switch ($key->getType()) {
+                case 'secret':
+                  $random = random_bytes(32);
+                  $expectedResult = hash_hmac(
+                      'sha256', $this->expectedSignatureBase64(),
+                      $random,
+                      true
+                  );
+                  $providedResult = hash_hmac(
+                      'sha256', $this->providedSignatureBase64(),
+                      $random,
+                      true
+                  );
+
+                  return $expectedResult === $providedResult;
+                case 'asymmetric':
+                    $signedString = new SigningString(
+                        $this->headerList(),
+                        $this->message
+                    );
+                    $hashAlgo = explode('-', $this->parameter('algorithm'))[1];
+                    $algorithm = new RsaAlgorithm($hashAlgo);
+                    $result = $algorithm->verify(
+                        $signedString->string(),
+                        $this->parameter('signature'),
+                        $key->getVerifyingKey());
+
+                    return $result;
+                default:
+                    throw new Exception("Unknown key type '".$key->getType()."', cannot verify");
+            }
+        } catch (SignatureParseException $e) {
+            return false;
+        } catch (KeyStoreException $e) {
+            return false;
+        } catch (SignedHeaderNotPresentException $e) {
+            return false;
+        }
+    }
+
+    /**
+     * @return string
+     */
+    private function expectedSignatureBase64()
+    {
+        return base64_encode($this->expectedSignature()->string());
+    }
+
+    /**
+     * @return Signature
+     */
+    private function expectedSignature()
+    {
+        return new Signature(
+            $this->message,
+            $this->key(),
+            $this->algorithm(),
+            $this->headerList()
+        );
+    }
+
+    /**
+     * @return string
+     */
+    private function providedSignatureBase64()
+    {
+        return $this->parameter('signature');
+    }
+
+    /**
+     * @return Key
+     */
+    private function key()
+    {
+        return $this->keyStore->fetch($this->parameter('keyId'));
+    }
+
+    /**
+     * @return HmacAlgorithm
+     */
+    private function algorithm()
+    {
+        return Algorithm::create($this->parameter('algorithm'));
+    }
+
+    /**
+     * @return HeaderList
+     */
+    private function headerList()
+    {
+        return HeaderList::fromString($this->parameter('headers'));
+    }
+
+    /**
+     * @param string $name
+     *
+     * @return string
+     *
+     * @throws Exception
+     */
+    private function parameter($name)
+    {
+        $parameters = $this->parameters();
+        if (!isset($parameters[$name])) {
+            throw new Exception("Signature parameters does not contain '$name'");
+        }
+
+        return $parameters[$name];
+    }
+
+    /**
+     * @return array
+     */
+    private function parameters()
+    {
+        if (!isset($this->_parameters)) {
+            $parser = new SignatureParametersParser($this->signatureHeader());
+            $this->_parameters = $parser->parse();
+        }
+
+        return $this->_parameters;
+    }
+
+    /**
+     * @return bool
+     */
+    private function hasSignatureHeader()
+    {
+        return $this->message->hasHeader('Signature') || $this->message->hasHeader('Authorization');
+    }
+
+    /**
+     * @return string
+     *
+     * @throws Exception
+     */
+    private function signatureHeader()
+    {
+        if ($signature = $this->fetchHeader('Signature')) {
+            return $signature;
+        } elseif ($authorization = $this->fetchHeader('Authorization')) {
+            return substr($authorization, strlen('Signature '));
+        } else {
+            throw new Exception('HTTP message has no Signature or Authorization header');
+        }
+    }
+
+    /**
+     * @param $name
+     *
+     * @return string|null
+     */
+    private function fetchHeader($name)
+    {
+        // grab the most recently set header.
+        $header = $this->message->getHeader($name);
+
+        return end($header);
+    }
+}

+ 31 - 0
app/Util/HTTPSignatures/Verifier.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Util\HttpSignatures;
+
+use Psr\Http\Message\RequestInterface;
+
+class Verifier
+{
+    /** @var KeyStoreInterface */
+    private $keyStore;
+
+    /**
+     * @param KeyStoreInterface $keyStore
+     */
+    public function __construct(KeyStoreInterface $keyStore)
+    {
+        $this->keyStore = $keyStore;
+    }
+
+    /**
+     * @param RequestInterface $message
+     *
+     * @return bool
+     */
+    public function isValid($message)
+    {
+        $verification = new Verification($message, $this->keyStore);
+
+        return $verification->isValid();
+    }
+}

+ 3 - 1
app/Util/Media/Image.php

@@ -15,7 +15,7 @@ class Image
     public $orientation;
     public $acceptedMimes = [
         'image/png',
-        'image/jpeg',
+        'image/jpeg'
     ];
 
     public function __construct()
@@ -114,9 +114,11 @@ class Image
             if($thumbnail) {
                 $img->crop($aspect['width'], $aspect['height']);
             } else {
+                $metadata = $img->exif();
                 $img->resize($aspect['width'], $aspect['height'], function ($constraint) {
                     $constraint->aspectRatio();
                 });
+                $media->metadata = json_encode($metadata);
             }
             $converted = $this->setBaseName($path, $thumbnail, $img->extension);
             $newPath = storage_path('app/'.$converted['path']);

+ 4 - 4
composer.json

@@ -1,8 +1,8 @@
 {
-    "name": "laravel/laravel",
-    "description": "The Laravel Framework.",
-    "keywords": ["framework", "laravel"],
-    "license": "MIT",
+    "name": "pixelfed/pixelfed",
+    "description": "Open and ethical photo sharing platform, powered by ActivityPub federation.",
+    "keywords": ["framework", "laravel", "pixelfed", "activitypub", "social", "network", "federation"],
+    "license": "AGPL-3.0-only",
     "type": "project",
     "require": {
         "php": "^7.1.3",

+ 3 - 2
config/app.php

@@ -13,7 +13,7 @@ return [
     |
     */
 
-    'name' => env('APP_NAME', 'Laravel'),
+    'name' => env('APP_NAME', 'Pixelfed'),
 
     /*
     |--------------------------------------------------------------------------
@@ -52,7 +52,7 @@ return [
     |
     */
 
-    'url' => env('APP_URL', 'http://localhost'),
+    'url' => env('APP_URL', 'https://localhost'),
 
     /*
     |--------------------------------------------------------------------------
@@ -214,6 +214,7 @@ return [
         'Recaptcha'    => Greggilbert\Recaptcha\Facades\Recaptcha::class,
         'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
         'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
+        'Purify'       => Stevebauman\Purify\Facades\Purify::class,
     ],
 
 ];

+ 2 - 2
config/debugbar.php

@@ -14,7 +14,7 @@ return [
      |
      */
 
-    'enabled' => env('DEBUGBAR_ENABLED', false),
+    'enabled' => false,
     'except'  => [
         //
     ],
@@ -32,7 +32,7 @@ return [
      |
      */
     'storage' => [
-        'enabled'    => true,
+        'enabled'    => false,
         'driver'     => 'file', // redis, file, pdo, custom
         'path'       => storage_path('debugbar'), // For file driver
         'connection' => null,   // Leave null for default connection (Redis/PDO)

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your PixelFed instance.
     |
     */
-    'version' => '0.1.9',
+    'version' => '0.2.0',
 
     /*
     |--------------------------------------------------------------------------

+ 141 - 0
config/purify.php

@@ -0,0 +1,141 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Settings
+    |--------------------------------------------------------------------------
+    |
+    | The configuration settings array is passed directly to HTMLPurifier.
+    |
+    | Feel free to add / remove / customize these attributes as you wish.
+    |
+    | Documentation: http://htmlpurifier.org/live/configdoc/plain.html
+    |
+    */
+
+    'settings' => [
+
+        /*
+        |--------------------------------------------------------------------------
+        | Core.Encoding
+        |--------------------------------------------------------------------------
+        |
+        | The encoding to convert input to.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#Core.Encoding
+        |
+        */
+
+        'Core.Encoding' => 'utf-8',
+
+        /*
+        |--------------------------------------------------------------------------
+        | Core.SerializerPath
+        |--------------------------------------------------------------------------
+        |
+        | The HTML purifier serializer cache path.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#Cache.SerializerPath
+        |
+        */
+
+        'Cache.SerializerPath' => storage_path('purify'),
+
+        /*
+        |--------------------------------------------------------------------------
+        | HTML.Doctype
+        |--------------------------------------------------------------------------
+        |
+        | Doctype to use during filtering.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#HTML.Doctype
+        |
+        */
+
+        'HTML.Doctype' => 'XHTML 1.0 Strict',
+
+        /*
+        |--------------------------------------------------------------------------
+        | HTML.Allowed
+        |--------------------------------------------------------------------------
+        |
+        | The allowed HTML Elements with their allowed attributes.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#HTML.Allowed
+        |
+        */
+
+        'HTML.Allowed' => 'a[href|title|rel],p',
+
+        /*
+        |--------------------------------------------------------------------------
+        | HTML.ForbiddenElements
+        |--------------------------------------------------------------------------
+        |
+        | The forbidden HTML elements. Elements that are listed in
+        | this string will be removed, however their content will remain.
+        |
+        | For example if 'p' is inside the string, the string: '<p>Test</p>',
+        |
+        | Will be cleaned to: 'Test'
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#HTML.ForbiddenElements
+        |
+        */
+
+        'HTML.ForbiddenElements' => '',
+
+        /*
+        |--------------------------------------------------------------------------
+        | CSS.AllowedProperties
+        |--------------------------------------------------------------------------
+        |
+        | The Allowed CSS properties.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#CSS.AllowedProperties
+        |
+        */
+
+        'CSS.AllowedProperties' => '',
+
+        /*
+        |--------------------------------------------------------------------------
+        | AutoFormat.AutoParagraph
+        |--------------------------------------------------------------------------
+        |
+        | The Allowed CSS properties.
+        |
+        | This directive turns on auto-paragraphing, where double
+        | newlines are converted in to paragraphs whenever possible.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.AutoParagraph
+        |
+        */
+
+        'AutoFormat.AutoParagraph' => false,
+
+        /*
+        |--------------------------------------------------------------------------
+        | AutoFormat.RemoveEmpty
+        |--------------------------------------------------------------------------
+        |
+        | When enabled, HTML Purifier will attempt to remove empty
+        | elements that contribute no semantic information to the document.
+        |
+        | http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.RemoveEmpty
+        |
+        */
+
+        'AutoFormat.RemoveEmpty' => false,
+
+        'Attr.AllowedRel' => [
+            'noreferrer',
+            'noopener',
+            'nofollow'
+        ],
+
+    ],
+
+];

+ 1 - 1
config/queue.php

@@ -61,7 +61,7 @@ return [
             'driver'      => 'redis',
             'connection'  => 'default',
             'queue'       => 'default',
-            'retry_after' => 90,
+            'retry_after' => 1800,
             'block_for'   => null,
         ],
 

+ 4 - 3
database/factories/UserFactory.php

@@ -15,9 +15,10 @@ use Faker\Generator as Faker;
 
 $factory->define(App\User::class, function (Faker $faker) {
     return [
-        'name'           => $faker->name,
-        'email'          => $faker->unique()->safeEmail,
-        'password'       => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
+        'name' => $faker->name,
+        'username' => str_replace('.', '', $faker->unique()->userName),
+        'email' => str_random(8).$faker->unique()->safeEmail,
+        'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
         'remember_token' => str_random(10),
     ];
 });

+ 186 - 8
package-lock.json

@@ -2,6 +2,20 @@
     "requires": true,
     "lockfileVersion": 1,
     "dependencies": {
+        "@videojs/http-streaming": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-1.2.5.tgz",
+            "integrity": "sha512-kqjx9oc4NiiUwzqt8EI2PcuebC0WlnxsWydUoMSktLmXc/T6qVS0m8d1eyMA2tjlDILvKkjq2YPS7Jl81phbQQ==",
+            "requires": {
+                "aes-decrypter": "3.0.0",
+                "global": "^4.3.0",
+                "m3u8-parser": "4.2.0",
+                "mpd-parser": "0.6.1",
+                "mux.js": "4.5.1",
+                "url-toolkit": "^2.1.3",
+                "video.js": "^6.8.0 || ^7.0.0"
+            }
+        },
         "abbrev": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -87,6 +101,16 @@
                 }
             }
         },
+        "aes-decrypter": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.0.0.tgz",
+            "integrity": "sha1-eEihwUW5/b9Xrj4rWxvHzwZEqPs=",
+            "requires": {
+                "commander": "^2.9.0",
+                "global": "^4.3.2",
+                "pkcs7": "^1.0.2"
+            }
+        },
         "after": {
             "version": "0.8.2",
             "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
@@ -1933,8 +1957,7 @@
         "commander": {
             "version": "2.17.1",
             "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
-            "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
-            "dev": true
+            "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg=="
         },
         "commondir": {
             "version": "1.0.1",
@@ -2764,6 +2787,11 @@
                 "buffer-indexof": "^1.0.0"
             }
         },
+        "dom-walk": {
+            "version": "0.1.1",
+            "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
+            "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg="
+        },
         "domain-browser": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
@@ -3596,6 +3624,14 @@
                 "debug": "^3.1.0"
             }
         },
+        "for-each": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+            "requires": {
+                "is-callable": "^1.1.3"
+            }
+        },
         "for-in": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@@ -4424,6 +4460,22 @@
                 }
             }
         },
+        "global": {
+            "version": "4.3.2",
+            "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+            "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
+            "requires": {
+                "min-document": "^2.19.0",
+                "process": "~0.5.1"
+            },
+            "dependencies": {
+                "process": {
+                    "version": "0.5.2",
+                    "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+                    "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
+                }
+            }
+        },
         "globals": {
             "version": "9.18.0",
             "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
@@ -4991,6 +5043,11 @@
             "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
             "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
         },
+        "individual": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
+            "integrity": "sha1-gzsJfa0jKU52EXqY+zjg2a1hu5c="
+        },
         "infinite-scroll": {
             "version": "3.0.5",
             "resolved": "https://registry.npmjs.org/infinite-scroll/-/infinite-scroll-3.0.5.tgz",
@@ -5161,8 +5218,7 @@
         "is-callable": {
             "version": "1.1.4",
             "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
-            "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
-            "dev": true
+            "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA=="
         },
         "is-data-descriptor": {
             "version": "0.1.4",
@@ -5256,6 +5312,11 @@
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
             "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
         },
+        "is-function": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz",
+            "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU="
+        },
         "is-glob": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz",
@@ -5421,8 +5482,7 @@
         "jquery": {
             "version": "3.3.1",
             "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
-            "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==",
-            "dev": true
+            "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg=="
         },
         "js-base64": {
             "version": "2.4.9",
@@ -5836,6 +5896,11 @@
                 "yallist": "^2.1.2"
             }
         },
+        "m3u8-parser": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.2.0.tgz",
+            "integrity": "sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg=="
+        },
         "make-dir": {
             "version": "1.3.0",
             "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
@@ -6019,6 +6084,14 @@
             "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
             "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
         },
+        "min-document": {
+            "version": "2.19.0",
+            "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+            "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
+            "requires": {
+                "dom-walk": "^0.1.0"
+            }
+        },
         "minimalistic-assert": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -6126,6 +6199,15 @@
                 "run-queue": "^1.0.3"
             }
         },
+        "mpd-parser": {
+            "version": "0.6.1",
+            "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.6.1.tgz",
+            "integrity": "sha512-3ucsY5NJMABltTLtYMSDfqZpvKV4yF8YvMx91hZFrHiblseuoKq4XUQ5IkcdtFAIRBAkPhXMU3/eunTFNCNsHw==",
+            "requires": {
+                "global": "^4.3.0",
+                "url-toolkit": "^2.1.1"
+            }
+        },
         "ms": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -6152,6 +6234,11 @@
             "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
             "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
         },
+        "mux.js": {
+            "version": "4.5.1",
+            "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-4.5.1.tgz",
+            "integrity": "sha512-j4rEyZKCRinGaSiBxPx9YD9B782TMPHPOlKyaMY07vIGTNYg4ouCEBvL6zX9Hh1k1fKZ5ZF3S7c+XVk6PB+Igw=="
+        },
         "nan": {
             "version": "2.11.0",
             "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz",
@@ -6786,6 +6873,15 @@
                 }
             }
         },
+        "parse-headers": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.1.tgz",
+            "integrity": "sha1-aug6eqJanZtwCswoaYzR8e1+lTY=",
+            "requires": {
+                "for-each": "^0.3.2",
+                "trim": "0.0.1"
+            }
+        },
         "parse-json": {
             "version": "2.2.0",
             "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
@@ -6928,6 +7024,11 @@
                 "pinkie": "^2.0.0"
             }
         },
+        "pkcs7": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.2.tgz",
+            "integrity": "sha1-ttulJ1KMKUK/wSLOLa/NteWQdOc="
+        },
         "pkg-dir": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
@@ -9329,6 +9430,14 @@
                 "readable-stream": "^2.0.2"
             }
         },
+        "readmore-js": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/readmore-js/-/readmore-js-2.2.1.tgz",
+            "integrity": "sha512-hbPP0nQpYYkAywCEZ8ozHivvhWyHic37KJ2IXrHES4qzjp0+nmw8R33MeyMAtXBZfXX4Es8cpd5JBVf9qj47+Q==",
+            "requires": {
+                "jquery": ">2.1.4"
+            }
+        },
         "recast": {
             "version": "0.11.23",
             "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz",
@@ -9712,6 +9821,14 @@
                 "aproba": "^1.1.1"
             }
         },
+        "rust-result": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
+            "integrity": "sha1-NMdbLm3Dn+WHXlveyFteD5FTb3I=",
+            "requires": {
+                "individual": "^2.0.0"
+            }
+        },
         "rx": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz",
@@ -9722,6 +9839,14 @@
             "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
             "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
         },
+        "safe-json-parse": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
+            "integrity": "sha1-fA9XjPzNEtM6ccDgVBPi7KFx6qw=",
+            "requires": {
+                "rust-result": "^1.0.0"
+            }
+        },
         "safe-regex": {
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@@ -10755,6 +10880,11 @@
                 "punycode": "^1.4.1"
             }
         },
+        "trim": {
+            "version": "0.0.1",
+            "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
+            "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0="
+        },
         "trim-newlines": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
@@ -10776,6 +10906,11 @@
                 "glob": "^7.1.2"
             }
         },
+        "tsml": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/tsml/-/tsml-1.0.1.tgz",
+            "integrity": "sha1-ifghi52eJX9H1/a1bQHFpNLGj8M="
+        },
         "tty-browserify": {
             "version": "0.0.0",
             "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
@@ -11135,6 +11270,11 @@
                 "requires-port": "^1.0.0"
             }
         },
+        "url-toolkit": {
+            "version": "2.1.6",
+            "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.1.6.tgz",
+            "integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw=="
+        },
         "use": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@@ -11209,6 +11349,34 @@
                 "extsprintf": "^1.2.0"
             }
         },
+        "video.js": {
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.2.3.tgz",
+            "integrity": "sha512-oiRGXew1yKk3ILh9+8cvnV0PQp8oqs/2XtkoO46j7BMsFvhgl9L+dy+hS//MUSh1JNgDGUkM/K+E6WTTLlwN7w==",
+            "requires": {
+                "@videojs/http-streaming": "1.2.5",
+                "babel-runtime": "^6.9.2",
+                "global": "4.3.2",
+                "safe-json-parse": "4.0.0",
+                "tsml": "1.0.1",
+                "videojs-font": "3.0.0",
+                "videojs-vtt.js": "0.14.1",
+                "xhr": "2.4.0"
+            }
+        },
+        "videojs-font": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.0.0.tgz",
+            "integrity": "sha512-XS6agz2T7p2cFuuXulJD70md8XMlAN617SJkMWjoTPqZWv+RU8NcZCKsE3Tk73inzxnQdihOp0cvI7NGz2ngHg=="
+        },
+        "videojs-vtt.js": {
+            "version": "0.14.1",
+            "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.14.1.tgz",
+            "integrity": "sha512-YxOiywx6N9t3J5nqsE5WN2Sw4CSqVe3zV+AZm2T4syOc2buNJaD6ZoexSdeszx2sHLU/RRo2r4BJAXFDQ7Qo2Q==",
+            "requires": {
+                "global": "^4.3.1"
+            }
+        },
         "vm-browserify": {
             "version": "0.0.4",
             "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
@@ -11694,6 +11862,17 @@
                 "ultron": "~1.1.0"
             }
         },
+        "xhr": {
+            "version": "2.4.0",
+            "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.4.0.tgz",
+            "integrity": "sha1-4W5mpF+GmGHu76tBbV7/ci3ECZM=",
+            "requires": {
+                "global": "~4.3.0",
+                "is-function": "^1.0.1",
+                "parse-headers": "^2.0.0",
+                "xtend": "^4.0.0"
+            }
+        },
         "xmlhttprequest": {
             "version": "1.8.0",
             "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",
@@ -11707,8 +11886,7 @@
         "xtend": {
             "version": "4.0.1",
             "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
-            "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=",
-            "dev": true
+            "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
         },
         "y18n": {
             "version": "3.2.1",

+ 3 - 1
package.json

@@ -25,8 +25,10 @@
         "infinite-scroll": "^3.0.4",
         "laravel-echo": "^1.4.0",
         "pusher-js": "^4.2.2",
+        "readmore-js": "^2.2.1",
         "socket.io-client": "^2.1.1",
         "sweetalert": "^2.1.0",
-        "twitter-text": "^2.0.5"
+        "twitter-text": "^2.0.5",
+        "video.js": "^7.2.3"
     }
 }

TEMPAT SAMPAH
public/css/app.css


File diff ditekan karena terlalu besar
+ 0 - 0
public/img/help/what_is_the_fediverse.svg


TEMPAT SAMPAH
public/img/pixelfed-icon-black.svg


TEMPAT SAMPAH
public/img/pixelfed-icon-grey.svg


TEMPAT SAMPAH
public/js/app.js


TEMPAT SAMPAH
public/js/components.js


TEMPAT SAMPAH
public/js/timeline.js


TEMPAT SAMPAH
public/mix-manifest.json


TEMPAT SAMPAH
public/static/beep.mp3


+ 5 - 101
resources/assets/js/bootstrap.js

@@ -1,113 +1,17 @@
 window._ = require('lodash');
 window.Popper = require('popper.js').default;
-import swal from 'sweetalert';
-
-window.pixelfed = {};
+window.pixelfed = window.pixelfed || {};
 window.$ = window.jQuery = require('jquery');
 require('bootstrap');
-window.Vue = require('vue');
-import BootstrapVue from 'bootstrap-vue'
-Vue.use(BootstrapVue);
-
-try {
-    window.InfiniteScroll = require('infinite-scroll');
-    window.filesize = require('filesize');
-    window.typeahead = require('./lib/typeahead');
-    window.Bloodhound = require('./lib/bloodhound');
-    require('./components/localstorage');
-    require('./components/likebutton');
-    require('./components/commentform');
-    require('./components/searchform');
-    require('./components/bookmarkform');
-    require('./components/statusform');
-    // require('./components/embed');
-    // require('./components/shortcuts');
-
-    Vue.component(
-        'follow-suggestions',
-        require('./components/FollowSuggestions.vue')
-    );
-
-    // Vue.component(
-    //     'circle-panel',
-    //     require('./components/CirclePanel.vue')
-    // );
-
-    // Vue.component(
-    //     'post-presenter',
-    //     require('./components/PostPresenter.vue')
-    // );
-
-    // Vue.component(
-    //     'post-comments',
-    //     require('./components/PostComments.vue')
-    // );
-
-    Vue.component(
-        'passport-clients',
-        require('./components/passport/Clients.vue')
-    );
-
-    Vue.component(
-        'passport-authorized-clients',
-        require('./components/passport/AuthorizedClients.vue')
-    );
-
-    Vue.component(
-        'passport-personal-access-tokens',
-        require('./components/passport/PersonalAccessTokens.vue')
-    );
-
-} catch (e) {}
-
-$(document).ready(function() {
-  $(function () {
-    $('[data-toggle="tooltip"]').tooltip()
-  });
-});
-
+window.typeahead = require('./lib/typeahead');
+window.Bloodhound = require('./lib/bloodhound');
 window.axios = require('axios');
-
 window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+require('readmore-js');
+
 let token = document.head.querySelector('meta[name="csrf-token"]');
 if (token) {
     window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
 } else {
     console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
 }
-
-// import Echo from "laravel-echo"
-
-// window.io = require('socket.io-client');
-
-// window.pixelfed.bootEcho = function() {
-//     window.Echo = new Echo({
-//         broadcaster: 'socket.io',
-//         host: window.location.hostname + ':2096',
-//         auth: {
-//             headers: {
-//                 Authorization: 'Bearer ' + token.content,
-//             },
-//         },
-//     });
-// }
-
-window.pixelfed.copyToClipboard = (str) => {
-  const el = document.createElement('textarea');
-  el.value = str;
-  el.setAttribute('readonly', '');
-  el.style.position = 'absolute';                 
-  el.style.left = '-9999px';
-  document.body.appendChild(el);
-  const selected = 
-    document.getSelection().rangeCount > 0
-      ? document.getSelection().getRangeAt(0)
-      : false;
-  el.select();
-  document.execCommand('copy');
-  document.body.removeChild(el);
-  if (selected) {
-    document.getSelection().removeAllRanges();
-    document.getSelection().addRange(selected);
-  }
-};

+ 117 - 0
resources/assets/js/components.js

@@ -0,0 +1,117 @@
+window.Vue = require('vue');
+import BootstrapVue from 'bootstrap-vue'
+Vue.use(BootstrapVue);
+
+pixelfed.readmore = () => {
+  $(document).find('.read-more').each(function(k,v) {
+      let el = $(this);
+      let attr = el.attr('data-readmore');
+      if(typeof attr !== typeof undefined && attr !== false) {
+        return;
+      }
+      el.readmore({
+        collapsedHeight: 44,
+        heightMargin: 20,
+        moreLink: '<a href="#" class="font-weight-bold small">Read more</a>',
+        lessLink: '<a href="#" class="font-weight-bold small">Hide</a>',
+      });
+  });
+};
+
+window.InfiniteScroll = require('infinite-scroll');
+window.filesize = require('filesize');
+import swal from 'sweetalert';
+
+require('./components/localstorage');
+require('./components/likebutton');
+require('./components/commentform');
+require('./components/searchform');
+require('./components/bookmarkform');
+require('./components/statusform');
+require('./components/embed');
+require('./components/notifications');
+
+// import Echo from "laravel-echo"
+
+// window.io = require('socket.io-client');
+
+// window.pixelfed.bootEcho = function() {
+//     window.Echo = new Echo({
+//         broadcaster: 'socket.io',
+//         host: window.location.hostname + ':2096',
+//         auth: {
+//             headers: {
+//                 Authorization: 'Bearer ' + token.content,
+//             },
+//         },
+//     });
+// }
+
+// Initalize Notification Helper
+window.pixelfed.n = {};
+
+Vue.component(
+    'follow-suggestions',
+    require('./components/FollowSuggestions.vue')
+);
+
+Vue.component(
+    'discover-component',
+    require('./components/DiscoverComponent.vue')
+);
+
+// Vue.component(
+//     'circle-panel',
+//     require('./components/CirclePanel.vue')
+// );
+
+Vue.component(
+    'post-component',
+    require('./components/PostComponent.vue')
+);
+
+Vue.component(
+    'post-comments',
+    require('./components/PostComments.vue')
+);
+
+Vue.component(
+    'passport-clients',
+    require('./components/passport/Clients.vue')
+);
+
+Vue.component(
+    'passport-authorized-clients',
+    require('./components/passport/AuthorizedClients.vue')
+);
+
+Vue.component(
+    'passport-personal-access-tokens',
+    require('./components/passport/PersonalAccessTokens.vue')
+);
+
+window.pixelfed.copyToClipboard = (str) => {
+  const el = document.createElement('textarea');
+  el.value = str;
+  el.setAttribute('readonly', '');
+  el.style.position = 'absolute';                 
+  el.style.left = '-9999px';
+  document.body.appendChild(el);
+  const selected = 
+    document.getSelection().rangeCount > 0
+      ? document.getSelection().getRangeAt(0)
+      : false;
+  el.select();
+  document.execCommand('copy');
+  document.body.removeChild(el);
+  if (selected) {
+    document.getSelection().removeAllRanges();
+    document.getSelection().addRange(selected);
+  }
+};
+
+$(document).ready(function() {
+  $(function () {
+    $('[data-toggle="tooltip"]').tooltip()
+  });
+});

+ 33 - 0
resources/assets/js/components/CirclePanel.vue

@@ -0,0 +1,33 @@
+<style scoped>
+ .b-dropdown {
+    padding:0 !important;
+ }
+</style>
+
+<template>
+	<div class="card mb-4">
+		<div class="card-header py-1 bg-white d-flex align-items-center justify-content-between">
+			<span class="font-weight-bold h5 mb-0">Circles</span>
+
+	        <b-dropdown variant="link" no-caret right>
+	          <template slot="button-content">
+	              <i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
+	          </template>
+	          <b-dropdown-item class="font-weight-bold">Create a circle</b-dropdown-item>
+	          <b-dropdown-divider></b-dropdown-divider>
+	          <b-dropdown-item class="font-weight-bold">Settings</b-dropdown-item>
+	        </b-dropdown>
+		</div>
+		<div class="card-body">
+			<div class="text-center p-3">
+				<p class="mb-0"><a class="btn btn-sm btn-outline-primary" style="border-radius: 20px;" href="/i/circle/create">Create new circle</a></p>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+	export default {
+
+	}
+</script>

+ 95 - 0
resources/assets/js/components/DiscoverComponent.vue

@@ -0,0 +1,95 @@
+<template>
+<div class="container">
+  <section class="mb-5 section-people">
+    <p class="lead text-muted font-weight-bold mb-0">Discover People</p>
+    <div class="loader text-center">
+    	<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
+    </div>
+    <div class="row d-none">
+      <div class="col-4 p-0 p-sm-2 p-md-3" v-for="profile in people">
+        <div class="card card-md-border-0">
+          <div class="card-body p-4 text-center">
+            <div class="avatar pb-3">
+              <a :href="profile.url">
+                <img :src="profile.avatar" class="img-thumbnail rounded-circle" width="64px">
+              </a>
+            </div>
+            <p class="lead font-weight-bold mb-0 text-truncate"><a :href="profile.url" class="text-dark">{{profile.username}}</a></p>
+            <p class="text-muted text-truncate">{{profile.name}}</p>
+            <form class="follow-form" method="post" action="/i/follow" data-id="#" data-action="follow">
+              <input type="hidden" name="item" value="#">
+              <button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+  <section class="mb-5 section-explore">
+    <p class="lead text-muted font-weight-bold mb-0">Explore</p>
+    <div class="profile-timeline">
+	    <div class="loader text-center">
+	    	<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
+	    </div>
+      <div class="row d-none">
+        <div class="col-4 p-0 p-sm-2 p-md-3" v-for="post in posts">
+          <a class="card info-overlay card-md-border-0" :href="post.url">
+            <div class="square filter_class">
+              <div class="square-content" v-bind:style="{ 'background-image': 'url(' + post.thumb + ')' }"></div>
+              <div class="info-overlay-text">
+                <h5 class="text-white m-auto font-weight-bold">
+                  <span class="pr-4">
+                  <span class="far fa-heart fa-lg pr-1"></span> {{post.likes_count}}
+                  </span>
+                  <span>
+                  <span class="far fa-comment fa-lg pr-1"></span> {{post.comments_count}}
+                  </span>
+                </h5>
+              </div>
+            </div>
+          </a>
+        </div>
+      </div>
+     </div>
+  </section>
+  <section class="mb-5">
+  	<p class="lead text-center">To view more posts, check the <a href="#" class="font-weight-bold">home</a>, <a href="#" class="font-weight-bold">local</a> or <a href="#" class="font-weight-bold">federated</a> timelines.</p>
+  </section>
+</div>
+</template>
+
+<script type="text/javascript">
+export default {
+	data() {
+		return {
+			people: {},
+			posts: {},
+			trending: {}
+		}
+	},
+	mounted() {
+		this.fetchData();
+	},
+
+	methods: {
+		fetchData() {
+			axios.get('/api/v2/discover')
+			.then((res) => {
+				let data = res.data;
+				this.people = data.people;
+				this.posts = data.posts;
+
+				if(this.people.length > 1) {
+					$('.section-people .lds-ring').hide();
+					$('.section-people .row.d-none').removeClass('d-none');
+				}
+
+				if(this.posts.length > 1) {
+					$('.section-explore .lds-ring').hide();
+					$('.section-explore .row.d-none').removeClass('d-none');
+				}
+			});
+		}
+	}
+}
+</script>

+ 60 - 70
resources/assets/js/components/PostComments.vue

@@ -1,82 +1,44 @@
-<style type="text/css">
-.b-dropdown > button {
-  padding:0 !important;
-}
-</style>
-<style scoped>
+<style>
  span {
   font-size: 14px;
  }
  .comment-text {
   word-break: break-all;
  }
- .b-dropdown {
-    padding:0 !important;
+ .comment-text p {
+  display: inline;
  }
-.b-dropdown < button {
- }
- .lds-ring {
-  display: inline-block;
-  position: relative;
-  width: 64px;
-  height: 64px;
-}
-.lds-ring div {
-  box-sizing: border-box;
-  display: block;
-  position: absolute;
-  width: 51px;
-  height: 51px;
-  margin: 6px;
-  border: 6px solid #6c757d;
-  border-radius: 50%;
-  animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
-  border-color: #6c757d transparent transparent transparent;
-}
-.lds-ring div:nth-child(1) {
-  animation-delay: -0.45s;
-}
-.lds-ring div:nth-child(2) {
-  animation-delay: -0.3s;
-}
-.lds-ring div:nth-child(3) {
-  animation-delay: -0.15s;
-}
-@keyframes lds-ring {
-  0% {
-    transform: rotate(0deg);
-  }
-  100% {
-    transform: rotate(360deg);
-  }
-}
-
 </style>
 
 <template>
 <div>
-  <div class="lwrapper text-center">
+  <div class="postCommentsLoader text-center">
     <div class="lds-ring"><div></div><div></div><div></div><div></div></div> 
   </div>
-  <div class="cwrapper d-none">
+  <div class="postCommentsContainer d-none">
     <p class="mb-1 text-center load-more-link"><a href="#" class="text-muted" v-on:click="loadMore">Load more comments</a></p>
     <div class="comments" data-min-id="0" data-max-id="0">
-      <p class="mb-0 d-flex justify-content-between align-items-center" v-for="(comment, index) in results" :data-id="comment.id" v-bind:key="comment.id">
-        <span class="pr-3">
-          <span class="font-weight-bold pr-1"><bdi><a class="text-dark" :href="comment.account.url">{{comment.account.username}}</a></bdi></span>
-          <span class="comment-text" v-html="comment.content"></span>
+      <p class="mb-1" v-for="(comment, index) in results" :data-id="comment.id" v-bind:key="comment.id">
+        <span class="d-flex justify-content-between align-items-center">
+          <span class="pr-3" style="overflow: hidden;">
+            <div class="font-weight-bold pr-1"><bdi><a class="text-dark" :href="comment.account.url" :title="comment.account.username">{{l(comment.account.username)}}</a></bdi>
+            </div>
+            <div class="read-more" style="overflow: hidden;">
+              <span class="comment-text" v-html="comment.content" style="overflow: hidden;"></span>
+            </div>
+          </span>
+          <b-dropdown :id="comment.uri" variant="link" no-caret class="float-right">
+            <template slot="button-content">
+                <i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
+            </template>
+            <b-dropdown-item class="font-weight-bold" v-on:click="reply(comment)">Reply</b-dropdown-item>
+            <b-dropdown-item class="font-weight-bold" :href="comment.url">Permalink</b-dropdown-item>
+            <b-dropdown-item class="font-weight-bold" v-on:click="embed(comment)">Embed</b-dropdown-item>
+            <b-dropdown-item class="font-weight-bold" :href="comment.account.url">Profile</b-dropdown-item>
+            <b-dropdown-divider></b-dropdown-divider>
+            <b-dropdown-item class="font-weight-bold" :href="'/i/report?type=post&id='+comment.id">Report</b-dropdown-item>
+          </b-dropdown>
         </span>
-        <b-dropdown :id="comment.uri" variant="link" no-caret class="float-right">
-          <template slot="button-content">
-              <i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
-          </template>
-          <b-dropdown-item class="font-weight-bold" v-on:click="reply(comment)">Reply</b-dropdown-item>
-          <b-dropdown-item class="font-weight-bold" :href="comment.url">Permalink</b-dropdown-item>
-          <b-dropdown-item class="font-weight-bold" v-on:click="embed(comment)">Embed</b-dropdown-item>
-          <b-dropdown-item class="font-weight-bold" :href="comment.account.url">Profile</b-dropdown-item>
-          <b-dropdown-divider></b-dropdown-divider>
-          <b-dropdown-item class="font-weight-bold" :href="'/i/report?type=post&id='+comment.id">Report</b-dropdown-item>
-        </b-dropdown>
       </p>
     </div>
   </div>
@@ -98,10 +60,18 @@ export default {
     mounted() {
       this.fetchData();
     },
+    updated() {
+      pixelfed.readmore();
+    },
     methods: {
       embed(e) {
           pixelfed.embed.build(e);
       },
+      l(e) {
+        let len = e.length;
+        if(len < 10) { return e; } 
+        return e.substr(0, 10)+'...';
+      },
       reply(e) {
           this.reply_to_profile_id = e.account.id;
           $('.comment-form input[name=comment]').val('@'+e.account.username+' ');
@@ -114,11 +84,33 @@ export default {
                 let self = this;
                 this.results = response.data.data;
                 this.pagination = response.data.meta.pagination;
-                $('.lwrapper').addClass('d-none');
-                $('.cwrapper').removeClass('d-none');
+                $('.postCommentsLoader').addClass('d-none');
+                $('.postCommentsContainer').removeClass('d-none');
             }).catch(error => {
-                $('.lds-ring').attr('style','width:100%').addClass('pt-4 font-weight-bold text-muted').text('An error occured, cannot fetch comments. Please try again later.');
+              if(!error.response) {
+                $('.postCommentsLoader .lds-ring')
+                  .attr('style','width:100%')
+                  .addClass('pt-4 font-weight-bold text-muted')
+                  .text('An error occured, cannot fetch comments. Please try again later.');
                 console.log(error);
+              } else {
+                switch(error.response.status) {
+                  case 401:
+                    $('.postCommentsLoader .lds-ring')
+                      .attr('style','width:100%')
+                      .addClass('pt-4 font-weight-bold text-muted')
+                      .text('Please login to view.');
+                  break;
+
+                  default:
+                    $('.postCommentsLoader .lds-ring')
+                      .attr('style','width:100%')
+                      .addClass('pt-4 font-weight-bold text-muted')
+                      .text('An error occured, cannot fetch comments. Please try again later.');
+                  break;
+                }
+                console.log(error.response.status);
+              }
             });
       },
       loadMore(e) {
@@ -127,18 +119,16 @@ export default {
             $('.load-more-link').addClass('d-none');
             return;
           }
-          $('.cwrapper').addClass('d-none');
-          $('.lwrapper').removeClass('d-none');
+          $('.postCommentsLoader').removeClass('d-none');
           let next = this.pagination.links.next;
           axios.get(next)
             .then(response => {
                 let self = this;
                 let res =  response.data.data;
-                $('.lwrapper').addClass('d-none');
+                $('.postCommentsLoader').addClass('d-none');
                 for(let i=0; i < res.length; i++) {
                   this.results.unshift(res[i]);
                 }
-                $('.cwrapper').removeClass('d-none');
                 this.pagination = response.data.meta.pagination;
             });
       }

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini