瀏覽代碼

Merge pull request #334 from dansup/frontend-ui-refactor

Frontend ui refactor
daniel 7 年之前
父節點
當前提交
56750833e9
共有 49 個文件被更改,包括 2420 次插入175 次删除
  1. 10 0
      app/AccountLog.php
  2. 36 0
      app/Events/AuthLoginEvent.php
  3. 1 1
      app/Hashtag.php
  4. 31 11
      app/Http/Controllers/AccountController.php
  5. 23 1
      app/Http/Controllers/Auth/LoginController.php
  6. 0 10
      app/Http/Controllers/ImportDataController.php
  7. 9 3
      app/Http/Controllers/LikeController.php
  8. 1 2
      app/Http/Controllers/ProfileController.php
  9. 29 1
      app/Http/Controllers/SiteController.php
  10. 1 1
      app/Http/Middleware/EmailVerificationCheck.php
  11. 1 1
      app/ImportJob.php
  12. 43 0
      app/Jobs/InboxPipeline/InboxWorker.php
  13. 41 0
      app/Jobs/InboxPipeline/SharedInboxWorker.php
  14. 5 0
      app/Jobs/LikePipeline/LikePipeline.php
  15. 24 22
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  16. 7 0
      app/Media.php
  17. 7 1
      app/Observer/UserObserver.php
  18. 14 0
      app/Profile.php
  19. 3 0
      app/Providers/EventServiceProvider.php
  20. 10 0
      app/ReportComment.php
  21. 10 0
      app/ReportLog.php
  22. 44 3
      app/Status.php
  23. 1 1
      app/StatusHashtag.php
  24. 33 0
      app/Transformer/Api/AccountTransformer.php
  25. 16 0
      app/Transformer/Api/ApplicationTransformer.php
  26. 18 0
      app/Transformer/Api/HashtagTransformer.php
  27. 24 0
      app/Transformer/Api/MediaTransformer.php
  28. 19 0
      app/Transformer/Api/MentionTransformer.php
  29. 69 0
      app/Transformer/Api/StatusTransformer.php
  30. 10 0
      app/UserFilter.php
  31. 10 0
      app/UserSetting.php
  32. 7 1
      composer.json
  33. 776 69
      composer.lock
  34. 1 1
      config/image-optimizer.php
  35. 11 0
      config/pixelfed.php
  36. 36 0
      database/migrations/2018_04_22_233721_create_web_subs_table.php
  37. 38 0
      database/migrations/2018_04_26_003259_create_import_jobs_table.php
  38. 35 0
      database/migrations/2018_06_22_062621_create_report_comments_table.php
  39. 37 0
      database/migrations/2018_06_22_062628_create_report_logs_table.php
  40. 40 0
      database/migrations/2018_07_05_010303_create_account_logs_table.php
  41. 50 0
      database/migrations/2018_07_12_054015_create_user_settings_table.php
  42. 38 0
      database/migrations/2018_07_15_011916_add_2fa_to_users_table.php
  43. 41 0
      database/migrations/2018_07_15_013106_create_user_filters_table.php
  44. 二進制
      public/img/pixelfed-icon-color.svg
  45. 107 0
      resources/assets/js/components/passport/AuthorizedClients.vue
  46. 350 0
      resources/assets/js/components/passport/Clients.vue
  47. 298 0
      resources/assets/js/components/passport/PersonalAccessTokens.vue
  48. 5 3
      resources/views/layouts/partial/nav.blade.php
  49. 0 43
      resources/views/settings/home.blade.php

+ 10 - 0
app/AccountLog.php

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

+ 36 - 0
app/Events/AuthLoginEvent.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\{User, UserSetting};
+
+class AuthLoginEvent
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    /**
+     * Create a new event instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        //
+    }
+
+    public function handle(User $user)
+    {
+        if(empty($user->settings)) {
+            $settings = new UserSetting;
+            $settings->user_id = $user->id;
+            $settings->save();
+        }
+    }
+}

+ 1 - 1
app/Hashtag.php

@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
 
 class Hashtag extends Model
 {
-    protected $fillable = ['name','slug'];
+    public $fillable = ['name','slug'];
 
     public function posts()
     {

+ 31 - 11
app/Http/Controllers/AccountController.php

@@ -18,15 +18,24 @@ class AccountController extends Controller
     public function notifications(Request $request)
     {
       $this->validate($request, [
-          'page' => 'nullable|min:1|max:3'
+          'page' => 'nullable|min:1|max:3',
+          'a'    => 'nullable|alpha_dash',
       ]);
       $profile = Auth::user()->profile;
+      $action = $request->input('a');
       $timeago = Carbon::now()->subMonths(6);
-      $notifications = Notification::whereProfileId($profile->id)
-          ->whereDate('created_at', '>', $timeago)
-          ->orderBy('id','desc')
-          ->take(30)
-          ->simplePaginate();
+      if($action && in_array($action, ['comment', 'follow', 'mention'])) {
+        $notifications = Notification::whereProfileId($profile->id)
+            ->whereAction($action)
+            ->whereDate('created_at', '>', $timeago)
+            ->orderBy('id','desc')
+            ->simplePaginate(30);
+      } else {
+        $notifications = Notification::whereProfileId($profile->id)
+            ->whereDate('created_at', '>', $timeago)
+            ->orderBy('id','desc')
+            ->simplePaginate(30);
+      }
 
       return view('account.activity', compact('profile', 'notifications'));
     }
@@ -38,10 +47,19 @@ class AccountController extends Controller
 
     public function sendVerifyEmail(Request $request)
     {
-        if(EmailVerification::whereUserId(Auth::id())->count() !== 0) {
-            return redirect()->back()->with('status', 'A verification email has already been sent! Please check your email.');
+        $timeLimit = Carbon::now()->subDays(1)->toDateTimeString();
+        $recentAttempt = EmailVerification::whereUserId(Auth::id())
+          ->where('created_at', '>', $timeLimit)->count();
+        $exists = EmailVerification::whereUserId(Auth::id())->count();
+
+        if($recentAttempt == 1 && $exists == 1) {
+            return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
+        } elseif ($recentAttempt == 0 && $exists !== 0) {
+            // Delete old verification and send new one.
+            EmailVerification::whereUserId(Auth::id())->delete();
         }
 
+
         $user = User::whereNull('email_verified_at')->find(Auth::id());
         $utoken = hash('sha512', $user->id);
         $rtoken = str_random(40);
@@ -60,14 +78,15 @@ class AccountController extends Controller
 
     public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
     {
-        $verify = EmailVerification::where(DB::raw('BINARY user_token'), $userToken)
-          ->where(DB::raw('BINARY random_token'), $randomToken)
+        $verify = EmailVerification::where('user_token', $userToken)
+          ->where('random_token', $randomToken)
           ->firstOrFail();
+
         if(Auth::id() === $verify->user_id) {
           $user = User::find(Auth::id());
           $user->email_verified_at = Carbon::now();
           $user->save();
-          return redirect('/timeline');
+          return redirect('/');
         }
     }
 
@@ -95,4 +114,5 @@ class AccountController extends Controller
       }
       return $notifications;
     }
+
 }

+ 23 - 1
app/Http/Controllers/Auth/LoginController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\Auth;
 
+use App\{AccountLog, User};
 use App\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
 
@@ -25,7 +26,7 @@ class LoginController extends Controller
      *
      * @var string
      */
