瀏覽代碼

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

v0.8.0rc1
daniel 6 年之前
父節點
當前提交
afc758764c
共有 100 個文件被更改,包括 2366 次插入261 次删除
  1. 11 0
      .env.example
  2. 2 1
      .env.testing
  3. 38 79
      README.md
  4. 4 1
      app/AccountLog.php
  5. 10 0
      app/Activity.php
  6. 12 1
      app/Bookmark.php
  7. 38 0
      app/Circle.php
  8. 13 0
      app/CircleProfile.php
  9. 4 1
      app/Collection.php
  10. 4 1
      app/CollectionItem.php
  11. 64 0
      app/DiscoverCategory.php
  12. 13 0
      app/DiscoverCategoryHashtag.php
  13. 5 0
      app/EmailVerification.php
  14. 19 0
      app/FailedJob.php
  15. 105 0
      app/Http/Controllers/Admin/AdminDiscoverController.php
  16. 101 0
      app/Http/Controllers/Admin/AdminInstanceController.php
  17. 48 0
      app/Http/Controllers/Admin/AdminMediaController.php
  18. 114 0
      app/Http/Controllers/Admin/AdminSettingsController.php
  19. 121 18
      app/Http/Controllers/AdminController.php
  20. 43 7
      app/Http/Controllers/Api/BaseApiController.php
  21. 10 2
      app/Http/Controllers/Auth/RegisterController.php
  22. 69 0
      app/Http/Controllers/CircleController.php
  23. 10 0
      app/Http/Controllers/CircleProfileController.php
  24. 24 0
      app/Http/Controllers/DeckController.php
  25. 10 7
      app/Http/Controllers/DirectMessageController.php
  26. 10 0
      app/Http/Controllers/DiscoverCategoryController.php
  27. 10 0
      app/Http/Controllers/DiscoverCategoryHashtagController.php
  28. 29 1
      app/Http/Controllers/DiscoverController.php
  29. 32 31
      app/Http/Controllers/FederationController.php
  30. 2 1
      app/Http/Controllers/FollowerController.php
  31. 1 1
      app/Http/Controllers/Import/Instagram.php
  32. 105 6
      app/Http/Controllers/InternalApiController.php
  33. 67 0
      app/Http/Controllers/MicroController.php
  34. 53 0
      app/Http/Controllers/PageController.php
  35. 2 2
      app/Http/Controllers/ProfileController.php
  36. 102 2
      app/Http/Controllers/PublicApiController.php
  37. 32 0
      app/Http/Controllers/SearchController.php
  38. 52 2
      app/Http/Controllers/Settings/PrivacySettings.php
  39. 15 0
      app/Http/Controllers/SettingsController.php
  40. 34 16
      app/Http/Controllers/SiteController.php
  41. 3 0
      app/Http/Controllers/StatusController.php
  42. 12 0
      app/Http/Controllers/StoryController.php
  43. 59 1
      app/Instance.php
  44. 7 0
      app/Jobs/AvatarPipeline/AvatarOptimize.php
  45. 7 0
      app/Jobs/AvatarPipeline/CreateAvatar.php
  46. 95 0
      app/Jobs/AvatarPipeline/ImportAvatar.php
  47. 7 0
      app/Jobs/CommentPipeline/CommentPipeline.php
  48. 7 0
      app/Jobs/FollowPipeline/FollowActivityPubDeliver.php
  49. 7 0
      app/Jobs/FollowPipeline/FollowPipeline.php
  50. 7 0
      app/Jobs/ImageOptimizePipeline/ImageOptimize.php
  51. 7 0
      app/Jobs/ImageOptimizePipeline/ImageResize.php
  52. 7 0
      app/Jobs/ImageOptimizePipeline/ImageThumbnail.php
  53. 28 10
      app/Jobs/ImageOptimizePipeline/ImageUpdate.php
  54. 8 1
      app/Jobs/ImportPipeline/ImportInstagram.php
  55. 7 0
      app/Jobs/LikePipeline/LikePipeline.php
  56. 7 0
      app/Jobs/MentionPipeline/MentionPipeline.php
  57. 21 14
      app/Jobs/SharePipeline/SharePipeline.php
  58. 8 1
      app/Jobs/StatusPipeline/NewStatusPipeline.php
  59. 8 1
      app/Jobs/StatusPipeline/StatusActivityPubDeliver.php
  60. 8 1
      app/Jobs/StatusPipeline/StatusDelete.php
  61. 8 1
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  62. 1 1
      app/Jobs/VideoPipeline/VideoThumbnail.php
  63. 16 1
      app/Media.php
  64. 18 0
      app/OauthClient.php
  65. 25 0
      app/Page.php
  66. 6 1
      app/Profile.php
  67. 3 5
      app/Providers/AuthServiceProvider.php
  68. 4 1
      app/ReportComment.php
  69. 4 1
      app/ReportLog.php
  70. 21 1
      app/Status.php
  71. 10 0
      app/StatusHashtag.php
  72. 28 1
      app/Story.php
  73. 19 0
      app/StoryItem.php
  74. 4 1
      app/StoryReaction.php
  75. 13 0
      app/StoryView.php
  76. 1 1
      app/Transformer/ActivityPub/StatusTransformer.php
  77. 5 1
      app/Transformer/Api/AccountTransformer.php
  78. 1 1
      app/Transformer/Api/AttachmentTransformer.php
  79. 1 1
      app/Transformer/Api/MediaTransformer.php
  80. 1 1
      app/Transformer/Api/MentionTransformer.php
  81. 2 1
      app/Transformer/Api/NotificationTransformer.php
  82. 6 4
      app/Transformer/Api/RelationshipTransformer.php
  83. 23 5
      app/Transformer/Api/ResultsTransformer.php
  84. 1 1
      app/Transformer/Api/StatusTransformer.php
  85. 27 0
      app/Transformer/Api/StoryItemTransformer.php
  86. 34 0
      app/Transformer/Api/StoryTransformer.php
  87. 5 0
      app/User.php
  88. 5 1
      app/UserFilter.php
  89. 25 0
      app/Util/ActivityPub/Validator/Follow.php
  90. 25 0
      app/Util/ActivityPub/Validator/Like.php
  91. 41 16
      app/Util/Lexer/RestrictedNames.php
  92. 6 0
      composer.json
  93. 1 1
      config/auth.php
  94. 15 0
      config/filesystems.php
  95. 41 1
      config/pixelfed.php
  96. 2 2
      config/purify.php
  97. 9 1
      contrib/docker/Dockerfile.apache
  98. 77 0
      database/migrations/2019_01_12_054413_stories.php
  99. 40 0
      database/migrations/2019_01_22_030129_create_pages_table.php
  100. 34 0
      database/migrations/2019_02_01_023357_add_remote_to_avatars_table.php

+ 11 - 0
.env.example

@@ -53,3 +53,14 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
 MIX_APP_URL="${APP_URL}"
 MIX_API_BASE="${API_BASE}"
 MIX_API_SEARCH="${API_SEARCH}"
+
+ACTIVITYPUB_INBOX=false
+ACTIVITYPUB_SHAREDINBOX=false
+
+# Set these both "true" to enable federation.
+# You might need to also run:
+#   php artisan cache:clear
+#   php artisan optimize:clear
+#   php artisan optimize
+ACTIVITY_PUB=false
+REMOTE_FOLLOW=false

+ 2 - 1
.env.testing

@@ -40,7 +40,7 @@ SESSION_SECURE_COOKIE=true
 API_BASE="/api/1/"
 API_SEARCH="/api/search"
 
-OPEN_REGISTRATION=true
+OPEN_REGISTRATION=false
 RECAPTCHA_ENABLED=false
 ENFORCE_EMAIL_VERIFICATION=true
 
@@ -55,3 +55,4 @@ MIX_API_BASE="${API_BASE}"
 MIX_API_SEARCH="${API_SEARCH}"
 
 TELESCOPE_ENABLED=false
+PF_MAX_USERS=1000

+ 38 - 79
README.md