-    protected $redirectTo = '/home';
+    protected $redirectTo = '/';
 
     /**
      * Create a new controller instance.
@@ -56,4 +57,25 @@ class LoginController extends Controller
 
         $this->validate($request, $rules);
     }
+
+    /**
+     * The user has been authenticated.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  mixed  $user
+     * @return mixed
+     */
+    protected function authenticated($request, $user)
+    {
+        $log = new AccountLog;
+        $log->user_id = $user->id;
+        $log->item_id = $user->id;
+        $log->item_type = 'App\User';
+        $log->action = 'auth.login';
+        $log->message = 'Account Login';
+        $log->link = null;
+        $log->ip_address = $request->ip();
+        $log->user_agent = $request->userAgent();
+        $log->save();
+    }
 }

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

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

+ 9 - 3
app/Http/Controllers/LikeController.php

@@ -3,7 +3,7 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
-use Auth, Hashids;
+use Auth, Cache, Hashids;
 use App\{Like, Profile, Status, User};
 use App\Jobs\LikePipeline\LikePipeline;
 
@@ -27,7 +27,7 @@ class LikeController extends Controller
 
       if($status->likes()->whereProfileId($profile->id)->count() !== 0) {
         $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
-        $like->delete();
+        $like->forceDelete();
         $count--;
       } else {
         $like = new Like;
@@ -35,9 +35,15 @@ class LikeController extends Controller
         $like->status_id = $status->id;
         $like->save();
         $count++;
+        LikePipeline::dispatch($like);
       }
 
-      LikePipeline::dispatch($like);
+      $likes = Like::whereProfileId($profile->id)
+               ->orderBy('id', 'desc')
+               ->take(1000)
+               ->pluck('status_id');
+               
+      Cache::put('api:like-ids:user:'.$profile->id,  $likes, 1440);
 
       if($request->ajax()) {
         $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];

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

@@ -21,7 +21,6 @@ class ProfileController extends Controller
 
       $mimes = [
         'application/activity+json', 
-        'application/ld+json',
         'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
       ];
 
@@ -36,7 +35,7 @@ class ProfileController extends Controller
       $timeline = $user->statuses()
                   ->whereHas('media')
                   ->whereNull('in_reply_to_id')
-                  ->orderBy('id','desc')
+                  ->orderBy('created_at','desc')
                   ->withCount(['comments', 'likes'])
                   ->simplePaginate(21);
 

+ 29 - 1
app/Http/Controllers/SiteController.php

@@ -2,11 +2,39 @@
 
 namespace App\Http\Controllers;
 
-use App;
+use App, Auth;
 use Illuminate\Http\Request;
+use App\{Follower, Status, User};
 
 class SiteController extends Controller
 {
+
+    public function home()
+    {
+        if(Auth::check()) {
+          return $this->homeTimeline();
+        } else {
+          return $this->homeGuest();
+        }
+    }
+
+    public function homeGuest()
+    {
+        return view('site.index');
+    }
+
+    public function homeTimeline()
+    {
+      // TODO: Use redis for timelines
+      $following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
+      $following->push(Auth::user()->profile->id);
+      $timeline = Status::whereIn('profile_id', $following)
+                  ->orderBy('id','desc')
+                  ->withCount(['comments', 'likes', 'shares'])
+                  ->simplePaginate(10);
+      return view('timeline.template', compact('timeline'));
+    }
+
     public function changeLocale(Request $request, $locale)
     {
         if(!App::isLocale($locale)) {

+ 1 - 1
app/Http/Middleware/EmailVerificationCheck.php

@@ -18,7 +18,7 @@ class EmailVerificationCheck
         if($request->user() && 
             config('pixelfed.enforce_email_verification') &&
             is_null($request->user()->email_verified_at) && 
-            !$request->is('i/verify-email') && !$request->is('login') && 
+            !$request->is('i/verify-email') && !$request->is('log*') && 
             !$request->is('i/confirm-email/*')
         ) {
             return redirect('/i/verify-email');

+ 1 - 1
app/Report.php → app/ImportJob.php

@@ -4,7 +4,7 @@ namespace App;
 
 use Illuminate\Database\Eloquent\Model;
 
-class Report extends Model
+class ImportJob extends Model
 {
     //
 }

+ 43 - 0
app/Jobs/InboxPipeline/InboxWorker.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Jobs\InboxPipeline;
+
+use App\Profile;
+use App\Util\ActivityPub\Inbox;
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class InboxWorker implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $request;
+    protected $profile;
+    protected $payload;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($request, Profile $profile, $payload)
+    {
+        $this->request = $request;
+        $this->profile = $profile;
+        $this->payload = $payload;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        (new Inbox($this->request, $this->profile, $this->payload))->handle();
+    }
+
+}

+ 41 - 0
app/Jobs/InboxPipeline/SharedInboxWorker.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Jobs\InboxPipeline;
+
+use App\Profile;
+use App\Util\ActivityPub\Inbox;
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class SharedInboxWorker implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $request;
+    protected $profile;
+    protected $payload;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($request, $payload)
+    {
+        $this->request = $request;
+        $this->payload = $payload;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        (new Inbox($this->request, null, $this->payload))->handleSharedInbox();
+    }
+}

+ 5 - 0
app/Jobs/LikePipeline/LikePipeline.php

@@ -37,6 +37,11 @@ class LikePipeline implements ShouldQueue
         $status = $this->like->status;
         $actor = $this->like->actor;
 
+        if($status->url !== null) {
+            // Ignore notifications to remote statuses
+            return;
+        }
+
         $exists = Notification::whereProfileId($status->profile_id)
                   ->whereActorId($actor->id)
                   ->whereAction('like')

+ 24 - 22
app/Jobs/StatusPipeline/StatusEntityLexer.php

@@ -2,7 +2,7 @@
 
 namespace App\Jobs\StatusPipeline;
 
-use Cache;
+use DB, Cache;
 use App\{
     Hashtag,
     Media,
@@ -68,12 +68,14 @@ class StatusEntityLexer implements ShouldQueue
 
     public function storeEntities()
     {
-        $status = $this->status;
         $this->storeHashtags();
         $this->storeMentions();
-        $status->rendered = $this->autolink;
-        $status->entities = json_encode($this->entities);
-        $status->save();
+        DB::transaction(function () {
+            $status = $this->status;
+            $status->rendered = $this->autolink;
+            $status->entities = json_encode($this->entities);
+            $status->save();
+        });
     }
 
     public function storeHashtags()
@@ -82,17 +84,15 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
 
         foreach($tags as $tag) {
-            $slug = str_slug($tag);
-            
-            $htag = Hashtag::firstOrCreate(
-                ['name' => $tag],
-                ['slug' => $slug]
-            );
-
-            StatusHashtag::firstOrCreate(
-                ['status_id' => $status->id],
-                ['hashtag_id' => $htag->id]
-            );
+            DB::transaction(function () use ($status, $tag) {
+                $slug = str_slug($tag);
+                $hashtag = Hashtag::firstOrCreate(
+                    ['name' => $tag, 'slug' => $slug]
+                );
+                StatusHashtag::firstOrCreate(
+                    ['status_id' => $status->id, 'hashtag_id' => $hashtag->id]
+                );
+            });
         }
     }
 
@@ -102,16 +102,18 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
 
         foreach($mentions as $mention) {
-            $mentioned = Profile::whereUsername($mention)->first();
+            $mentioned = Profile::whereUsername($mention)->firstOrFail();
             
             if(empty($mentioned) || !isset($mentioned->id)) {
                 continue;
             }
-
-            $m = new Mention;
-            $m->status_id = $status->id;
-            $m->profile_id = $mentioned->id;
-            $m->save();
+            
+            DB::transaction(function () use ($status, $mentioned) {
+                $m = new Mention;
+                $m->status_id = $status->id;
+                $m->profile_id = $mentioned->id;
+                $m->save();
+            });
 
             MentionPipeline::dispatch($status, $m);
         }

+ 7 - 0
app/Media.php

@@ -23,4 +23,11 @@ class Media extends Model
       $url = Storage::url($path);
       return url($url);
     }
+
+    public function thumbnailUrl()
+    {
+      $path = $this->thumbnail_path;
+      $url = Storage::url($path);
+      return url($url);
+    }
 }

+ 7 - 1
app/Observer/UserObserver.php

@@ -2,7 +2,7 @@
 
 namespace App\Observers;
 
-use App\{Profile, User};
+use App\{Profile, User, UserSetting};
 use App\Jobs\AvatarPipeline\CreateAvatar;
 
 class UserObserver
@@ -36,6 +36,12 @@ class UserObserver
 
             CreateAvatar::dispatch($profile);
         }
+
+        if(empty($user->settings)) {
+            $settings = new UserSetting;
+            $settings->user_id = $user->id;
+            $settings->save();
+        }
     }
 
 }

+ 14 - 0
app/Profile.php

@@ -29,6 +29,15 @@ class Profile extends Model
     }
 
     public function url($suffix = '')
+    {
+        if($this->remote_url) {
+            return $this->remote_url;
+        } else {
+            return url($this->username . $suffix);
+        }
+    }
+
+    public function localUrl($suffix = '')
     {
         return url($this->username . $suffix);
     }
@@ -124,4 +133,9 @@ class Profile extends Model
         $url = url(Storage::url($this->avatar->media_path ?? 'public/avatars/default.png'));
         return $url;
     }
+
+    public function statusCount()
+    {
+        return $this->statuses()->whereHas('media')->count();
+    }
 }

+ 3 - 0
app/Providers/EventServiceProvider.php

@@ -16,6 +16,9 @@ class EventServiceProvider extends ServiceProvider
         'App\Events\Event' => [
             'App\Listeners\EventListener',
         ],