@@ -1,66 +1,27 @@
-# PixelFed: Federated Image Sharing
-[![Backers on Open Collective](https://opencollective.com/pixelfed-528/backers/badge.svg)](#backers)
- [![Sponsors on Open Collective](https://opencollective.com/pixelfed-528/sponsors/badge.svg)](#sponsors) 
-
-PixelFed is a federated social image sharing platform, similar to Instagram.
-Federation is done using the [ActivityPub](https://activitypub.rocks/) protocol,
-which is used by [Mastodon](http://joinmastodon.org/), [PeerTube](https://joinpeertube.org/en/),
-[Pleroma](https://pleroma.social/), and more. Through ActivityPub PixelFed can share
-and interact with these platforms, as well as other instances of PixelFed. 
-
-**_Please note this is alpha software, not recommended for production use,
-and federation is not supported yet._**
-
-PixelFed is very early into the development stage. If you would like to have a
-permanent instance with minimal breakage, **do not use this software until
-there is a stable release**. The following setup instructions are intended for
-testing and development.
-
-## Requirements
- - PHP >= 7.1.3 < 7.3 (7.2.x recommended for stable version)
- - MySQL >= 5.7 (Postgres, MariaDB and sqlite are not supported)
- - Redis
- - Composer
- - GD or ImageMagick
- - OpenSSL PHP Extension
- - PDO PHP Extension
- - Mbstring PHP Extension
- - Tokenizer PHP Extension
- - XML PHP Extension
- - Ctype PHP Extension
- - JSON PHP Extension
- - BCMath PHP Extension
- - JpegOptim
- - Optipng
- - Pngquant 2
- - SVGO
- - Gifsicle
-
-## Installation
-
-This guide assumes you have NGINX/Apache installed, along with the dependencies.
-Those will not be covered in these early docs.
-
-```bash
-git clone https://github.com/pixelfed/pixelfed.git
-cd pixelfed
-composer install
-cp .env.example .env
-```
-
-**Edit .env file with proper values**
-
-```bash
-php artisan key:generate
-```
-
-```bash
-php artisan storage:link
-php artisan migrate
-php artisan horizon
-```
+<p align="center"><img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/logos/pixelfed-full-color.svg" width="300px"></p>
 
+<p align="center">
+<a href="https://circleci.com/gh/pixelfed/pixelfed"><img src="https://circleci.com/gh/pixelfed/pixelfed.svg?style=svg" alt="Build Status"></a>
+<a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/d/total.svg" alt="Total Downloads"></a>
+<a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/v/stable.svg" alt="Latest Stable Version"></a>
+<a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/license.svg" alt="License"></a>
+</p>
 
+## Introduction
+
+A free and ethical photo sharing platform, powered by ActivityPub federation.
+
+<p align="center">
+<img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/media/Screen%20Shot%202019-02-05%20at%206.34.59%20PM.png">
+</p>
+
+## Official Documentation
+
+Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://pixelfed.github.io/docs/master/).
+
+## License
+
+Pixelfed is open-sourced software licensed under the AGPL license.
 
 ## Communication
 
@@ -68,7 +29,7 @@ The ways you can communicate on the project are below. Before interacting, pleas
 read through the [Code Of Conduct](CODE_OF_CONDUCT.md).
 
 * IRC: #pixelfed on irc.freenode.net ([#freenode_#pixelfed:matrix.org through
-Matrix](https://matrix.to/#/#freenode_#pixelfed:matrix.org)
+Matrix](https://matrix.to/#/#freenode_#pixelfed:matrix.org))
 * Project on Mastodon: [@pixelfed@mastodon.social](https://mastodon.social/@pixelfed)
 * E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org)
 
@@ -80,29 +41,27 @@ https://www.patreon.com/dansup
 ### Contributors
 
 This project exists thanks to all the people who contribute. 
-<a href="https://github.com/pixelfed/pixelfed/graphs/contributors"><img src="https://opencollective.com/pixelfed-528/contributors.svg?width=890&button=false" /></a>
+<a href="https://github.com/pixelfed/pixelfed/graphs/contributors"><img src="https://opencollective.com/pixelfed/contributors.svg?width=890&button=false" /></a>
 
 
 ### Backers
 
-Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/pixelfed-528#backer)]
+Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/pixelfed#backer)]
 
-<a href="https://opencollective.com/pixelfed-528#backers" target="_blank"><img src="https://opencollective.com/pixelfed-528/backers.svg?width=890"></a>
+<a href="https://opencollective.com/pixelfed#backers" target="_blank"><img src="https://opencollective.com/pixelfed/backers.svg?width=890"></a>
 
 
 ### Sponsors
 
-Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/pixelfed-528#sponsor)]
-
-<a href="https://opencollective.com/pixelfed-528/sponsor/0/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/0/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/1/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/1/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/2/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/2/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/3/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/3/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/4/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/4/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/5/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/5/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/6/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/6/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/7/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/7/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/8/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/8/avatar.svg"></a>
-<a href="https://opencollective.com/pixelfed-528/sponsor/9/website" target="_blank"><img src="https://opencollective.com/pixelfed-528/sponsor/9/avatar.svg"></a>
-
-
+Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/pixelfed#sponsor)]
+
+<a href="https://opencollective.com/pixelfed/sponsor/0/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/0/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/1/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/1/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/2/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/2/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/3/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/3/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/4/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/4/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/5/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/5/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/6/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/6/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/7/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/7/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/8/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/8/avatar.svg"></a>
+<a href="https://opencollective.com/pixelfed/sponsor/9/website" target="_blank"><img src="https://opencollective.com/pixelfed/sponsor/9/avatar.svg"></a>

+ 4 - 1
app/AccountLog.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class AccountLog extends Model
 {
-    //
+    public function user()
+    {
+    	return $this->belongsTo(User::class);
+    }
 }

+ 10 - 0
app/Activity.php

@@ -7,4 +7,14 @@ use Illuminate\Database\Eloquent\Model;
 class Activity extends Model
 {
     protected $dates = ['processed_at'];
+
+	public function toProfile()
+	{
+		return $this->belongsTo(Profile::class, 'to_id');
+	}
+
+	public function fromProfile()
+	{
+		return $this->belongsTo(Profile::class, 'from_id');
+	}
 }

+ 12 - 1
app/Bookmark.php

@@ -6,5 +6,16 @@ use Illuminate\Database\Eloquent\Model;
 
 class Bookmark extends Model
 {
-    protected $fillable = ['profile_id', 'status_id'];
+	protected $fillable = ['profile_id', 'status_id'];
+
+	public function status()
+	{
+		return $this->belongsTo(Status::class);
+	}
+
+
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
 }

+ 38 - 0
app/Circle.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Circle extends Model
+{
+    protected $fillable = [
+    	'name',
+    	'description',
+    	'bcc',
+    	'scope',
+    	'active'
+    ];
+
+    public function members()
+    {
+    	return $this->hasManyThrough(
+    		Profile::class,
+    		CircleProfile::class,
+    		'circle_id',
+    		'id',
+    		'id',
+    		'profile_id'
+    	);
+    }
+
+    public function owner()
+    {
+    	return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function url()
+    {
+        return url("/i/circle/show/{$this->id}");
+    }
+}

+ 13 - 0
app/CircleProfile.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CircleProfile extends Model
+{
+    protected $fillable = [
+    	'circle_id',
+    	'profile_id'
+    ];
+}

+ 4 - 1
app/Collection.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class Collection extends Model
 {
-    //
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
 }

+ 4 - 1
app/CollectionItem.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class CollectionItem extends Model
 {
-    //
+	public function collection()
+	{
+		return $this->belongsTo(Collection::class);
+	}
 }

+ 64 - 0
app/DiscoverCategory.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use App\{Status, StatusHashtag};
+
+class DiscoverCategory extends Model
+{
+    protected $fillable = ['slug'];
+
+    public function media()
+    {
+    	return $this->belongsTo(Media::class);
+    }
+
+    public function url()
+    {
+    	return url('/discover/c/'.$this->slug);
+    }
+
+    public function editUrl()
+    {
+    	return url('/i/admin/discover/category/edit/' . $this->id);
+    }
+
+    public function thumb()
+    {
+    	return $this->media->thumb();
+    }
+
+    public function mediaUrl()
+    {
+        return $this->media->url();
+    }
+
+
+    public function items()
+    {
+    	return $this->hasMany(DiscoverCategoryHashtag::class, 'discover_category_id');
+    }
+
+    public function hashtags()
+    {
+    	return $this->hasManyThrough(
+    		Hashtag::class,
+    		DiscoverCategoryHashtag::class,
+    		'discover_category_id',
+    		'id',
+    		'id',
+    		'hashtag_id'
+    	);
+    }
+
+    public function posts()
+    {
+    	return Status::select('*')
+    		->join('status_hashtags', 'statuses.id', '=', 'status_hashtags.status_id')
+    		->join('hashtags', 'status_hashtags.hashtag_id', '=', 'hashtags.id')
+    		->join('discover_category_hashtags', 'hashtags.id', '=', 'discover_category_hashtags.hashtag_id')
+    		->join('discover_categories', 'discover_category_hashtags.discover_category_id', '=', 'discover_categories.id')
+    		->where('discover_categories.id', $this->id);
+    }
+}

+ 13 - 0
app/DiscoverCategoryHashtag.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class DiscoverCategoryHashtag extends Model
+{
+    protected $fillable = [
+    	'discover_category_id',
+    	'hashtag_id'
+    ];
+}

+ 5 - 0
app/EmailVerification.php

@@ -13,4 +13,9 @@ class EmailVerification extends Model
 
         return "{$base}{$path}";
     }
+
+	public function user()
+	{
+		return $this->belongsTo(User::class);
+	}
 }

+ 19 - 0
app/FailedJob.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use Carbon\Carbon;
+
+class FailedJob extends Model
+{
+    const CREATED_AT = 'failed_at';
+    const UPDATED_AT = 'failed_at';
+
+    public $timestamps = 'failed_at';
+
+    public function getFailedAtAttribute($val)
+    {
+    	return Carbon::parse($val);
+    }
+}

+ 105 - 0
app/Http/Controllers/Admin/AdminDiscoverController.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use DB, Cache;
+use App\{
+	DiscoverCategory, 
+	DiscoverCategoryHashtag, 
+	Hashtag, 
+	Media, 
+	Profile, 
+	StatusHashtag
+};
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+
+trait AdminDiscoverController
+{
+	public function discoverHome()
+	{
+		$categories = DiscoverCategory::orderByDesc('id')->paginate(10);
+		return view('admin.discover.home', compact('categories'));
+	}
+
+	public function discoverCreateCategory()
+	{
+		return view('admin.discover.create-category');
+	}
+
+	public function discoverCreateCategoryStore(Request $request)
+	{
+		$this->validate($request, [
+			'name' => 'required|string|min:1',
+			'active' => 'required|boolean',
+			'media' => 'nullable|integer|min:1'
+		]);
+
+		$name = $request->input('name');
+		$slug = str_slug($name);
+		$active = $request->input('active');
+		$media = (int) $request->input('media');
+
+		$media = Media::findOrFail($media);
+
+		$category = DiscoverCategory::firstOrNew(['slug' => $slug]);
+		$category->name = $name;
+		$category->active = $active;
+		$category->media_id = $media->id;
+		$category->save();
+		return $category;
+	}
+
+	public function discoverCategoryEdit(Request $request, $id)
+	{
+		$category = DiscoverCategory::findOrFail($id);
+		return view('admin.discover.show', compact('category'));
+	}
+
+	public function discoverCategoryUpdate(Request $request, $id)
+	{
+		$this->validate($request, [
+			'name' => 'required|string|min:1',
+			'active' => 'required|boolean',
+			'media' => 'nullable|integer|min:1',
+			'hashtags' => 'nullable|string'
+		]);
+		$name = $request->input('name');
+		$slug = str_slug($name);
+		$active = $request->input('active');
+		$media = (int) $request->input('media');
+		$media = Media::findOrFail($media);
+
+		$category = DiscoverCategory::findOrFail($id);
+		$category->name = $name;
+		$category->active = $active;
+		$category->media_id = $media->id;
+		$category->save();
+
+		return $category;
+	}
+
+	public function discoveryCategoryTagStore(Request $request)
+	{
+		$this->validate($request, [
+			'category_id' => 'required|integer|min:1',
+			'hashtag' => 'required|string',
+			'action' => 'required|string|min:1|max:6'
+		]);
+		$category_id = $request->input('category_id');
+		$category = DiscoverCategory::findOrFail($category_id);
+		$hashtag = Hashtag::whereName($request->input('hashtag'))->firstOrFail();
+
+		$tag = DiscoverCategoryHashtag::firstOrCreate([
+			'hashtag_id' => $hashtag->id,
+			'discover_category_id' => $category->id
+		]);
+
+		if($request->input('action') == 'delete') {
+			$tag->delete();
+			return [];
+		}
+		return $tag;
+	}
+}

+ 101 - 0
app/Http/Controllers/Admin/AdminInstanceController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use DB, Cache;
+use App\{Instance, Profile};
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+
+trait AdminInstanceController
+{
+
+	public function instances(Request $request)
+	{
+		$this->validate($request, [
+			'filter' => [
+				'nullable',
+				'string',
+				'min:1',
+				'max:20',
+				Rule::in(['autocw', 'unlisted', 'banned'])
+			],
+		]);
+		if($request->has('filter') && $request->filled('filter')) {
+			switch ($request->filter) {
+				case 'autocw':
+					$instances = Instance::whereAutoCw(true)->orderByDesc('id')->paginate(5);
+					break;
+				case 'unlisted':
+					$instances = Instance::whereUnlisted(true)->orderByDesc('id')->paginate(5);
+					break;
+				case 'banned':
+					$instances = Instance::whereBanned(true)->orderByDesc('id')->paginate(5);
+					break;
+			}
+		} else {
+			$instances = Instance::orderByDesc('id')->paginate(5);
+		}
+		return view('admin.instances.home', compact('instances'));
+	}
+
+	public function instanceScan(Request $request)
+	{
+		DB::transaction(function() {
+			Profile::whereNotNull('domain')
+				->groupBy('domain')
+				->chunk(50, function($domains) {
+					foreach($domains as $domain) {
+						Instance::firstOrCreate([
+							'domain' => $domain->domain
+						]);
+					}
+				});
+		});
+		return redirect()->back();
+	}
+
+	public function instanceShow(Request $request, $id)
+	{
+		$instance = Instance::findOrFail($id);
+		return view('admin.instances.show', compact('instance'));
+	}
+
+	public function instanceEdit(Request $request, $id)
+	{
+		$this->validate($request, [
+			'action' => [
+				'required',
+				'string',
+				'min:1',
+				'max:20',
+				Rule::in(['autocw', 'unlist', 'ban'])
+			],
+		]);
+
+		$instance = Instance::findOrFail($id);
+		$unlisted = $instance->unlisted;
+		$autocw = $instance->auto_cw;
+		$banned = $instance->banned;
+
+		switch ($request->action) {
+			case 'autocw':
+				$instance->auto_cw = $autocw == true ? false : true;
+				$instance->save();
+				break;
+
+			case 'unlist':
+				$instance->unlisted = $unlisted == true ? false : true;
+				$instance->save();
+				break;
+
+			case 'ban':
+				$instance->banned = $banned == true ? false : true;
+				$instance->save();
+				break;
+		}
+
+		return response()->json([]);
+	}
+}

+ 48 - 0
app/Http/Controllers/Admin/AdminMediaController.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use DB, Cache;
+use App\{
+	Media,
+	Profile,
+	Status
+};
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+
+trait AdminMediaController
+{
+	public function media(Request $request)
+	{
+		$this->validate($request, [
+			'layout' => [
+				'nullable',
+				'string',
+				'min:1',
+				'max:4',
+				Rule::in(['grid','list'])
+			],
+			'search' => 'nullable|string|min:1|max:20'
+		]);
+		if($request->filled('search')) {
+			$profiles = Profile::where('username', 'like', '%'.$request->input('search').'%')->pluck('id')->toArray();
+			$media = Media::whereHas('status')
+				->with('status')
+				->orderby('id', 'desc')
+				->whereIn('profile_id', $profiles)
+				->orWhere('mime', $request->input('search'))
+				->paginate(12);
+		} else {
+			$media = Media::whereHas('status')->with('status')->orderby('id', 'desc')->paginate(12);
+		}
+		return view('admin.media.home', compact('media'));
+	}
+
+	public function mediaShow(Request $request, $id)
+	{
+		$media = Media::findOrFail($id);
+		return view('admin.media.show', compact('media'));
+	}
+}

+ 114 - 0
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use Artisan, Cache, DB;
+use Illuminate\Http\Request;
+use Carbon\Carbon;
+use App\{Comment, Like, Media, Page, Profile, Report, Status, User};
+use App\Http\Controllers\Controller;
+use Jackiedo\DotenvEditor\Facades\DotenvEditor;
+use App\Util\Lexer\PrettyNumber;
+
+trait AdminSettingsController
+{
+    public function settings(Request $request)
+    {
+      return view('admin.settings.home');
+    }
+
+    public function settingsBackups(Request $request)
+    {
+      $path = storage_path('app/PixelFed');
+      $files = new \DirectoryIterator($path);
+      return view('admin.settings.backups', compact('files'));
+    }
+
+    public function settingsConfig(Request $request, DotenvEditor $editor)
+    {
+      return view('admin.settings.config', compact('editor'));
+    }
+
+    public function settingsMaintenance(Request $request)
+    {
+      return view('admin.settings.maintenance');
+    }
+
+    public function settingsStorage(Request $request)
+    {
+      $databaseSum = Cache::remember('admin:settings:storage:db:storageUsed', 360, function() {
+        $q = 'SELECT sum(ROUND(((data_length + index_length)), 0)) AS size FROM information_schema.TABLES WHERE table_schema = ?';
+        $db = config('database.default');
+        $db = config("database.connections.{$db}.database");
+        return DB::select($q, [$db])[0]->size;
+      });
+      $mediaSum = Cache::remember('admin:settings:storage:media:storageUsed', 360, function() {
+        return Media::sum('size');
+      });
+      $backupSum = Cache::remember('admin:settings:storage:backups:storageUsed', 360, function() {
+        $dir = storage_path('app/'.config('app.name'));
+        $size = 0;
+        foreach (glob(rtrim($dir, '/').'/*', GLOB_NOSORT) as $each) {
+          $size += is_file($each) ? filesize($each) : folderSize($each);
+        }
+        return $size;
+      });
+      $storage = new \StdClass;
+      $storage->total = disk_total_space(base_path());
+      $storage->free = disk_free_space(base_path());
+      $storage->prettyTotal = PrettyNumber::size($storage->total, false, false);
+      $storage->prettyFree = PrettyNumber::size($storage->free, false, false);
+      $storage->percentFree = ceil($storage->free / $storage->total * 100);
+      $storage->percentUsed = ceil(100 - $storage->percentFree);
+      $storage->media = [
+        'used' => $mediaSum,
+        'prettyUsed' => PrettyNumber::size($mediaSum),
+        'percentUsed' => ceil($mediaSum / $storage->total * 100)
+      ];
+      $storage->backups = [
+        'used' => $backupSum
+      ];
+      $storage->database = [
+        'used' => $databaseSum
+      ];
+      return view('admin.settings.storage', compact('storage'));
+    }
+
+    public function settingsFeatures(Request $request)
+    {
+      return view('admin.settings.features');
+    }
+    
+	public function settingsHomeStore(Request $request)
+	{
+		$this->validate($request, [
+			'APP_NAME' => 'required|string',
+		]);
+		Artisan::call('config:clear');
+		DotenvEditor::setKey('APP_NAME', $request->input('APP_NAME'));
+		DotenvEditor::save();
+		return redirect()->back();
+	}
+
+	public function settingsPages(Request $request)
+	{
+    $pages = Page::orderByDesc('updated_at')->paginate(10);
+		return view('admin.pages.home', compact('pages'));
+	}
+
+	public function settingsPageEdit(Request $request)
+	{
+		return view('admin.pages.edit');
+	}
+
+  public function settingsSystem(Request $request)
+  {
+    $sys = [
+      'pixelfed' => config('pixelfed.version'),
+      'mysql' => DB::select( DB::raw("select version()") )[0]->{'version()'},
+      'php' => phpversion(),
+      'redis' => explode(' ',exec('redis-cli -v'))[1],
+    ];
+    return view('admin.settings.system', compact('sys'));
+  }
+}

+ 121 - 18
app/Http/Controllers/AdminController.php

@@ -2,21 +2,38 @@
 
 namespace App\Http\Controllers;
 
-use App\Media;
-use App\Like;
-use App\Profile;
-use App\Report;
-use App\Status;
-use App\User;
+use App\{
+  FailedJob,
+  Hashtag,
+  Instance,
+  Media,
+  Like,
+  OauthClient,
+  Profile,
+  Report,
+  Status,
+  User
+};
+use DB, Cache;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Jackiedo\DotenvEditor\DotenvEditor;
-use App\Http\Controllers\Admin\AdminReportController;
+use App\Http\Controllers\Admin\{
+  AdminDiscoverController,
+  AdminInstanceController,
+  AdminReportController,
+  AdminMediaController,
+  AdminSettingsController
+};
 use App\Util\Lexer\PrettyNumber;
 
 class AdminController extends Controller
 {
-    use AdminReportController;
+    use AdminReportController, 
+        AdminDiscoverController, 
+        AdminMediaController, 
+        AdminSettingsController, 
+        AdminInstanceController;
 
     public function __construct()
     {
@@ -26,7 +43,55 @@ class AdminController extends Controller
 
     public function home()
     {
-        return view('admin.home');
+        $data = Cache::remember('admin:dashboard:home:data', 15, function() {
+          return [
+            'failedjobs' => [
+              'count' => PrettyNumber::convert(FailedJob::where('failed_at', '>=', \Carbon\Carbon::now()->subDay())->count()),
+              'graph' => FailedJob::selectRaw('count(*) as count, day(failed_at) as d')->groupBy('d')->whereBetween('failed_at',[now()->subDays(24), now()])->orderBy('d')->pluck('count')
+            ],
+            'reports' => [
+              'count' => PrettyNumber::convert(Report::whereNull('admin_seen')->count()),
+              'graph' => Report::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'statuses' => [
+              'count' => PrettyNumber::convert(Status::whereNull('in_reply_to_id')->whereNull('reblog_of_id')->count()),
+              'graph' => Status::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'replies' => [
+              'count' => PrettyNumber::convert(Status::whereNotNull('in_reply_to_id')->count()),
+              'graph' => Status::whereNotNull('in_reply_to_id')->selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'shares' => [
+              'count' => PrettyNumber::convert(Status::whereNotNull('reblog_of_id')->count()),
+              'graph' => Status::whereNotNull('reblog_of_id')->selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'likes' => [
+              'count' => PrettyNumber::convert(Like::count()),
+              'graph' => Like::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'profiles' => [
+              'count' => PrettyNumber::convert(Profile::count()),
+              'graph' => Profile::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'users' => [
+              'count' => PrettyNumber::convert(User::count()),
+              'graph' => User::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'instances' => [
+              'count' => PrettyNumber::convert(Instance::count()),
+              'graph' => Instance::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(28), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'media' => [
+              'count' => PrettyNumber::convert(Media::count()),
+              'graph' => Media::selectRaw('count(*) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ],
+            'storage' => [
+              'count' => Media::sum('size'),
+              'graph' => Media::selectRaw('sum(size) as count, day(created_at) as day')->whereBetween('created_at',[now()->subDays(14), now()])->groupBy('day')->orderBy('day')->pluck('count')
+            ]
+          ];
+        });
+        return view('admin.home', compact('data'));
     }
 
     public function users(Request $request)
@@ -35,6 +100,7 @@ class AdminController extends Controller
         $dir = $request->query('dir') ?? 'desc';
         $stats = $this->collectUserStats($request);
         $users = User::withCount('statuses')->orderBy($col, $dir)->paginate(10);
+
         return view('admin.users.home', compact('users', 'stats'));
     }
 
@@ -59,16 +125,23 @@ class AdminController extends Controller
         return view('admin.statuses.show', compact('status'));
     }
 
-    public function media(Request $request)
-    {
-        $media = Status::whereHas('media')->orderby('id', 'desc')->paginate(12);
-
-        return view('admin.media.home', compact('media'));
-    }
-
     public function reports(Request $request)
     {
-      $reports = Report::orderBy('created_at','desc')->paginate(12);
+      $filter = $request->input('filter');
+      if(in_array($filter, ['open', 'closed'])) {
+        if($filter == 'open') {
+          $reports = Report::orderBy('created_at','desc')
+            ->whereNotNull('admin_seen')
+            ->paginate(10);
+        } else {
+          $reports = Report::orderBy('created_at','desc')
+            ->whereNull('admin_seen')
+            ->paginate(10);        
+        }
+      } else {
+        $reports = Report::orderBy('created_at','desc')
+          ->paginate(10);
+      }
       return view('admin.reports.home', compact('reports'));
     }
 
@@ -78,7 +151,6 @@ class AdminController extends Controller
       return view('admin.reports.show', compact('report'));
     }
 
-
     protected function collectUserStats($request)
     { 
       $total_duration = $request->query('total_duration') ?? '30';
@@ -106,4 +178,35 @@ class AdminController extends Controller
       return $stats;
 
     }
+
+    public function profiles(Request $request)
+    {
+      $profiles = Profile::orderBy('id','desc')->paginate(10);
+      return view('admin.profiles.home', compact('profiles'));
+    }
+
+    public function appsHome(Request $request)
+    {
+      $filter = $request->input('filter');
+      if(in_array($filter, ['revoked'])) {
+        $apps = OauthClient::with('user')
+          ->whereNotNull('user_id')
+          ->whereRevoked(true)
+          ->orderByDesc('id')
+          ->paginate(10);
+      } else {
+        $apps = OauthClient::with('user')
+          ->whereNotNull('user_id')
+          ->orderByDesc('id')
+          ->paginate(10);
+      }
+      return view('admin.apps.home', compact('apps'));
+    }
+
+    public function hashtagsHome(Request $request)
+    {
+      $hashtags = Hashtag::orderByDesc('id')->paginate(10);
+      return view('admin.hashtags.home', compact('hashtags'));
+    }
+
 }

+ 43 - 7
app/Http/Controllers/Api/BaseApiController.php

@@ -13,7 +13,8 @@ use App\{
     Avatar,
     Notification,
     Media,
-    Profile
+    Profile,
+    Status
 };
 use App\Transformer\Api\{
     AccountTransformer,
@@ -23,6 +24,7 @@ use App\Transformer\Api\{
 };
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
 use App\Jobs\AvatarPipeline\AvatarOptimize;
 use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\VideoPipeline\{
@@ -97,13 +99,46 @@ class BaseApiController extends Controller
 
     public function accountStatuses(Request $request, $id)
     {
-        $pid = Auth::user()->profile->id;
-        $profile = Profile::findOrFail($id);
-        $statuses = $profile->statuses(); 
-        if($pid === $profile->id) {
-            $statuses = $statuses->orderBy('id', 'desc')->paginate(20);
+        $this->validate($request, [
+            'only_media' => 'nullable',
+            'pinned' => 'nullable',
+            'exclude_replies' => 'nullable',
+            'max_id' => 'nullable|integer|min:1',
+            'since_id' => 'nullable|integer|min:1',
+            'min_id' => 'nullable|integer|min:1',
+            'limit' => 'nullable|integer|min:1|max:24'
+        ]);
+        $limit = $request->limit ?? 20;
+        $max_id = $request->max_id ?? false;
+        $min_id = $request->min_id ?? false;
+        $since_id = $request->since_id ?? false;
+        $only_media = $request->only_media ?? false;
+        $user = Auth::user();
+        $account = Profile::findOrFail($id);
+        $statuses = $account->statuses()->getQuery(); 
+        if($only_media == true) {
+            $statuses = $statuses
+                ->whereHas('media')
+                ->whereNull('in_reply_to_id')
+                ->whereNull('reblog_of_id');
+        }
+        if($id == $account->id && !$max_id && !$min_id && !$since_id) {
+            $statuses = $statuses->orderBy('id', 'desc')
+                ->paginate($limit);
+        } else if($since_id) {
+            $statuses = $statuses->where('id', '>', $since_id)
+                ->orderBy('id', 'DESC')
+                ->paginate($limit);
+        } else if($min_id) {
+            $statuses = $statuses->where('id', '>', $min_id)
+                ->orderBy('id', 'ASC')
+                ->paginate($limit);
+        } else if($max_id) {
+            $statuses = $statuses->where('id', '<', $max_id)
+                ->orderBy('id', 'DESC')
+                ->paginate($limit);
         } else {
-            $statuses = $statuses->whereVisibility('public')->orderBy('id', 'desc')->paginate(20);
+            $statuses = $statuses->whereVisibility('public')->orderBy('id', 'desc')->paginate($limit);
         }
         $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
         $res = $this->fractal->createData($resource)->toArray();
@@ -265,4 +300,5 @@ class BaseApiController extends Controller
 
         return response()->json($res);
     }
+
 }

+ 10 - 2
app/Http/Controllers/Auth/RegisterController.php

@@ -116,7 +116,13 @@ class RegisterController extends Controller
      */
     public function showRegistrationForm()
     {
-        $view = config('pixelfed.open_registration') == true ? 'auth.register' : 'site.closed-registration';
+        $count = User::count();
+        $limit = config('pixelfed.max_users');
+        if($limit && $limit <= $count) {
+            $view = 'site.closed-registration';
+        } else {
+            $view = config('pixelfed.open_registration') == true ? 'auth.register' : 'site.closed-registration';
+        }
         return view($view);
     }
 
@@ -128,7 +134,9 @@ class RegisterController extends Controller
      */
     public function register(Request $request)
     {
-        if(false == config('pixelfed.open_registration')) {
+        $count = User::count();
+        $limit = config('pixelfed.max_users');
+        if(false == config('pixelfed.open_registration') || $limit && $limit <= $count) {
             return abort(403);
         }
 

+ 69 - 0
app/Http/Controllers/CircleController.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use Auth;
+use App\{
+	Circle,
+	CircleProfile,
+	Profile,
+	Status,
+};
+
+class CircleController extends Controller
+{
+    public function __construct()
+    {
+    	$this->middleware('auth');
+    }
+
+    public function home(Request $request)
+    {
+    	$circles = Circle::whereProfileId(Auth::user()->profile->id)
+    		->orderByDesc('created_at')
+    		->paginate(10);
+    	return view('account.circles.home', compact('circles'));
+    }
+
+    public function create(Request $request)
+    {
+    	return view('account.circles.create');
+    }
+
+    public function store(Request $request)
+    {
+    	$this->validate($request, [
+    		'name' => 'required|string|min:1',
+    		'description' => 'nullable|string|max:255',
+    		'scope' => [
+    			'required',
+    			'string',
+    			Rule::in([
+    				'public',
+    				'private',
+    				'unlisted',
+    				'exclusive'
+    			])
+    		],
+    	]);
+
+    	$circle = Circle::firstOrCreate([
+    		'profile_id' => Auth::user()->profile->id,
+    		'name' => $request->input('name')
+    	], [
+    		'description' => $request->input('description'),
+    		'scope' => $request->input('scope'),
+    		'active' => false
+    	]);
+
+    	return redirect(route('account.circles'));
+    }
+
+    public function show(Request $request, $id)
+    {
+        $circle = Circle::findOrFail($id);
+    	return view('account.circles.show', compact('circle'));
+    }
+}

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

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

+ 24 - 0
app/Http/Controllers/DeckController.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class DeckController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function home()
+    {
+        return view('deck.index');
+    }
+
+
+    public function insights()
+    {
+        return view('deck.insights.index');
+    }
+}

+ 10 - 7
app/Http/Controllers/DirectMessageController.php

@@ -20,11 +20,12 @@ class DirectMessageController extends Controller
     public function inbox(Request $request)
     {
     	$profile = Auth::user()->profile;
-    	$inbox = DirectMessage::whereToId($profile->id)
+    	$inbox = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+            ->whereToId($profile->id)
     		->with(['author','status'])
-    		->orderBy('created_at', 'desc')
-    		->groupBy('from_id')
-    		->paginate(10);
+            ->orderBy('createdAt', 'desc')
+            ->groupBy('from_id')
+    		->paginate(12);
     	return view('account.messages', compact('inbox'));
 
     }
@@ -40,10 +41,12 @@ class DirectMessageController extends Controller
     	$msg = DirectMessage::whereToId($profile->id)
     		->findOrFail($mid);
 
-    	$thread = DirectMessage::whereToId($profile->id)
-    		->orWhere([['from_id', $profile->id],['to_id', $msg->from_id]])
+    	$thread = DirectMessage::whereIn('to_id', [$profile->id, $msg->from_id])
+    		->whereIn('from_id', [$profile->id,$msg->from_id])
     		->orderBy('created_at', 'desc')
-    		->paginate(10);
+    		->paginate(30);
+
+        $thread = $thread->reverse();
 
     	return view('account.message', compact('msg', 'profile', 'thread'));
     }

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

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

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

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

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

@@ -3,10 +3,12 @@
 namespace App\Http\Controllers;
 
 use App\{
+  DiscoverCategory,
   Follower,
   Hashtag,
   Profile,
   Status, 
+  StatusHashtag, 
   UserFilter
 };
 use Auth, DB, Cache;
@@ -28,7 +30,7 @@ class DiscoverController extends Controller
     {
         $this->validate($request, [
           'page' => 'nullable|integer|min:1|max:10',
-      ]);
+        ]);
 
         $tag = Hashtag::with('posts')
           ->withCount('posts')
@@ -51,4 +53,30 @@ class DiscoverController extends Controller
         
         return view('discover.tags.show', compact('tag', 'posts'));
     }
+
+    public function showCategory(Request $request, $slug)
+    {
+      $tag = DiscoverCategory::whereActive(true)
+        ->whereSlug($slug)
+        ->firstOrFail();
+
+      // todo refactor this mess
+      $tagids = $tag->hashtags->pluck('id')->toArray();
+      $sids = StatusHashtag::whereIn('hashtag_id', $tagids)->orderByDesc('status_id')->take(500)->pluck('status_id')->toArray();
+      $posts = Status::whereIn('id', $sids)->whereNull('uri')->whereType('photo')->whereNull('in_reply_to_id')->whereNull('reblog_of_id')->orderByDesc('created_at')->paginate(21);
+      $tag->posts_count = $tag->posts()->count();
+      return view('discover.tags.category', compact('tag', 'posts'));
+    }
+
+    public function showPersonal(Request $request)
+    {
+      $profile = Auth::user()->profile;
+      // todo refactor this mess
+      $tags = Hashtag::whereHas('posts')->orderByRaw('rand()')->take(5)->get();
+      $following = $profile->following->pluck('id');
+      $following = $following->push($profile->id)->toArray();
+      $posts = Status::withCount(['likes','comments'])->whereNotIn('profile_id', $following)->whereHas('media')->whereType('photo')->orderByDesc('created_at')->paginate(21);
+      $posts->post_count = Status::whereNotIn('profile_id', $following)->whereHas('media')->whereType('photo')->count();
+      return view('discover.personal', compact('posts', 'tags'));
+    }
 }

+ 32 - 31
app/Http/Controllers/FederationController.php

@@ -82,37 +82,38 @@ class FederationController extends Controller
     {
         $res = Cache::remember('api:nodeinfo', 60, function () {
             return [
-          'metadata' => [
-            'nodeName' => config('app.name'),
-            'software' => [
-              'homepage' => 'https://pixelfed.org',
-              'github'   => 'https://github.com/pixelfed',
-              'follow'   => 'https://mastodon.social/@pixelfed',
-            ],
-          ],
-          'openRegistrations' => config('pixelfed.open_registration'),
-          'protocols'         => [
-            'activitypub',
-          ],
-          'services' => [
-            'inbound'  => [],
-            'outbound' => [],
-          ],
-          'software' => [
-            'name'    => 'pixelfed',
-            'version' => config('pixelfed.version'),
-          ],
-          'usage' => [
-            'localPosts'    => \App\Status::whereLocal(true)->whereHas('media')->count(),
-            'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
-            'users'         => [
-              'total'          => \App\User::count(),
-              'activeHalfyear' => \App\AccountLog::select('user_id')->whereAction('auth.login')->where('updated_at', '>',Carbon::now()->subMonths(6)->toDateTimeString())->groupBy('user_id')->get()->count(),
-              'activeMonth'    => \App\AccountLog::select('user_id')->whereAction('auth.login')->where('updated_at', '>',Carbon::now()->subMonths(1)->toDateTimeString())->groupBy('user_id')->get()->count(),
-            ],
-          ],
-          'version' => '2.0',
-        ];
+                'metadata' => [
+                    'nodeName' => config('app.name'),
+                    'software' => [
+                        'homepage' => 'https://pixelfed.org',
+                        'github'   => 'https://github.com/pixelfed',
+                        'follow'   => 'https://mastodon.social/@pixelfed',
+                    ],
+                    'captcha' => (bool) config('pixelfed.recaptcha'),
+                ],
+                'openRegistrations' => config('pixelfed.open_registration'),
+                'protocols'         => [
+                    'activitypub',
+                ],
+                'services' => [
+                    'inbound'  => [],
+                    'outbound' => [],
+                ],
+                'software' => [
+                    'name'    => 'pixelfed',
+                    'version' => config('pixelfed.version'),
+                ],
+                'usage' => [
+                    'localPosts'    => \App\Status::whereLocal(true)->whereHas('media')->count(),
+                    'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
+                    'users'         => [
+                        'total'          => \App\User::count(),
+                        'activeHalfyear' => \App\AccountLog::select('user_id')->whereAction('auth.login')->where('updated_at', '>',Carbon::now()->subMonths(6)->toDateTimeString())->groupBy('user_id')->get()->count(),
+                        'activeMonth'    => \App\AccountLog::select('user_id')->whereAction('auth.login')->where('updated_at', '>',Carbon::now()->subMonths(1)->toDateTimeString())->groupBy('user_id')->get()->count(),
+                    ],
+                ],
+                'version' => '2.0',
+            ];
         });
 
         return response()->json($res, 200, [

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

@@ -39,6 +39,7 @@ class FollowerController extends Controller
         $user = Auth::user()->profile;
         $target = Profile::where('id', '!=', $user->id)->whereNull('status')->findOrFail($item);
         $private = (bool) $target->is_private;
+        $remote = (bool) $target->domain;
         $blocked = UserFilter::whereUserId($target->id)
                 ->whereFilterType('block')
                 ->whereFilterableId($user->id)
@@ -51,7 +52,7 @@ class FollowerController extends Controller
 
         $isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
 
-        if($private == true && $isFollowing == 0) {
+        if($private == true && $isFollowing == 0 || $remote == true) {
             $follow = FollowRequest::firstOrCreate([
                 'follower_id' => $user->id,
                 'following_id' => $target->id

+ 1 - 1
app/Http/Controllers/Import/Instagram.php

@@ -124,7 +124,7 @@ trait Instagram
     		->firstOrFail();
     	$media = $request->file('media');
     	$file = file_get_contents($media);
-		$json = json_decode($file, true);
+		$json = json_decode($file, true, 5);
 		if(!$json || !isset($json['photos'])) {
 			return abort(500);
 		}

+ 105 - 6
app/Http/Controllers/InternalApiController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use Illuminate\Http\Request;
 use App\{
     DirectMessage,
+    DiscoverCategory,
     Hashtag,
     Follower,
     Like,
@@ -25,6 +26,7 @@ use App\Transformer\Api\{
 use App\Jobs\StatusPipeline\NewStatusPipeline;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use Illuminate\Validation\Rule;
 
 class InternalApiController extends Controller
 {
@@ -199,14 +201,21 @@ class InternalApiController extends Controller
     {
         $profile = Auth::user()->profile;
         $pid = $profile->id;
-        $following = Cache::remember('feature:discover:following:'.$pid, 60, function() use ($pid) {
+        $following = Cache::remember('feature:discover:following:'.$pid, 15, function() use ($pid) {
             return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
         });
-        $filters = Cache::remember("user:filter:list:$pid", 60, function() use($pid) {
-            return UserFilter::whereUserId($pid)
-            ->whereFilterableType('App\Profile')
-            ->whereIn('filter_type', ['mute', 'block'])
-            ->pluck('filterable_id')->toArray();
+        $filters = Cache::remember("user:filter:list:$pid", 15, function() use($pid) {
+            $private = Profile::whereIsPrivate(true)
+                ->orWhere('unlisted', true)
+                ->orWhere('status', '!=', null)
+                ->pluck('id')
+                ->toArray();
+            $filters = UserFilter::whereUserId($pid)
+                ->whereFilterableType('App\Profile')
+                ->whereIn('filter_type', ['mute', 'block'])
+                ->pluck('filterable_id')
+                ->toArray();
+            return array_merge($private, $filters);
         });
         $following = array_merge($following, $filters);
 
@@ -281,4 +290,94 @@ class InternalApiController extends Controller
 
         return response()->json($res);
     }
+
+    public function stories(Request $request)
+    {
+        
+    }
+
+    public function discoverCategories(Request $request)
+    {
+        $categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
+        $res = $categories->map(function($item) {
+            return [
+                'name' => $item->name,
+                'url' => $item->url(),
+                'thumb' => $item->thumb()
+            ];
+        });
+        return response()->json($res);
+    }
+
+    public function modAction(Request $request)
+    {
+        abort_unless(Auth::user()->is_admin, 403);
+        $this->validate($request, [
+            'action' => [
+                'required',
+                'string',
+                Rule::in([
+                    'autocw',
+                    'noautolink',
+                    'unlisted',
+                    'disable',
+                    'suspend'
+                ])
+            ],
+            'item_id' => 'required|integer|min:1',
+            'item_type' => [
+                'required',
+                'string',
+                Rule::in(['status'])
+            ]
+        ]);
+
+        $action = $request->input('action');
+        $item_id = $request->input('item_id');
+        $item_type = $request->input('item_type');
+
+        switch($action) {
+            case 'autocw':
+                $profile = $item_type == 'status' ? Status::findOrFail($item_id)->profile : null;
+                $profile->cw = true;
+                $profile->save();
+            break;
+
+            case 'noautolink':
+                $profile = $item_type == 'status' ? Status::findOrFail($item_id)->profile : null;
+                $profile->no_autolink = true;
+                $profile->save();
+            break;
+
+            case 'unlisted':
+                $profile = $item_type == 'status' ? Status::findOrFail($item_id)->profile : null;
+                $profile->unlisted = true;
+                $profile->save();
+            break;
+
+            case 'disable':
+                $profile = $item_type == 'status' ? Status::findOrFail($item_id)->profile : null;
+                $user = $profile->user;
+                $profile->status = 'disabled';
+                $user->status = 'disabled';
+                $profile->save();
+                $user->save();
+            break;
+
+
+            case 'suspend':
+                $profile = $item_type == 'status' ? Status::findOrFail($item_id)->profile : null;
+                $user = $profile->user;
+                $profile->status = 'suspended';
+                $user->status = 'suspended';
+                $profile->save();
+                $user->save();
+            break;
+            
+            default:
+                # code...
+                break;
+        }
+        return ['msg' => 200];
+    }
 }

+ 67 - 0
app/Http/Controllers/MicroController.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\{
+	Profile, 
+	Status, 
+};
+use Auth, DB, Purify;
+use Illuminate\Validation\Rule;
+
+class MicroController extends Controller
+{
+	public function __construct()
+	{
+		$this->middleware('auth');
+	}
+
+	public function composeText(Request $request)
+	{
+		$this->validate($request, [
+			'type' => [
+				'required',
+				'string',
+				Rule::in(['text'])
+			],
+			'title' => 'nullable|string|max:140',
+			'content' => 'required|string|max:500',
+			'visibility' => [
+				'required',
+				'string',
+				Rule::in([
+					'public',
+					'unlisted',
+					'private',
+					'draft'
+				])
+			]
+		]);
+		$profile = Auth::user()->profile;
+		$title = $request->input('title');
+		$content = $request->input('content');
+		$visibility = $request->input('visibility');
+
+		$status = DB::transaction(function() use($profile, $content, $visibility, $title) {
+			$status = new Status;
+			$status->type = 'text';
+			$status->profile_id = $profile->id;
+			$status->caption = strip_tags($content);
+			$status->rendered = Purify::clean($content);
+			$status->is_nsfw = false;
+
+			// TODO: remove deprecated visibility in favor of scope
+			$status->visibility = $visibility;
+			$status->scope = $visibility;
+			$status->entities = json_encode(['title'=>$title]);
+			$status->save();
+			return $status;
+		});
+
+		$fractal = new \League\Fractal\Manager();
+		$fractal->setSerializer(new \League\Fractal\Serializer\ArraySerializer());
+		$s = new \League\Fractal\Resource\Item($status, new \App\Transformer\Api\StatusTransformer());
+		return $fractal->createData($s)->toArray();
+	}
+}

+ 53 - 0
app/Http/Controllers/PageController.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Auth;
+use App\Page;
+
+class PageController extends Controller
+{
+	public function __construct()
+	{
+		$this->middleware(['auth', 'admin']);
+	}
+
+	protected function authCheck($admin_only = false)
+	{
+		$auth = $admin_only ?
+			Auth::check() && Auth::user()->is_admin == true :
+			Auth::check();
+		if($auth == false) {
+			abort(403);
+		}
+	}
+
+	public function edit(Request $request)
+	{
+		$this->authCheck(true);
+		$this->validate($request, [
+			'page'	=> 'required|string'
+		]);
+		$slug = urldecode($request->page);
+		$page = Page::firstOrCreate(['slug' => $slug]);
+		return view('admin.pages.edit', compact('page'));
+	}
+
+	public function store(Request $request)
+	{
+		$this->validate($request, [
+			'slug' => 'required|string',
+			'content' => 'required|string',
+			'title' => 'nullable|string',
+			'active'  => 'required|boolean'
+		]);
+		$slug = urldecode($request->input('slug'));
+		$page = Page::firstOrCreate(['slug' => $slug]);
+		$page->content = $request->input('content');
+		$page->title = $request->input('title');
+		$page->active = (bool) $request->input('active');
+		$page->save();
+		return response()->json(['msg' => 200]);
+	}
+}

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

@@ -187,7 +187,7 @@ class ProfileController extends Controller
                 return view('profile.private', compact('user', 'is_following'));
             }
         }
-        $followers = $profile->followers()->whereNull('status')->orderBy('created_at', 'desc')->simplePaginate(12);
+        $followers = $profile->followers()->whereNull('status')->orderBy('followers.created_at', 'desc')->simplePaginate(12);
         $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
         if ($user->remote_url) {
             $settings = new \StdClass;
@@ -217,7 +217,7 @@ class ProfileController extends Controller
                 return view('profile.private', compact('user', 'is_following'));
             }
         }
-        $following = $profile->following()->whereNull('status')->orderBy('created_at', 'desc')->simplePaginate(12);
+        $following = $profile->following()->whereNull('status')->orderBy('followers.created_at', 'desc')->simplePaginate(12);
         $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
         if ($user->remote_url) {
             $settings = new \StdClass;

+ 102 - 2
app/Http/Controllers/PublicApiController.php

@@ -19,6 +19,7 @@ use Carbon\Carbon;
 use League\Fractal;
 use App\Transformer\Api\{
     AccountTransformer,
+    RelationshipTransformer,
     StatusTransformer,
 };
 use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -32,7 +33,6 @@ class PublicApiController extends Controller
 
     public function __construct()
     {
-        $this->middleware('throttle:3000, 30');
         $this->fractal = new Fractal\Manager();
         $this->fractal->setSerializer(new ArraySerializer());
     }
@@ -222,7 +222,11 @@ class PublicApiController extends Controller
         // $timeline = Timeline::build()->local();
         $pid = Auth::user()->profile->id;
 
-        $private = Profile::whereIsPrivate(true)->orWhereNotNull('status')->where('id', '!=', $pid)->pluck('id');
+        $private = Profile::whereIsPrivate(true)
+            ->orWhere('unlisted', true)
+            ->orWhere('status', '!=', null)
+            ->where('id', '!=', $pid)
+            ->pluck('id');
         $filters = UserFilter::whereUserId($pid)
                   ->whereFilterableType('App\Profile')
                   ->whereIn('filter_type', ['mute', 'block'])
@@ -330,4 +334,100 @@ class PublicApiController extends Controller
         return response()->json($res);
 
     }
+
+    public function relationships(Request $request)
+    {
+        abort_if(!Auth::check(), 403);
+
+        $this->validate($request, [
+            'id'    => 'required|array|min:1|max:20',
+            'id.*'  => 'required|integer'
+        ]);
+        $ids = collect($request->input('id'));
+        $filtered = $ids->filter(function($v) { 
+            return $v != Auth::user()->profile->id;
+        });
+        $relations = Profile::findOrFail($filtered->all());
+        $fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer());
+        $res = $this->fractal->createData($fractal)->toArray();
+        return response()->json($res);
+    }
+
+    public function account(Request $request, $id)
+    {
+        $profile = Profile::whereNull('status')->findOrFail($id);
+        $resource = new Fractal\Resource\Item($profile, new AccountTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function accountFollowers(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $followers = $profile->followers;
+        $resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function accountFollowing(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $following = $profile->following;
+        $resource = new Fractal\Resource\Collection($following, new AccountTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function accountStatuses(Request $request, $id)
+    {
+        $this->validate($request, [
+            'only_media' => 'nullable',
+            'pinned' => 'nullable',
+            'exclude_replies' => 'nullable',
+            'max_id' => 'nullable|integer|min:1',
+            'since_id' => 'nullable|integer|min:1',
+            'min_id' => 'nullable|integer|min:1',
+            'limit' => 'nullable|integer|min:1|max:24'
+        ]);
+        $limit = $request->limit ?? 20;
+        $max_id = $request->max_id ?? false;
+        $min_id = $request->min_id ?? false;
+        $since_id = $request->since_id ?? false;
+        $only_media = $request->only_media ?? false;
+        $user = Auth::user();
+        $account = Profile::findOrFail($id);
+        $statuses = $account->statuses()->getQuery(); 
+        if($only_media == true) {
+            $statuses = $statuses
+                ->whereHas('media')
+                ->whereNull('in_reply_to_id')
+                ->whereNull('reblog_of_id');
+        }
+        if($id == $account->id && !$max_id && !$min_id && !$since_id) {
+            $statuses = $statuses->orderBy('id', 'desc')
+                ->paginate($limit);
+        } else if($since_id) {
+            $statuses = $statuses->where('id', '>', $since_id)
+                ->orderBy('id', 'DESC')
+                ->paginate($limit);
+        } else if($min_id) {
+            $statuses = $statuses->where('id', '>', $min_id)
+                ->orderBy('id', 'ASC')
+                ->paginate($limit);
+        } else if($max_id) {
+            $statuses = $statuses->where('id', '<', $max_id)
+                ->orderBy('id', 'DESC')
+                ->paginate($limit);
+        } else {
+            $statuses = $statuses->whereVisibility('public')->orderBy('id', 'desc')->paginate($limit);
+        }
+        $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
 }

+ 32 - 0
app/Http/Controllers/SearchController.php

@@ -7,6 +7,7 @@ use App\Hashtag;
 use App\Profile;
 use App\Status;
 use Illuminate\Http\Request;
+use App\Util\ActivityPub\Helpers;
 use Illuminate\Support\Facades\Cache;
 
 class SearchController extends Controller
@@ -24,6 +25,35 @@ class SearchController extends Controller
         $hash = hash('sha256', $tag);
         $tokens = Cache::remember('api:search:tag:'.$hash, 5, function () use ($tag) {
             $tokens = collect([]);
+            if(Helpers::validateUrl($tag)) {
+                $remote = Helpers::fetchFromUrl($tag);
+                if(isset($remote['type']) && in_array($remote['type'], ['Create', 'Person']) == true) {
+                    $type = $remote['type'];
+                    if($type == 'Person') {
+                        $item = Helpers::profileFirstOrNew($tag);
+                        $tokens->push([[
+                            'count'  => 1,
+                            'url'    => $item->url(),
+                            'type'   => 'profile',
+                            'value'  => $item->username,
+                            'tokens' => [$item->username],
+                            'name'   => $item->name,
+                        ]]);
+                    } else if ($type == 'Create') {
+                        $item = Helpers::statusFirstOrFetch($tag, false);
+                        $tokens->push([[
+                            'count'  => 0,
+                            'url'    => $item->url(),
+                            'type'   => 'status',
+                            '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(),
+                        ]]);
+                    }
+                }
+
+            }
             $hashtags = Hashtag::select('id', 'name', 'slug')->where('slug', 'like', '%'.$tag.'%')->limit(20)->get();
             if($hashtags->count() > 0) {
                 $tags = $hashtags->map(function ($item, $key) {
@@ -41,6 +71,7 @@ class SearchController extends Controller
             $users = Profile::select('username', 'name', 'id')
                 ->whereNull('status')
                 ->where('username', 'like', '%'.$tag.'%')
+                ->orWhere('remote_url', $tag)
                 ->limit(20)
                 ->get();
 
@@ -66,6 +97,7 @@ class SearchController extends Controller
                     ->whereNull('reblog_of_id')
                     ->whereProfileId(Auth::user()->profile->id)
                     ->where('caption', 'like', '%'.$tag.'%')
+                    ->orWhere('uri', $tag)
                     ->orderBy('created_at', 'desc')
                     ->get();
 

+ 52 - 2
app/Http/Controllers/Settings/PrivacySettings.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Settings;
 
 use App\AccountLog;
 use App\EmailVerification;
+use App\Instance;
 use App\Media;
 use App\Profile;
 use App\User;
@@ -121,7 +122,56 @@ trait PrivacySettings
 
     public function blockedInstances()
     {
-        $settings = Auth::user()->settings;
-        return view('settings.privacy.blocked-instances');
+        $pid = Auth::user()->profile->id;
+        $filters = UserFilter::whereUserId($pid)
+            ->whereFilterableType('App\Instance')
+            ->whereFilterType('block')
+            ->orderByDesc('id')
+            ->paginate(10);
+        return view('settings.privacy.blocked-instances', compact('filters'));
+    }
+
+    public function blockedInstanceStore(Request $request)
+    {
+        $this->validate($request, [
+            'domain'    => [
+                'required',
+                'min:3',
+                'max:100',
+                function($attribute, $value, $fail) {
+                    if(!filter_var($value, FILTER_VALIDATE_DOMAIN)) {
+                        $fail($attribute. 'is invalid');
+                    }
+                }
+            ]
+        ]);
+        $domain = $request->input('domain');
+        $instance = Instance::firstOrCreate(['domain' => $domain]);
+        $filter = new UserFilter;
+        $filter->user_id = Auth::user()->profile->id;
+        $filter->filterable_id = $instance->id;
+        $filter->filterable_type = 'App\Instance';
+        $filter->filter_type = 'block';
+        $filter->save();
+        return response()->json(['msg' => 200]);
+    }
+
+    public function blockedInstanceUnblock(Request $request)
+    {
+        $this->validate($request, [
+            'id'    => 'required|integer|min:1'
+        ]);
+        $pid = Auth::user()->profile->id;
+
+        $filter = UserFilter::whereFilterableType('App\Instance')
+            ->whereUserId($pid)
+            ->findOrFail($request->input('id'));
+        $filter->delete();
+        return redirect(route('settings.privacy.blocked-instances'));
+    }
+
+    public function blockedKeywords()
+    {
+        return view('settings.privacy.blocked-keywords');
     }
 }

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

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\AccountLog;
 use App\Following;
+use App\Report;
 use App\UserFilter;
 use Auth, DB, Cache, Purify;
 use Carbon\Carbon;
@@ -160,6 +161,7 @@ class SettingsController extends Controller
         if(config('pixelfed.account_deletion') == false) {
             abort(404);
         }
+        
         $user = Auth::user();
         if($user->is_admin == true) {
             return abort(400, 'You cannot delete an admin account.');
@@ -175,5 +177,18 @@ class SettingsController extends Controller
         Auth::logout();
         return redirect('/');
     }
+
+    public function requestFullExport(Request $request)
+    {
+        $user = Auth::user();
+        return view('settings.export.show');
+    }
+
+    public function reportsHome(Request $request)
+    {
+        $profile = Auth::user()->profile;
+        $reports = Report::whereProfileId($profile->id)->orderByDesc('created_at')->paginate(10);
+        return view('settings.reports', compact('reports'));
+    }
 }
 

+ 34 - 16
app/Http/Controllers/SiteController.php

@@ -2,16 +2,10 @@
 
 namespace App\Http\Controllers;
 
-use App;
-use App\Follower;
-use App\Profile;
-use App\Status;
-use App\User;
-use App\UserFilter;
-use App\Util\Lexer\PrettyNumber;
-use Auth;
-use Cache;
 use Illuminate\Http\Request;
+use App, Auth, Cache, View;
+use App\Util\Lexer\PrettyNumber;
+use App\{Follower, Page, Profile, Status, User, UserFilter};
 
 class SiteController extends Controller
 {
@@ -47,18 +41,42 @@ class SiteController extends Controller
 
     public function about()
     {
-        $stats = Cache::remember('site:about:stats', 1440, function() {
-            return [
-                'posts' => Status::whereLocal(true)->count(),
-                'users' => User::count(),
-                'admin' => User::whereIsAdmin(true)->first()
-            ];
+        $res = Cache::remember('site:about', 120, function() {
+            $custom = Page::whereSlug('/site/about')->whereActive(true)->exists();
+            if($custom) {
+              $stats = Cache::remember('site:about:stats', 60, function() {
+                    return [
+                        'posts' => Status::whereLocal(true)->count(),
+                        'users' => User::count(),
+                        'admin' => User::whereIsAdmin(true)->first()
+                    ];
+                });
+                return View::make('site.about')->with('stats', $stats)->render();
+            } else {
+                $stats = Cache::remember('site:about:stats', 60, function() {
+                    return [
+                        'posts' => Status::whereLocal(true)->count(),
+                        'users' => User::count(),
+                        'admin' => User::whereIsAdmin(true)->first()
+                    ];
+                });
+                //return view('site.about', compact('stats'));
+                return View::make('site.about')->with('stats', $stats)->render();
+            }
         });
-        return view('site.about', compact('stats'));
+        return $res;
     }
 
     public function language()
     {
       return view('site.language');
     }
+
+    public function communityGuidelines(Request $request)
+    {
+        $slug = '/site/kb/community-guidelines';
+        $page = Page::whereSlug($slug)->whereActive(true)->first();
+        return view('site.help.community-guidelines', compact('page'));
+    }
+
 }

+ 3 - 0
app/Http/Controllers/StatusController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
 use App\Jobs\StatusPipeline\StatusDelete;
+use App\Jobs\SharePipeline\SharePipeline;
 use App\Media;
 use App\Profile;
 use App\Status;
@@ -234,8 +235,10 @@ class StatusController extends Controller
             $share = new Status();
             $share->profile_id = $profile->id;
             $share->reblog_of_id = $status->id;
+            $share->in_reply_to_profile_id = $status->profile_id;
             $share->save();
             $count++;
+            SharePipeline::dispatch($share);
         }
 
         if ($request->ajax()) {

+ 12 - 0
app/Http/Controllers/StoryController.php

@@ -2,6 +2,18 @@
 
 namespace App\Http\Controllers;
 
+use Illuminate\Http\Request;
+
 class StoryController extends Controller
 {
+
+	public function construct()
+	{
+		$this->middleware('auth');
+	}
+
+	public function home(Request $request)
+	{
+		return view('stories.home');
+	}
 }

+ 59 - 1
app/Instance.php

@@ -6,5 +6,63 @@ use Illuminate\Database\Eloquent\Model;
 
 class Instance extends Model
 {
-    protected $fillable = ['domain'];
+	protected $fillable = ['domain'];
+
+	public function profiles()
+	{
+		return $this->hasMany(Profile::class, 'domain', 'domain');
+	}
+
+	public function statuses()
+	{
+		return $this->hasManyThrough(
+			Status::class,
+			Profile::class,
+			'domain',
+			'profile_id',
+			'domain',
+			'id'
+		);
+	}
+
+	public function reported()
+	{
+		return $this->hasManyThrough(
+			Report::class,
+			Profile::class,
+			'domain',
+			'reported_profile_id',
+			'domain',
+			'id'
+		);
+	}
+
+	public function reports()
+	{
+		return $this->hasManyThrough(
+			Report::class,
+			Profile::class,
+			'domain',
+			'profile_id',
+			'domain',
+			'id'
+		);
+	}
+
+	public function media()
+	{
+		return $this->hasManyThrough(
+			Media::class,
+			Profile::class,
+			'domain',
+			'profile_id',
+			'domain',
+			'id'
+		);
+	}
+
+	public function getUrl()
+	{
+		return url("/i/admin/instances/show/{$this->id}");
+	}
 }

+ 7 - 0
app/Jobs/AvatarPipeline/AvatarOptimize.php

@@ -19,6 +19,13 @@ class AvatarOptimize implements ShouldQueue
     protected $profile;
     protected $current;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/AvatarPipeline/CreateAvatar.php

@@ -20,6 +20,13 @@ class CreateAvatar implements ShouldQueue
 
     protected $profile;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 95 - 0
app/Jobs/AvatarPipeline/ImportAvatar.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Avatar;
+use App\Profile;
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class ImportAvatar implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $url;
+    protected $profile;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($url, Profile $profile)
+    {
+        $this->url = $url;
+        $this->profile = $profile;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $url = $this->url;
+        $profile = $this->profile;
+
+        $basePath = $this->buildPath();
+    }
+
+    public function buildPath()
+    {
+        $baseDir = storage_path('app/public/avatars');
+        if (!is_dir($baseDir)) {
+            mkdir($baseDir);
+        }
+
+        $prefix = $this->profile->id;
+        $padded = str_pad($prefix, 12, 0, STR_PAD_LEFT);
+        $parts = str_split($padded, 3);
+        foreach ($parts as $k => $part) {
+            if ($k == 0) {
+                $prefix = storage_path('app/public/avatars/'.$parts[0]);
+                if (!is_dir($prefix)) {
+                    mkdir($prefix);
+                }
+            }
+            if ($k == 1) {
+                $prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1]);
+                if (!is_dir($prefix)) {
+                    mkdir($prefix);
+                }
+            }
+            if ($k == 2) {
+                $prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2]);
+                if (!is_dir($prefix)) {
+                    mkdir($prefix);
+                }
+            }
+            if ($k == 3) {
+                $avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3];
+                $prefix = storage_path('app/'.$avatarpath);
+                if (!is_dir($prefix)) {
+                    mkdir($prefix);
+                }
+            }
+        }
+        $dir = storage_path('app/'.$avatarpath);
+        if (!is_dir($dir)) {
+            mkdir($dir);
+        }
+        $path = $avatarpath.'/avatar.svg';
+        return storage_path('app/'.$path);
+    }
+}

+ 7 - 0
app/Jobs/CommentPipeline/CommentPipeline.php

@@ -20,6 +20,13 @@ class CommentPipeline implements ShouldQueue
     protected $status;
     protected $comment;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/FollowPipeline/FollowActivityPubDeliver.php

@@ -21,6 +21,13 @@ class FollowActivityPubDeliver implements ShouldQueue
 
     protected $followRequest;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/FollowPipeline/FollowPipeline.php

@@ -18,6 +18,13 @@ class FollowPipeline implements ShouldQueue
 
     protected $follower;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/ImageOptimizePipeline/ImageOptimize.php

@@ -15,6 +15,13 @@ class ImageOptimize implements ShouldQueue
 
     protected $media;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/ImageOptimizePipeline/ImageResize.php

@@ -16,6 +16,13 @@ class ImageResize implements ShouldQueue
 
     protected $media;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/ImageOptimizePipeline/ImageThumbnail.php

@@ -17,6 +17,13 @@ class ImageThumbnail implements ShouldQueue
 
     protected $media;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 28 - 10
app/Jobs/ImageOptimizePipeline/ImageUpdate.php

@@ -2,6 +2,7 @@
 
 namespace App\Jobs\ImageOptimizePipeline;
 
+use Storage;
 use App\Media;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -9,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
 use ImageOptimizer;
+use Illuminate\Http\File;
 
 class ImageUpdate implements ShouldQueue
 {
@@ -17,11 +19,17 @@ class ImageUpdate implements ShouldQueue
     protected $media;
 
     protected $protectedMimes = [
-        'image/gif',
-        'image/bmp',
-        'video/mp4',
+        'image/jpeg',
+        'image/png',
     ];
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *
@@ -43,21 +51,31 @@ class ImageUpdate implements ShouldQueue
         $path = storage_path('app/'.$media->media_path);
         $thumb = storage_path('app/'.$media->thumbnail_path);
 
-        try {
-            if (!in_array($media->mime, $this->protectedMimes)) {
-                ImageOptimizer::optimize($thumb);
-                ImageOptimizer::optimize($path);
-            }
-        } catch (Exception $e) {
-            return;
+        if (in_array($media->mime, $this->protectedMimes) == true) {
+            ImageOptimizer::optimize($thumb);
+            ImageOptimizer::optimize($path);
         }
+
         if (!is_file($path) || !is_file($thumb)) {
             return;
         }
+
         $photo_size = filesize($path);
         $thumb_size = filesize($thumb);
         $total = ($photo_size + $thumb_size);
         $media->size = $total;
         $media->save();
+
+        if(config('pixelfed.cloud_storage') == true) {
+            $p = explode('/', $media->media_path);
+            $monthHash = $p[2];
+            $userHash = $p[3];
+            $storagePath = "public/m/{$monthHash}/{$userHash}";
+            $file = Storage::disk(config('filesystems.cloud'))->putFile($storagePath, new File($path), 'public');
+            $url = Storage::disk(config('filesystems.cloud'))->url($file);
+            $media->cdn_url = $url;
+            $media->optimized_url = $url;
+            $media->save();
+        }
     }
 }

+ 8 - 1
app/Jobs/ImportPipeline/ImportInstagram.php

@@ -25,7 +25,14 @@ class ImportInstagram implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
     
     protected $job;
-
+    
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

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

@@ -19,6 +19,13 @@ class LikePipeline implements ShouldQueue
 
     protected $like;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
     /**
      * Create a new job instance.
      *

+ 7 - 0
app/Jobs/MentionPipeline/MentionPipeline.php

@@ -18,6 +18,13 @@ class MentionPipeline implements ShouldQueue
     protected $status;
     protected $mention;
 
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
     /**
      * Create a new job instance.
      *

+ 21 - 14
app/Jobs/SharePipeline/SharePipeline.php

@@ -17,7 +17,14 @@ class SharePipeline implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
-    protected $like;
+    protected $status;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
 
     /**
      * Create a new job instance.
@@ -37,32 +44,32 @@ class SharePipeline implements ShouldQueue
     public function handle()
     {
         $status = $this->status;
-        $actor = $this->status->profile;
-        $target = $this->status->parent()->profile;
+        $actor = $status->profile;
+        $target = $status->parent()->profile;
 
-        if ($status->url !== null) {
+        if ($status->uri !== null) {
             // Ignore notifications to remote statuses
             return;
         }
 
-        $exists = Notification::whereProfileId($status->profile_id)
-                  ->whereActorId($actor->id)
-                  ->whereAction('like')
-                  ->whereItemId($status->id)
+        $exists = Notification::whereProfileId($target->id)
+                  ->whereActorId($status->profile_id)
+                  ->whereAction('share')
+                  ->whereItemId($status->reblog_of_id)
                   ->whereItemType('App\Status')
                   ->count();
 
-        if ($actor->id === $status->profile_id || $exists !== 0) {
+        if ($target->id === $status->profile_id || $exists !== 0) {
             return true;
         }
 
         try {
-            $notification = new Notification();
-            $notification->profile_id = $status->profile_id;
+            $notification = new Notification;
+            $notification->profile_id = $target->id;
             $notification->actor_id = $actor->id;
-            $notification->action = 'like';
-            $notification->message = $like->toText();
-            $notification->rendered = $like->toHtml();
+            $notification->action = 'share';
+            $notification->message = $status->shareToText();
+            $notification->rendered = $status->shareToHtml();
             $notification->item_id = $status->id;
             $notification->item_type = "App\Status";
             $notification->save();

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

@@ -16,7 +16,14 @@ class NewStatusPipeline implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
-
+    
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 8 - 1
app/Jobs/StatusPipeline/StatusActivityPubDeliver.php

@@ -18,7 +18,14 @@ class StatusActivityPubDeliver implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
-
+    
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 8 - 1
app/Jobs/StatusPipeline/StatusDelete.php

@@ -19,7 +19,14 @@ class StatusDelete implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
-
+    
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

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

@@ -24,7 +24,14 @@ class StatusEntityLexer implements ShouldQueue
     protected $status;
     protected $entities;
     protected $autolink;
-
+    
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
     /**
      * Create a new job instance.
      *

+ 1 - 1
app/Jobs/VideoPipeline/VideoThumbnail.php

@@ -49,7 +49,7 @@ class VideoThumbnail implements ShouldQueue
             } elseif($video->getDurationInSeconds() < 5) {
                 $video->getFrameFromSeconds(4);
             }
-                $video->export()
+            $video->export()
                 ->save($save);
 
             $media->thumbnail_path = $save;

+ 16 - 1
app/Media.php

@@ -17,13 +17,23 @@ class Media extends Model
      */
     protected $dates = ['deleted_at'];
 
+    public function status()
+    {
+        return $this->belongsTo(Status::class);
+    }
+
+    public function profile()
+    {
+        return $this->belongsTo(Profile::class);
+    }
+
     public function url()
     {
         if(!empty($this->remote_media) && $this->remote_url) {
             $url = $this->remote_url;
         } else {
             $path = $this->media_path;
-            $url = Storage::url($path);
+            $url = $this->cdn_url ?? Storage::url($path);
         }
 
         return url($url);
@@ -37,6 +47,11 @@ class Media extends Model
         return url($url);
     }
 
+    public function thumb()
+    {
+        return $this->thumbnailUrl();
+    }
+
     public function mimeType()
     {
         return explode('/', $this->mime)[0];

+ 18 - 0
app/OauthClient.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class OauthClient extends Model
+{
+
+	protected $table = 'oauth_clients';
+
+	public function user()
+	{
+		return $this->belongsTo(User::class);
+	}
+
+}

+ 25 - 0
app/Page.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Page extends Model
+{
+    const SLUG_ROOT = [
+    	'site',
+    	'page'
+    ];
+
+    protected $fillable = ['slug'];
+
+    public function url()
+    {
+    	return url($this->slug);
+    }
+
+    public function editUrl()
+    {
+    	return url("/i/admin/settings/pages/edit?page=".urlencode($this->slug));
+    }
+}

+ 6 - 1
app/Profile.php

@@ -12,7 +12,7 @@ class Profile extends Model
 
     protected $dates = ['deleted_at'];
     protected $hidden = ['private_key'];
-    protected $visible = ['username', 'name'];
+    protected $visible = ['id', 'user_id', 'username', 'name'];
 
     public function user()
     {
@@ -274,4 +274,9 @@ class Profile extends Model
             ->unique()
             ->toArray();
     }
+
+    public function circles()
+    {
+        return $this->hasMany(Circle::class);
+    }
 }

+ 3 - 5
app/Providers/AuthServiceProvider.php

@@ -25,10 +25,8 @@ class AuthServiceProvider extends ServiceProvider
     {
         $this->registerPolicies();
 
-        // Passport::routes();
-
-        // Passport::tokensExpireIn(now()->addDays(15));
-
-        // Passport::refreshTokensExpireIn(now()->addDays(30));
+        Passport::routes();
+        Passport::tokensExpireIn(now()->addDays(15));
+        Passport::refreshTokensExpireIn(now()->addDays(30));
     }
 }

+ 4 - 1
app/ReportComment.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class ReportComment extends Model
 {
-    //
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
 }

+ 4 - 1
app/ReportLog.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class ReportLog extends Model
 {
-    //
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
 }

+ 21 - 1
app/Status.php

@@ -118,7 +118,11 @@ class Status extends Model
         $media = $this->firstMedia();
         $path = $media->media_path;
         $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
-        $url = Storage::url($path)."?v={$hash}";
+        if(config('pixelfed.cloud_storage') == true) {
+            $url = Storage::disk(config('filesystems.cloud'))->url($path)."?v={$hash}";
+        } else {
+            $url = Storage::url($path)."?v={$hash}";
+        }
 
         return url($url);
     }
@@ -270,6 +274,22 @@ class Status extends Model
           __('notification.commented');
     }
 
+    public function shareToText()
+    {
+        $actorName = $this->profile->username;
+
+        return "{$actorName} ".__('notification.shared');
+    }
+
+    public function shareToHtml()
+    {
+        $actorName = $this->profile->username;
+        $actorUrl = $this->profile->url();
+
+        return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
+          __('notification.shared');
+    }
+
     public function recentComments()
     {
         return $this->comments()->orderBy('created_at', 'desc')->take(3);

+ 10 - 0
app/StatusHashtag.php

@@ -7,4 +7,14 @@ use Illuminate\Database\Eloquent\Model;
 class StatusHashtag extends Model
 {
     public $fillable = ['status_id', 'hashtag_id'];
+
+	public function status()
+	{
+		return $this->belongsTo(Status::class);
+	}
+
+	public function hashtag()
+	{
+		return $this->belongsTo(Hashtag::class);
+	}
 }

+ 28 - 1
app/Story.php

@@ -2,9 +2,36 @@
 
 namespace App;
 
+use Auth;
 use Illuminate\Database\Eloquent\Model;
 
 class Story extends Model
 {
-    //
+	protected $visible = ['id'];
+
+	public function profile()
+	{
+		return $this->belongsTo(Profile::class);
+	}
+
+	public function items()
+	{
+		return $this->hasMany(StoryItem::class);
+	}
+
+	public function reactions()
+	{
+		return $this->hasMany(StoryReaction::class);
+	}
+
+	public function views()
+	{
+		return $this->hasMany(StoryView::class);
+	}
+
+	public function seen($pid = false)
+	{
+		$id = $pid ?? Auth::user()->profile->id;
+		return $this->views()->whereProfileId($id)->exists();
+	}
 }

+ 19 - 0
app/StoryItem.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use Storage;
+
+class StoryItem extends Model
+{
+	public function story()
+	{
+		return $this->belongsTo(Story::class);
+	}
+
+	public function url()
+	{
+		return Storage::url($this->media_path);
+	}
+}

+ 4 - 1
app/StoryReaction.php

@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 
 class StoryReaction extends Model
 {
-    //
+	public function story()
+	{
+		return $this->belongsTo(Story::class);
+	}
 }

+ 13 - 0
app/StoryView.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class StoryView extends Model
+{
+	public function story()
+	{
+		return $this->belongsTo(Story::class);
+	}
+}

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

@@ -50,7 +50,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
               'type'      => 'Document',
               'mediaType' => $media->mime,
               'url'       => $media->url(),
-              'name'      => null,
+              'name'      => $media->caption
             ];
           }),
           'tag' => [],

+ 5 - 1
app/Transformer/Api/AccountTransformer.php

@@ -9,8 +9,9 @@ class AccountTransformer extends Fractal\TransformerAbstract
 {
 	public function transform(Profile $profile)
 	{
+		$is_admin = $profile->domain ? false : $profile->user->is_admin;
 		return [
-			'id' => $profile->id,
+			'id' => (string) $profile->id,
 			'username' => $profile->username,
 			'acct' => $profile->username,
 			'display_name' => $profile->name,
@@ -28,6 +29,9 @@ class AccountTransformer extends Fractal\TransformerAbstract
 			'moved' => null,
 			'fields' => null,
 			'bot' => null,
+			'website' => $profile->website,
+			'software' => 'pixelfed',
+			'is_admin' => (bool) $is_admin
 		];
 	}
 }

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

@@ -9,7 +9,7 @@ class AttachmentTransformer extends Fractal\TransformerAbstract
 	public function transform(Media $media)
 	{
 		return [
-			'id'			=> $media->id,
+			'id'			=> (string) $media->id,
 			'type'			=> $media->activityVerb(),
 			'url'			=> $media->url(),
 			'remote_url' 	=> null,

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

@@ -10,7 +10,7 @@ class MediaTransformer extends Fractal\TransformerAbstract
     public function transform(Media $media)
     {
         return [
-            'id'            => $media->id,
+            'id'            => (string) $media->id,
             'type'          => $media->activityVerb(),
             'url'           => $media->url(),
             'remote_url'    => null,

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

@@ -10,7 +10,7 @@ class MentionTransformer extends Fractal\TransformerAbstract
     public function transform(Profile $profile)
     {
         return [
-            'id'       => $profile->id,
+            'id'       => (string) $profile->id,
             'url'      => $profile->url(),
             'username' => $profile->username,
             'acct'     => $profile->username,

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

@@ -15,7 +15,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
 	public function transform(Notification $notification)
 	{
 		return [
-			'id'       		=> $notification->id,
+			'id'       		=> (string) $notification->id,
 			'type'       	=> $this->replaceTypeVerb($notification->action),
 			'created_at' 	=> (string) $notification->created_at,
 			'account' 		=> null,
@@ -44,6 +44,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
 			'follow' => 'follow',
 			'mention' => 'mention',
 			'reblog' => 'share',
+			'share' => 'share',
 			'like' => 'favourite',
 			'comment' => 'comment',
 		];

+ 6 - 4
app/Transformer/Api/RelationshipTransformer.php

@@ -2,6 +2,7 @@
 
 namespace App\Transformer\Api;
 
+use Auth;
 use App\Profile;
 use League\Fractal;
 
@@ -9,17 +10,18 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
 {
     public function transform(Profile $profile)
     {
+        $user = Auth::user()->profile;
         return [
-            'id' => $profile->id,
-            'following' => null,
-            'followed_by' => null,
+            'id' => (string) $profile->id,
+            'following' => $user->follows($profile),
+            'followed_by' => $user->followedBy($profile),
             'blocking' => null,
             'muting' => null,
             'muting_notifications' => null,
             'requested' => null,
             'domain_blocking' => null,
             'showing_reblogs' => null,
-            'endorsed' => null
+            'endorsed' => false
         ];
     }
 }

+ 23 - 5
app/Transformer/Api/ResultsTransformer.php

@@ -8,12 +8,12 @@ class ResultsTransformer extends Fractal\TransformerAbstract
 {
 
 	protected $defaultIncludes = [
-		'account',
-		'mentions',
-		'media_attachments',
-		'tags',
+		'accounts',
+		'statuses',
+		'hashtags',
 	];
-	public function transform()
+
+	public function transform($results)
 	{
 		return [
 			'accounts' => [],
@@ -21,4 +21,22 @@ class ResultsTransformer extends Fractal\TransformerAbstract
 			'hashtags' => []
 		];
 	}
+
+	public function includeAccounts($results)
+	{
+		$accounts = $results->accounts;
+		return $this->collection($accounts, new AccountTransformer());
+	}
+
+	public function includeStatuses($results)
+	{
+		$statuses = $results->statuses;
+		return $this->collection($statuses, new StatusTransformer());
+	}
+
+	public function includeTags($results)
+	{
+		$hashtags = $status->hashtags;
+		return $this->collection($hashtags, new HashtagTransformer());
+	}
 }

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

@@ -17,7 +17,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
     public function transform(Status $status)
     {
         return [
-            'id'                        => $status->id,
+            'id'                        => (string) $status->id,
             'uri'                       => $status->url(),
             'url'                       => $status->url(),
             'in_reply_to_id'            => $status->in_reply_to_id,

+ 27 - 0
app/Transformer/Api/StoryItemTransformer.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\StoryItem;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class StoryItemTransformer extends Fractal\TransformerAbstract
+{
+
+    public function transform(StoryItem $item)
+    {
+        return [
+            'id'                        => (string) Str::uuid(),
+            'type'                      => $item->type,
+            'length'                    => $item->duration,
+            'src'                       => $item->url(),
+            'preview'                   => null,
+            'link'                      => null,
+            'linkText'                  => null,
+            'time'                      => $item->updated_at->format('U'),
+            'seen'                      => $item->story->seen(),
+        ];
+    }
+
+}

+ 34 - 0
app/Transformer/Api/StoryTransformer.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Story;
+use League\Fractal;
+
+class StoryTransformer extends Fractal\TransformerAbstract
+{
+    protected $defaultIncludes = [
+        'items',
+    ];
+
+    public function transform(Story $story)
+    {
+        return [
+            'id'                        => (string) $story->id,
+            'photo'                     => $story->profile->avatarUrl(),
+            'name'                      => '',
+            'link'                      => '',
+            'lastUpdated'               => $story->updated_at->format('U'),
+            'seen'                      => $story->seen(),
+            'items'                     => [],
+        ];
+    }
+
+    public function includeItems(Story $story)
+    {
+        $items = $story->items;
+
+        return $this->collection($items, new StoryItemTransformer());
+    }
+
+}

+ 5 - 0
app/User.php

@@ -62,6 +62,11 @@ class User extends Authenticatable
         );
     }
 
+    public function filters()
+    {
+        return $this->hasMany(UserFilter::class);
+    }
+
     public function receivesBroadcastNotificationsOn()
     {
         return 'App.User.'.$this->id;

+ 5 - 1
app/UserFilter.php

@@ -21,7 +21,6 @@ class UserFilter extends Model
     		->pluck('filterable_id');
     }
 
-
     public function blockedUserIds($profile_id)
     {
     	return $this->whereUserId($profile_id)
@@ -29,4 +28,9 @@ class UserFilter extends Model
     		->whereFilterType('block')
     		->pluck('filterable_id');
     }
+
+    public function instance()
+    {
+        return $this->belongsTo(Instance::class, 'filterable_id');
+    }
 }

+ 25 - 0
app/Util/ActivityPub/Validator/Follow.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Util\ActivityPub\Validator;
+
+use Validator;
+use Illuminate\Validation\Rule;
+
+class Follow {
+
+	public static function validate($payload)
+	{
+		$valid = Validator::make($payload, [
+			'@context' => 'required',
+			'id' => 'required|string',
+			'type' => [
+				'required',
+				Rule::in(['Follow'])
+			],
+			'actor' => 'required|url|active_url',
+			'object' => 'required|url|active_url'
+		])->passes();
+
+		return $valid;
+	}
+}

+ 25 - 0
app/Util/ActivityPub/Validator/Like.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Util\ActivityPub\Validator;
+
+use Validator;
+use Illuminate\Validation\Rule;
+
+class Like {
+
+	public static function validate($payload)
+	{
+		$valid = Validator::make($payload, [
+			'@context' => 'required',
+			'id' => 'required|string',
+			'type' => [
+				'required',
+				Rule::in(['Like'])
+			],
+			'actor' => 'required|url|active_url',
+			'object' => 'required|url|active_url'
+		])->passes();
+
+		return $valid;
+	}
+}

+ 41 - 16
app/Util/Lexer/RestrictedNames.php

@@ -17,15 +17,6 @@ class RestrictedNames
      'contact-us',
      'contact_us',
      'copyright',
-     'd',
-     'dashboard',
-     'dev',
-     'developer',
-     'developers',
-     'discover',
-     'discovers',
-     'doc',
-     'docs',
      'download',
      'domainadmin',
      'domainadministrator',
@@ -41,10 +32,7 @@ class RestrictedNames
      'guests',
      'hostmaster',
      'hostmaster',
-     'image',
-     'images',
      'imap',
-     'img',
      'info',
      'info',
      'is',
@@ -57,7 +45,6 @@ class RestrictedNames
      'mailerdaemon',
      'marketing',
      'me',
-     'media',
      'mis',
      'mx',
      'new',
@@ -82,7 +69,6 @@ class RestrictedNames
      'pop3',
      'postmaster',
      'pricing',
-     'privacy',
      'root',
      'sales',
      'security',
@@ -96,7 +82,6 @@ class RestrictedNames
      'sys',
      'sysadmin',
      'system',
-     'terms',
      'tutorial',
      'tutorials',
      'usenet',
@@ -121,34 +106,68 @@ class RestrictedNames
      'account',
      'api',
      'auth',
+     'bartender',
      'broadcast',
      'broadcaster',
+     'booth',
+     'bouncer',
+     'c',
      'css',
      'checkpoint',
      'collection',
      'collections',
-     'c',
+     'costar',
+     'costars',
      'cdn',
+     'd',
      'dashboard',
      'deck',
+     'dev',
+     'developer',
+     'developers',
      'discover',
+     'discovers',
+     'dj',
+     'doc',
      'docs',
+     'docs',
+     'drive',
+     'driver',
      'error',
      'explore',
+     'font',
      'fonts',
+     'gdpr',
      'home',
      'help',
      'helpcenter',
+     'help-center',
+     'help_center',
+     'help_center_',
+     'help-center-',
+     'help-center_',
+     'help_center-',
      'i',
      'img',
+     'imgs',
+     'image',
+     'images',
      'js',
+     'legal',
      'live',
      'login',
      'logout',
      'media',
+     'menu',
+     'oauth',
      'official',
      'p',
+     'page',
+     'pages',
+     'photo',
+     'photos',
      'password',
+     'privacy',
      'reset',
      'report',
      'reports',
@@ -161,10 +180,14 @@ class RestrictedNames
      'statuses',
      'site',
      'sites',
+     'stage',
      'static',
      'story',
      'stories',
      'support',
+     'svg',
+     'svgs',
+     'terms',
      'telescope',
      'timeline',
      'timelines',
@@ -174,9 +197,11 @@ class RestrictedNames
      'username',
      'usernames',
      'vendor',
+     'waiter',
      'ws',
      'wss',
      'www',
+     'valet',
      '400',
      '401',
      '403',

+ 6 - 0
composer.json

@@ -6,6 +6,12 @@
     "type": "project",
     "require": {
         "php": "^7.1.3",
+        "ext-bcmath": "*",
+        "ext-ctype": "*",
+        "ext-curl": "*",
+        "ext-json": "*",
+        "ext-mbstring": "*",
+        "ext-openssl": "*",
         "beyondcode/laravel-self-diagnosis": "^1.0.2",
         "bitverse/identicon": "^1.1",
         "doctrine/dbal": "^2.7",

+ 1 - 1
config/auth.php

@@ -42,7 +42,7 @@ return [
         ],
 
         'api' => [
-            'driver'   => 'token',
+            'driver'   => 'passport',
             'provider' => 'users',
         ],
     ],

+ 15 - 0
config/filesystems.php

@@ -65,6 +65,21 @@ return [
             'endpoint' => env('AWS_ENDPOINT'),
         ],
 
+        'spaces' => [
+            'driver' => 's3',
+            'key' => env('DO_SPACES_KEY'),
+            'secret' => env('DO_SPACES_SECRET'),
+            'endpoint' => env('DO_SPACES_ENDPOINT'),
+            'region' => env('DO_SPACES_REGION'),
+            'bucket' => env('DO_SPACES_BUCKET'),
+            'visibility' => 'public',
+            'options' => [
+                'CacheControl' => 'max-age=31536000'
+            ],
+            'root' => env('DO_SPACES_ROOT','/'),
+            'url' => str_replace(env('DO_SPACES_REGION'),env('DO_SPACES_BUCKET').'.'.env('DO_SPACES_REGION'),str_replace("digitaloceanspaces","cdn.digitaloceanspaces",env('DO_SPACES_ENDPOINT'))),
+        ],
+
     ],
 
 ];

+ 41 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your PixelFed instance.
     |
     */
-    'version' => '0.7.10',
+    'version' => '0.8.0rc1',
 
     /*
     |--------------------------------------------------------------------------
@@ -198,6 +198,46 @@ return [
     */
     'account_delete_after' => env('ACCOUNT_DELETE_AFTER', false),
 
+    /*
+    |--------------------------------------------------------------------------
+    | Enable Cloud Storage
+    |--------------------------------------------------------------------------
+    |
+    | Store media on object storage like S3, Digital Ocean Spaces, Rackspace
+    |
+    */
+    'cloud_storage' => env('PF_ENABLE_CLOUD', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Max User Limit
+    |--------------------------------------------------------------------------
+    |
+    | Allow a maximum number of user accounts. Default: off
+    |
+    */
+    'max_users' => env('PF_MAX_USERS', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Optimize Images
+    |--------------------------------------------------------------------------
+    |
+    | Resize and optimize image uploads. Default: on
+    |
+    */
+    'optimize_image' => env('PF_OPTIMIZE_IMAGES', true),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Optimize Videos
+    |--------------------------------------------------------------------------
+    |
+    | Resize and optimize video uploads. Default: on
+    |
+    */
+    'optimize_video' => env('PF_OPTIMIZE_VIDEOS', true),
+
 
     'media_types' => env('MEDIA_TYPES', 'image/jpeg,image/png,image/gif'),
     'enforce_account_limit' => env('LIMIT_ACCOUNT_SIZE', true),

+ 2 - 2
config/purify.php

@@ -54,7 +54,7 @@ return [
         |
         */
 
-        'HTML.Doctype' => 'XHTML 1.0 Strict',
+        'HTML.Doctype' => 'XHTML 1.0 Transitional',
 
         /*
         |--------------------------------------------------------------------------
@@ -67,7 +67,7 @@ return [
         |
         */
 
-        'HTML.Allowed' => 'a[href|title|rel],p',
+        'HTML.Allowed' => 'a[href|title|rel],p,strong,em,i,u,h1,h2,h3,h4,h5,ul,ol,li',
 
         /*
         |--------------------------------------------------------------------------

+ 9 - 1
contrib/docker/Dockerfile.apache

@@ -17,7 +17,15 @@ RUN apt-get update \
  && docker-php-ext-install pdo_mysql pcntl gd exif bcmath \
  && pecl install imagick \
  && docker-php-ext-enable imagick pcntl imagick gd exif \
- && a2enmod rewrite \
+ && a2enmod rewrite remoteip \
+ && {\
+     echo RemoteIPHeader X-Real-IP ;\
+     echo RemoteIPTrustedProxy 10.0.0.0/8 ;\
+     echo RemoteIPTrustedProxy 172.16.0.0/12 ;\
+     echo RemoteIPTrustedProxy 192.168.0.0/16 ;\
+     echo SetEnvIf X-Forwarded-Proto "https" HTTPS=on ;\
+    } > /etc/apache2/conf-available/remoteip.conf \
+ && a2enconf remoteip \
  && curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /usr/bin/composer \
  && echo "${COMPOSER_CHECKSUM}  /usr/bin/composer" | sha256sum -c - \
  && chmod 755 /usr/bin/composer \

+ 77 - 0
database/migrations/2019_01_12_054413_stories.php

@@ -0,0 +1,77 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class Stories extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('story_items', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('story_id')->unsigned()->index();
+            $table->string('media_path')->nullable();
+            $table->string('media_url')->nullable();
+            $table->tinyInteger('duration')->unsigned();
+            $table->string('filter')->nullable();
+            $table->string('link_url')->nullable()->index();
+            $table->string('link_text')->nullable();
+            $table->tinyInteger('order')->unsigned()->nullable();
+            $table->string('type')->default('photo');
+            $table->json('layers')->nullable();
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+
+        Schema::create('story_views', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('story_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->unique(['story_id', 'profile_id']);
+            $table->timestamps();
+        });
+
+        Schema::table('stories', function (Blueprint $table) {
+            $table->string('title')->nullable()->after('profile_id');
+            $table->boolean('preview_photo')->default(false)->after('title');
+            $table->boolean('local_only')->default(false)->after('preview_photo');
+            $table->boolean('is_live')->default(false)->after('local_only');
+            $table->string('broadcast_url')->nullable()->after('is_live');
+            $table->string('broadcast_key')->nullable()->after('broadcast_url');
+        });
+
+        Schema::table('story_reactions', function (Blueprint $table) {
+            $table->bigInteger('story_id')->unsigned()->index()->after('profile_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('story_items');
+        Schema::dropIfExists('story_views');
+
+        Schema::table('stories', function (Blueprint $table) {
+            $table->dropColumn('title');
+            $table->dropColumn('preview_photo');
+            $table->dropColumn('local_only');
+            $table->dropColumn('is_live');
+            $table->dropColumn('broadcast_url');
+            $table->dropColumn('broadcast_key');
+        });
+
+        Schema::table('story_reactions', function (Blueprint $table) {
+            $table->dropColumn('story_id');
+        });
+    }
+}

+ 40 - 0
database/migrations/2019_01_22_030129_create_pages_table.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreatePagesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('pages', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('root')->nullable()->index();
+            $table->string('slug')->nullable()->unique()->index();
+            $table->string('title')->nullable();
+            $table->unsignedInteger('category_id')->nullable()->index();
+            $table->longText('content')->nullable();
+            $table->string('template')->default('layouts.app')->index();
+            $table->boolean('active')->default(false)->index();
+            $table->boolean('cached')->default(true)->index();
+            $table->timestamp('active_until')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('pages');
+    }
+}

+ 34 - 0
database/migrations/2019_02_01_023357_add_remote_to_avatars_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddRemoteToAvatarsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('avatars', function (Blueprint $table) {
+            $table->string('remote_url')->nullable()->index()->after('thumb_path');
+            $table->timestamp('last_fetched_at')->nullable()->after('change_count');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('avatars', function (Blueprint $table) {
+            $table->dropColumn('remote_url');
+            $table->dropColumn('last_fetched_at');
+        });
+    }
+}

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