+        'auth.login' => [
+            'App\Events\AuthLoginEvent',
+        ],
     ];
 
     /**

+ 10 - 0
app/ReportComment.php

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

+ 10 - 0
app/ReportLog.php

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

+ 44 - 3
app/Status.php

@@ -2,7 +2,7 @@
 
 namespace App;
 
-use Storage;
+use Auth, Storage;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
@@ -71,15 +71,39 @@ class Status extends Model
       return $this->hasMany(Like::class);
     }
 
+    public function liked() : bool
+    {
+      $profile = Auth::user()->profile;
+      return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+    }
+
     public function comments()
     {
       return $this->hasMany(Status::class, 'in_reply_to_id');
     }
 
+    public function bookmarked()
+    {
+      $profile = Auth::user()->profile;
+      return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+    }
+
+    public function shares()
+    {
+      return $this->hasMany(Status::class, 'reblog_of_id');
+    }
+
+    public function shared() : bool
+    {
+      $profile = Auth::user()->profile;
+      return Status::whereProfileId($profile->id)->whereReblogOfId($this->id)->count();
+    }
+
     public function parent()
     {
-      if(!empty($this->in_reply_to_id)) {
-        return Status::findOrFail($this->in_reply_to_id);
+      $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
+      if(!empty($parent)) {
+        return Status::findOrFail($parent);
       }
     }
 
@@ -100,6 +124,23 @@ class Status extends Model
       );
     }
 
+    public function mentions()
+    {
+      return $this->hasManyThrough(
+        Profile::class,
+        Mention::class,
+        'status_id',
+        'id',
+        'id',
+        'profile_id'
+      );
+    }
+
+    public function reportUrl()
+    {
+      return route('report.form') . "?type=post&id={$this->id}";
+    }
+
     public function toActivityStream()
     {
       $media = $this->media;

+ 1 - 1
app/StatusHashtag.php

@@ -6,5 +6,5 @@ use Illuminate\Database\Eloquent\Model;
 
 class StatusHashtag extends Model
 {
-    protected $fillable = ['status_id', 'hashtag_id'];
+    public $fillable = ['status_id', 'hashtag_id'];
 }

+ 33 - 0
app/Transformer/Api/AccountTransformer.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Profile;
+use League\Fractal;
+
+class AccountTransformer extends Fractal\TransformerAbstract
+{
+  public function transform(Profile $profile)
+  {
+      return [
+          'id' => $profile->id,
+          'username' => $profile->username,
+          'acct' => $profile->username,
+          'display_name' => $profile->name,
+          'locked' => (bool) $profile->is_private,
+          'created_at' => $profile->created_at->format('c'),
+          'followers_count' => $profile->followerCount(),
+          'following_count' => $profile->followingCount(),
+          'statuses_count' => $profile->statusCount(),
+          'note' => $profile->bio,
+          'url' => $profile->url(),
+          'avatar' => $profile->avatarUrl(),
+          'avatar_static' => $profile->avatarUrl(),
+          'header' => '',
+          'header_static' => '',
+          'moved' => null,
+          'fields' => null,
+          'bot' => null
+      ];
+  }
+}

+ 16 - 0
app/Transformer/Api/ApplicationTransformer.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use League\Fractal;
+
+class ApplicationTransformer extends Fractal\TransformerAbstract
+{
+  public function transform()
+  {
+      return [
+        'name'  => '',
+        'website' => null
+      ];
+  }
+}

+ 18 - 0
app/Transformer/Api/HashtagTransformer.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Hashtag;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+class HashtagTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Hashtag $hashtag)
+    {
+        return [
+            'name' => $hashtag->name,
+            'url' => $hashtag->url(),
+        ];
+    }
+}

+ 24 - 0
app/Transformer/Api/MediaTransformer.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Media;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+class MediaTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Media $media)
+    {
+        return [
+            'id'  => $media->id,
+            'type' => 'image',
+            'url' => $media->url(),
+            'remote_url' => null,
+            'preview_url' => $media->thumbnailUrl(),
+            'text_url' => null,
+            'meta' => null,
+            'description' => null
+        ];
+    }
+}

+ 19 - 0
app/Transformer/Api/MentionTransformer.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Profile;
+use League\Fractal;
+
+class MentionTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Profile $profile)
+    {
+        return [
+            'id'  => $profile->id,
+            'url' => $profile->url(),
+            'username' => $profile->username,
+            'acct' => $profile->username,
+        ];
+    }
+}

+ 69 - 0
app/Transformer/Api/StatusTransformer.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Status;
+use League\Fractal;
+
+class StatusTransformer extends Fractal\TransformerAbstract
+{
+    protected $defaultIncludes = [
+        'account',
+        'mentions',
+        'media_attachments',
+        'tags'
+    ];
+
+    public function transform(Status $status)
+    {
+        return [
+            'id'  => $status->id,
+            'uri' => $status->url(),
+            'url' => $status->url(),
+            'in_reply_to_id' => $status->in_reply_to_id,
+            'in_reply_to_account_id' => $status->in_reply_to_profile_id,
+            
+            // TODO: fixme
+            'reblog' => null,
+
+            'content' => "<p>$status->rendered</p>",
+            'created_at' => $status->created_at->format('c'),
+            'emojis' => [],
+            'reblogs_count' => $status->shares()->count(),
+            'favourites_count' => $status->likes()->count(),
+            'reblogged' => $status->shared(),
+            'favourited' => $status->liked(),
+            'muted' => null,
+            'sensitive' => (bool) $status->is_nsfw,
+            'spoiler_text' => '',
+            'visibility' => $status->visibility,
+            'application' => null,
+            'language' => null,
+            'pinned' => null
+        ];
+    }
+
+    public function includeAccount(Status $status)
+    {
+        $account = $status->profile;
+        return $this->item($account, new AccountTransformer);
+    }
+
+    public function includeMentions(Status $status)
+    {
+        $mentions = $status->mentions;
+        return $this->collection($mentions, new MentionTransformer);
+    }
+
+    public function includeMediaAttachments(Status $status)
+    {
+        $media = $status->media;
+        return $this->collection($media, new MediaTransformer);
+    }
+
+    public function includeTags(Status $status)
+    {
+        $tags = $status->hashtags;
+        return $this->collection($tags, new HashtagTransformer);
+    }
+}

+ 10 - 0
app/UserFilter.php

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

+ 10 - 0
app/UserSetting.php

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

+ 7 - 1
composer.json

@@ -7,6 +7,7 @@
     "require": {
         "php": "^7.1.3",
         "99designs/http-signatures-guzzlehttp": "^2.0",
+        "beyondcode/laravel-self-diagnosis": "^0.4.0",
         "bitverse/identicon": "^1.1",
         "doctrine/dbal": "^2.7",
         "fideloper/proxy": "^4.0",
@@ -15,9 +16,13 @@
         "kitetail/zttp": "^0.3.0",
         "laravel/framework": "5.6.*",
         "laravel/horizon": "^1.2",
+        "laravel/passport": "^6.0",
         "laravel/tinker": "^1.0",
-        "league/fractal": "^0.17.0",
+        "moontoast/math": "^1.1",
         "phpseclib/phpseclib": "~2.0",
+        "pixelfed/dotenv-editor": "^2.0",
+        "pixelfed/fractal": "^0.18.0",
+        "pixelfed/google2fa-laravel": "^2.0",
         "predis/predis": "^1.1",
         "spatie/laravel-backup": "^5.0.0",
         "spatie/laravel-image-optimizer": "^1.1",
@@ -25,6 +30,7 @@
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.1",
+        "beyondcode/laravel-er-diagram-generator": "^0.2.2",
         "filp/whoops": "^2.0",
         "fzaninotto/faker": "^1.4",
         "mockery/mockery": "^1.0",

文件差異過大導致無法顯示
+ 776 - 69
composer.lock


+ 1 - 1
config/image-optimizer.php

@@ -49,5 +49,5 @@ return [
      * If set to `true` all output of the optimizer binaries will be appended to the default log.
      * You can also set this to a class that implements `Psr\Log\LoggerInterface`.
      */
-    'log_optimizer_activity' => true,
+    'log_optimizer_activity' => false,
 ];

+ 11 - 0
config/pixelfed.php

@@ -77,6 +77,17 @@ return [
 
     'activitypub_enabled' => env('ACTIVITY_PUB', false),
 
+    /*
+    |--------------------------------------------------------------------------
+    | Account file size limit
+    |--------------------------------------------------------------------------
+    |
+    | Update the max account size, the per user limit of files in kb.
+    | This applies to local and remote users. Old remote posts may be GC.
+    |
+    */
+    'max_account_size' => env('MAX_ACCOUNT_SIZE', 100000),
+
     /*
     |--------------------------------------------------------------------------
     | Photo file size limit

+ 36 - 0
database/migrations/2018_04_22_233721_create_web_subs_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateWebSubsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('web_subs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('follower_id')->unsigned()->index();
+            $table->bigInteger('following_id')->unsigned()->index();
+            $table->string('profile_url')->index();
+            $table->timestamp('approved_at')->nullable();
+            $table->unique(['follower_id', 'following_id', 'profile_url']);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('web_subs');
+    }
+}

+ 38 - 0
database/migrations/2018_04_26_003259_create_import_jobs_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateImportJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('import_jobs', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('profile_id')->unsigned();
+            $table->string('service')->default('instagram');
+            $table->string('uuid')->nullable();
+            $table->string('storage_path')->nullable();
+            $table->tinyInteger('stage')->unsigned()->default(0);
+            $table->text('media_json')->nullable();
+            $table->timestamp('completed_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('import_jobs');
+    }
+}

+ 35 - 0
database/migrations/2018_06_22_062621_create_report_comments_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateReportCommentsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('report_comments', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('report_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned();
+            $table->bigInteger('user_id')->unsigned();
+            $table->text('comment');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('report_comments');
+    }
+}

+ 37 - 0
database/migrations/2018_06_22_062628_create_report_logs_table.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateReportLogsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('report_logs', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('profile_id')->unsigned();
+            $table->bigInteger('item_id')->unsigned()->nullable();
+            $table->string('item_type')->nullable();
+            $table->string('action')->nullable();
+            $table->boolean('system_message')->default(false);
+            $table->json('metadata')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('report_logs');
+    }
+}

+ 40 - 0
database/migrations/2018_07_05_010303_create_account_logs_table.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateAccountLogsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('account_logs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->bigInteger('item_id')->unsigned()->nullable();
+            $table->string('item_type')->nullable();
+            $table->string('action')->nullable();
+            $table->string('message')->nullable();
+            $table->string('link')->nullable();
+            $table->string('ip_address')->nullable();
+            $table->string('user_agent')->nullable();
+            $table->json('metadata')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('account_logs');
+    }
+}

+ 50 - 0
database/migrations/2018_07_12_054015_create_user_settings_table.php

@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateUserSettingsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_settings', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->unique();
+            $table->string('role')->default('user');
+            $table->boolean('crawlable')->default(true);
+            $table->boolean('show_guests')->default(true);
+            $table->boolean('show_discover')->default(true);
+            $table->boolean('public_dm')->default(false);
+            $table->boolean('hide_cw_search')->default(true);
+            $table->boolean('hide_blocked_search')->default(true);
+            $table->boolean('always_show_cw')->default(false);
+            $table->boolean('compose_media_descriptions')->default(false);
+            $table->boolean('reduce_motion')->default(false);
+            $table->boolean('optimize_screen_reader')->default(false);
+            $table->boolean('high_contrast_mode')->default(false);
+            $table->boolean('video_autoplay')->default(false);
+            $table->boolean('send_email_new_follower')->default(false);
+            $table->boolean('send_email_new_follower_request')->default(true);
+            $table->boolean('send_email_on_share')->default(false);
+            $table->boolean('send_email_on_like')->default(false);
+            $table->boolean('send_email_on_mention')->default(false);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_settings');
+    }
+}

+ 38 - 0
database/migrations/2018_07_15_011916_add_2fa_to_users_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class Add2faToUsersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->boolean('2fa_enabled')->default(false);
+            $table->string('2fa_secret')->nullable();
+            $table->json('2fa_backup_codes')->nullable();
+            $table->timestamp('2fa_setup_at')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('2fa_enabled');
+            $table->dropColumn('2fa_secret');
+            $table->dropColumn('2fa_backup_codes');
+            $table->dropColumn('2fa_setup_at');
+        });
+    }
+}

+ 41 - 0
database/migrations/2018_07_15_013106_create_user_filters_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateUserFiltersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_filters', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->bigInteger('filterable_id')->unsigned();
+            $table->string('filterable_type');
+            $table->string('filter_type')->default('block')->index();
+            $table->unique([
+                'user_id',
+                'filterable_id',
+                'filterable_type',
+                'filter_type'
+            ], 'filter_unique');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_filters');
+    }
+}

二進制
public/img/pixelfed-icon-color.svg


+ 107 - 0
resources/assets/js/components/passport/AuthorizedClients.vue

@@ -0,0 +1,107 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div v-if="tokens.length > 0">
+            <div class="card card-default mb-4">
+                <div class="card-header font-weight-bold bg-white">Authorized Applications</div>
+
+                <div class="card-body">
+                    <!-- Authorized Tokens -->
+                    <table class="table table-borderless mb-0">
+                        <thead>
+                            <tr>
+                                <th>Name</th>
+                                <th>Scopes</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+
+                        <tbody>
+                            <tr v-for="token in tokens">
+                                <!-- Client Name -->
+                                <td style="vertical-align: middle;">
+                                    {{ token.client.name }}
+                                </td>
+
+                                <!-- Scopes -->
+                                <td style="vertical-align: middle;">
+                                    <span v-if="token.scopes.length > 0">
+                                        {{ token.scopes.join(', ') }}
+                                    </span>
+                                </td>
+
+                                <!-- Revoke Button -->
+                                <td style="vertical-align: middle;">
+                                    <a class="action-link text-danger" @click="revoke(token)">
+                                        Revoke
+                                    </a>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                tokens: []
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component (Vue 2.x).
+             */
+            prepareComponent() {
+                this.getTokens();
+            },
+
+            /**
+             * Get all of the authorized tokens for the user.
+             */
+            getTokens() {
+                axios.get('/oauth/tokens')
+                        .then(response => {
+                            this.tokens = response.data;
+                        });
+            },
+
+            /**
+             * Revoke the given token.
+             */
+            revoke(token) {
+                axios.delete('/oauth/tokens/' + token.id)
+                        .then(response => {
+                            this.getTokens();
+                        });
+            }
+        }
+    }
+</script>

+ 350 - 0
resources/assets/js/components/passport/Clients.vue

@@ -0,0 +1,350 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div class="card card-default mb-4">
+            <div class="card-header font-weight-bold bg-white">
+                <div style="display: flex; justify-content: space-between; align-items: center;">
+                    <span>
+                        OAuth Clients
+                    </span>
+
+                    <a class="action-link" tabindex="-1" @click="showCreateClientForm">
+                        Create New Client
+                    </a>
+                </div>
+            </div>
+
+            <div class="card-body">
+                <!-- Current Clients -->
+                <p class="mb-0" v-if="clients.length === 0">
+                    You have not created any OAuth clients.
+                </p>
+
+                <table class="table table-borderless mb-0" v-if="clients.length > 0">
+                    <thead>
+                        <tr>
+                            <th>Client ID</th>
+                            <th>Name</th>
+                            <th>Secret</th>
+                            <th></th>
+                            <th></th>
+                        </tr>
+                    </thead>
+
+                    <tbody>
+                        <tr v-for="client in clients">
+                            <!-- ID -->
+                            <td style="vertical-align: middle;">
+                                {{ client.id }}
+                            </td>
+
+                            <!-- Name -->
+                            <td style="vertical-align: middle;">
+                                {{ client.name }}
+                            </td>
+
+                            <!-- Secret -->
+                            <td style="vertical-align: middle;">
+                                <code>{{ client.secret }}</code>
+                            </td>
+
+                            <!-- Edit Button -->
+                            <td style="vertical-align: middle;">
+                                <a class="action-link" tabindex="-1" @click="edit(client)">
+                                    Edit
+                                </a>
+                            </td>
+
+                            <!-- Delete Button -->
+                            <td style="vertical-align: middle;">
+                                <a class="action-link text-danger" @click="destroy(client)">
+                                    Delete
+                                </a>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <!-- Create Client Modal -->
+        <div class="modal fade" id="modal-create-client" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Create Client
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="createForm.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in createForm.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Create Client Form -->
+                        <form role="form">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Name</label>
+
+                                <div class="col-md-9">
+                                    <input id="create-client-name" type="text" class="form-control" autocomplete="off"
+                                                                @keyup.enter="store" v-model="createForm.name">
+
+                                    <span class="form-text text-muted">
+                                        Something your users will recognize and trust.
+                                    </span>
+                                </div>
+                            </div>
+
+                            <!-- Redirect URL -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Redirect URL</label>
+
+                                <div class="col-md-9">
+                                    <input type="text" class="form-control" name="redirect"
+                                                    @keyup.enter="store" v-model="createForm.redirect">
+
+                                    <span class="form-text text-muted">
+                                        Your application's authorization callback URL.
+                                    </span>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary font-weight-bold" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary font-weight-bold" @click="store">
+                            Create
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Edit Client Modal -->
+        <div class="modal fade" id="modal-edit-client" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Edit Client
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="editForm.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in editForm.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Edit Client Form -->
+                        <form role="form">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Name</label>
+
+                                <div class="col-md-9">
+                                    <input id="edit-client-name" type="text" class="form-control"
+                                                                @keyup.enter="update" v-model="editForm.name">
+
+                                    <span class="form-text text-muted">
+                                        Something your users will recognize and trust.
+                                    </span>
+                                </div>
+                            </div>
+
+                            <!-- Redirect URL -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Redirect URL</label>
+
+                                <div class="col-md-9">
+                                    <input type="text" class="form-control" name="redirect"
+                                                    @keyup.enter="update" v-model="editForm.redirect">
+
+                                    <span class="form-text text-muted">
+                                        Your application's authorization callback URL.
+                                    </span>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary" @click="update">
+                            Save Changes
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                clients: [],
+
+                createForm: {
+                    errors: [],
+                    name: '',
+                    redirect: ''
+                },
+
+                editForm: {
+                    errors: [],
+                    name: '',
+                    redirect: ''
+                }
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component.
+             */
+            prepareComponent() {
+                this.getClients();
+
+                $('#modal-create-client').on('shown.bs.modal', () => {
+                    $('#create-client-name').focus();
+                });
+
+                $('#modal-edit-client').on('shown.bs.modal', () => {
+                    $('#edit-client-name').focus();
+                });
+            },
+
+            /**
+             * Get all of the OAuth clients for the user.
+             */
+            getClients() {
+                axios.get('/oauth/clients')
+                        .then(response => {
+                            this.clients = response.data;
+                        });
+            },
+
+            /**
+             * Show the form for creating new clients.
+             */
+            showCreateClientForm() {
+                $('#modal-create-client').modal('show');
+            },
+
+            /**
+             * Create a new OAuth client for the user.
+             */
+            store() {
+                this.persistClient(
+                    'post', '/oauth/clients',
+                    this.createForm, '#modal-create-client'
+                );
+            },
+
+            /**
+             * Edit the given client.
+             */
+            edit(client) {
+                this.editForm.id = client.id;
+                this.editForm.name = client.name;
+                this.editForm.redirect = client.redirect;
+
+                $('#modal-edit-client').modal('show');
+            },
+
+            /**
+             * Update the client being edited.
+             */
+            update() {
+                this.persistClient(
+                    'put', '/oauth/clients/' + this.editForm.id,
+                    this.editForm, '#modal-edit-client'
+                );
+            },
+
+            /**
+             * Persist the client to storage using the given form.
+             */
+            persistClient(method, uri, form, modal) {
+                form.errors = [];
+
+                axios[method](uri, form)
+                    .then(response => {
+                        this.getClients();
+
+                        form.name = '';
+                        form.redirect = '';
+                        form.errors = [];
+
+                        $(modal).modal('hide');
+                    })
+                    .catch(error => {
+                        if (typeof error.response.data === 'object') {
+                            form.errors = _.flatten(_.toArray(error.response.data.errors));
+                        } else {
+                            form.errors = ['Something went wrong. Please try again.'];
+                        }
+                    });
+            },
+
+            /**
+             * Destroy the given client.
+             */
+            destroy(client) {
+                axios.delete('/oauth/clients/' + client.id)
+                        .then(response => {
+                            this.getClients();
+                        });
+            }
+        }
+    }
+</script>

+ 298 - 0
resources/assets/js/components/passport/PersonalAccessTokens.vue

@@ -0,0 +1,298 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div>
+            <div class="card card-default mb-4">
+                <div class="card-header font-weight-bold bg-white">
+                    <div style="display: flex; justify-content: space-between; align-items: center;">
+                        <span>
+                            Personal Access Tokens
+                        </span>
+
+                        <a class="action-link" tabindex="-1" @click="showCreateTokenForm">
+                            Create New Token
+                        </a>
+                    </div>
+                </div>
+
+                <div class="card-body">
+                    <!-- No Tokens Notice -->
+                    <p class="mb-0" v-if="tokens.length === 0">
+                        You have not created any personal access tokens.
+                    </p>
+
+                    <!-- Personal Access Tokens -->
+                    <table class="table table-borderless mb-0" v-if="tokens.length > 0">
+                        <thead>
+                            <tr>
+                                <th>Name</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+
+                        <tbody>
+                            <tr v-for="token in tokens">
+                                <!-- Client Name -->
+                                <td style="vertical-align: middle;">
+                                    {{ token.name }}
+                                </td>
+
+                                <!-- Delete Button -->
+                                <td style="vertical-align: middle;">
+                                    <a class="action-link text-danger" @click="revoke(token)">
+                                        Delete
+                                    </a>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+
+        <!-- Create Token Modal -->
+        <div class="modal fade" id="modal-create-token" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Create Token
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="form.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in form.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Create Token Form -->
+                        <form role="form" @submit.prevent="store">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-4 col-form-label">Name</label>
+
+                                <div class="col-md-6">
+                                    <input id="create-token-name" type="text" class="form-control" name="name" v-model="form.name" autocomplete="off">
+                                </div>
+                            </div>
+
+                            <!-- Scopes -->
+                            <div class="form-group row" v-if="scopes.length > 0">
+                                <label class="col-md-4 col-form-label">Scopes</label>
+
+                                <div class="col-md-6">
+                                    <div v-for="scope in scopes">
+                                        <div class="checkbox">
+                                            <label>
+                                                <input type="checkbox"
+                                                    @click="toggleScope(scope.id)"
+                                                    :checked="scopeIsAssigned(scope.id)">
+
+                                                    {{ scope.id }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary font-weight-bold" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary font-weight-bold" @click="store">
+                            Create
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Access Token Modal -->
+        <div class="modal fade" id="modal-access-token" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Personal Access Token
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <p>
+                            Here is your new personal access token. This is the only time it will be shown so don't lose it!
+                            You may now use this token to make API requests.
+                        </p>
+
+                        <textarea class="form-control" rows="10">{{ accessToken }}</textarea>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                accessToken: null,
+
+                tokens: [],
+                scopes: [],
+
+                form: {
+                    name: '',
+                    scopes: [],
+                    errors: []
+                }
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component.
+             */
+            prepareComponent() {
+                this.getTokens();
+                this.getScopes();
+
+                $('#modal-create-token').on('shown.bs.modal', () => {
+                    $('#create-token-name').focus();
+                });
+            },
+
+            /**
+             * Get all of the personal access tokens for the user.
+             */
+            getTokens() {
+                axios.get('/oauth/personal-access-tokens')
+                        .then(response => {
+                            this.tokens = response.data;
+                        });
+            },
+
+            /**
+             * Get all of the available scopes.
+             */
+            getScopes() {
+                axios.get('/oauth/scopes')
+                        .then(response => {
+                            this.scopes = response.data;
+                        });
+            },
+
+            /**
+             * Show the form for creating new tokens.
+             */
+            showCreateTokenForm() {
+                $('#modal-create-token').modal('show');
+            },
+
+            /**
+             * Create a new personal access token.
+             */
+            store() {
+                this.accessToken = null;
+
+                this.form.errors = [];
+
+                axios.post('/oauth/personal-access-tokens', this.form)
+                        .then(response => {
+                            this.form.name = '';
+                            this.form.scopes = [];
+                            this.form.errors = [];
+
+                            this.tokens.push(response.data.token);
+
+                            this.showAccessToken(response.data.accessToken);
+                        })
+                        .catch(error => {
+                            if (typeof error.response.data === 'object') {
+                                this.form.errors = _.flatten(_.toArray(error.response.data.errors));
+                            } else {
+                                this.form.errors = ['Something went wrong. Please try again.'];
+                            }
+                        });
+            },
+
+            /**
+             * Toggle the given scope in the list of assigned scopes.
+             */
+            toggleScope(scope) {
+                if (this.scopeIsAssigned(scope)) {
+                    this.form.scopes = _.reject(this.form.scopes, s => s == scope);
+                } else {
+                    this.form.scopes.push(scope);
+                }
+            },
+
+            /**
+             * Determine if the given scope has been assigned to the token.
+             */
+            scopeIsAssigned(scope) {
+                return _.indexOf(this.form.scopes, scope) >= 0;
+            },
+
+            /**
+             * Show the given access token to the user.
+             */
+            showAccessToken(accessToken) {
+                $('#modal-create-token').modal('hide');
+
+                this.accessToken = accessToken;
+
+                $('#modal-access-token').modal('show');
+            },
+
+            /**
+             * Revoke the given token.
+             */
+            revoke(token) {
+                axios.delete('/oauth/personal-access-tokens/' + token.id)
+                        .then(response => {
+                            this.getTokens();
+                        });
+            }
+        }
+    }
+</script>

+ 5 - 3
resources/views/layouts/partial/nav.blade.php

@@ -1,8 +1,8 @@
 <nav class="navbar navbar-expand navbar-light navbar-laravel sticky-top">
     <div class="container">
         <a class="navbar-brand d-flex align-items-center" href="{{ url('/timeline') }}" title="Logo">
-            <img src="/img/pixelfed-icon-black.svg" height="60px" class="p-2">
-            <span class="h4 font-weight-bold mb-0">{{ config('app.name', 'Laravel') }}</span>
+            <img src="/img/pixelfed-icon-color.svg" height="30px" class="px-2">
+            <span class="font-weight-bold mb-0" style="font-size:20px;">{{ config('app.name', 'Laravel') }}</span>
         </a>
 
         <div class="collapse navbar-collapse" id="navbarSupportedContent">
@@ -23,7 +23,9 @@
                         <a class="nav-link" href="{{route('discover')}}" title="Discover"><i class="far fa-compass fa-lg"></i></a>
                     </li>
                     <li class="nav-item px-2">
-                        <a class="nav-link" href="{{route('notifications')}}" title="Notifications"><i class="far fa-heart fa-lg"></i></a>
+                        <a class="nav-link" href="{{route('notifications')}}" title="Notifications">
+                            <i class="far fa-heart fa-lg"></i>
+                        </a>
                     </li>
                     <li class="nav-item dropdown px-2">
                         <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre title="User Menu">

+ 0 - 43
resources/views/settings/home.blade.php

@@ -35,49 +35,6 @@
         <input type="email" class="form-control" id="email" name="email" placeholder="Email Address" value="{{Auth::user()->email}}" readonly>
       </div>
     </div>
-    {{--<div class="form-group row">
-      <label for="inputPassword3" class="col-sm-3 col-form-label">Password</label>
-      <div class="col-sm-9">
-        <input type="password" class="form-control" id="inputPassword3" placeholder="Password">
-      </div>
-    </div>
-    <hr>
-    <fieldset class="form-group">
-      <div class="row">
-        <legend class="col-form-label col-sm-3 pt-0">Radios</legend>
-        <div class="col-sm-9">
-          <div class="form-check">
-            <input class="form-check-input" type="radio" name="gridRadios" id="gridRadios1" value="option1" checked>
-            <label class="form-check-label" for="gridRadios1">
-              First radio
-            </label>
-          </div>
-          <div class="form-check">
-            <input class="form-check-input" type="radio" name="gridRadios" id="gridRadios2" value="option2">
-            <label class="form-check-label" for="gridRadios2">
-              Second radio
-            </label>
-          </div>
-          <div class="form-check disabled">
-            <input class="form-check-input" type="radio" name="gridRadios" id="gridRadios3" value="option3" disabled>
-            <label class="form-check-label" for="gridRadios3">
-              Third disabled radio
-            </label>
-          </div>
-        </div>
-      </div>
-    </fieldset>
-    <div class="form-group row">
-      <div class="col-sm-3">Checkbox</div>
-      <div class="col-sm-9">
-        <div class="form-check">
-          <input class="form-check-input" type="checkbox" id="gridCheck1">
-          <label class="form-check-label" for="gridCheck1">
-            Example checkbox
-          </label>
-        </div>
-      </div>
-    </div>--}}
     <hr>
     <div class="form-group row">
       <div class="col-sm-9">

部分文件因文件數量過多而無法顯示