Browse Source

Merge pull request #6 from pixelfed/dev

Sync August 24
okpierre 5 years ago
parent
commit
754801e4fe
100 changed files with 3250 additions and 1445 deletions
  1. 1 0
      .env.example
  2. 1 0
      .env.testing
  3. 127 0
      CHANGELOG.md
  4. 17 0
      CONTRIBUTING.md
  5. 1 1
      README.md
  6. 27 0
      app/Collection.php
  7. 7 0
      app/CollectionItem.php
  8. 50 0
      app/Console/Commands/BannedEmailCheck.php
  9. 16 4
      app/Console/Commands/CatchUnoptimizedMedia.php
  10. 109 0
      app/Console/Commands/FixHashtags.php
  11. 158 0
      app/Console/Commands/ImportCities.php
  12. 9 3
      app/Console/Commands/MediaGarbageCollector.php
  13. 63 0
      app/Console/Commands/StatusDedupe.php
  14. 2 2
      app/Hashtag.php
  15. 19 0
      app/HashtagFollow.php
  16. 434 466
      app/Http/Controllers/AccountController.php
  17. 2 31
      app/Http/Controllers/AdminController.php
  18. 118 0
      app/Http/Controllers/Api/AdminApiController.php
  19. 6 23
      app/Http/Controllers/Api/BaseApiController.php
  20. 23 28
      app/Http/Controllers/ApiController.php
  21. 21 7
      app/Http/Controllers/Auth/RegisterController.php
  22. 16 1
      app/Http/Controllers/AvatarController.php
  23. 199 1
      app/Http/Controllers/CollectionController.php
  24. 11 0
      app/Http/Controllers/CommentController.php
  25. 28 51
      app/Http/Controllers/DiscoverController.php
  26. 33 59
      app/Http/Controllers/FederationController.php
  27. 18 12
      app/Http/Controllers/FollowerController.php
  28. 61 0
      app/Http/Controllers/HashtagFollowController.php
  29. 21 47
      app/Http/Controllers/InternalApiController.php
  30. 23 0
      app/Http/Controllers/PlaceController.php
  31. 9 17
      app/Http/Controllers/ProfileController.php
  32. 17 0
      app/Http/Controllers/ProfileSponsorController.php
  33. 33 3
      app/Http/Controllers/PublicApiController.php
  34. 4 1
      app/Http/Controllers/SearchController.php
  35. 3 3
      app/Http/Controllers/Settings/ExportSettings.php
  36. 21 7
      app/Http/Controllers/Settings/RelationshipSettings.php
  37. 68 6
      app/Http/Controllers/SettingsController.php
  38. 1 0
      app/Http/Controllers/SiteController.php
  39. 0 95
      app/Jobs/AvatarPipeline/ImportAvatar.php
  40. 11 1
      app/Jobs/CommentPipeline/CommentPipeline.php
  41. 3 1
      app/Jobs/DeletePipeline/DeleteAccountPipeline.php
  42. 1 1
      app/Jobs/StatusPipeline/StatusActivityPubDeliver.php
  43. 1 1
      app/Jobs/StatusPipeline/StatusDelete.php
  44. 5 1
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  45. 3 2
      app/Media.php
  46. 64 0
      app/Observers/StatusHashtagObserver.php
  47. 31 0
      app/Place.php
  48. 28 3
      app/Profile.php
  49. 15 0
      app/ProfileSponsor.php
  50. 3 0
      app/Providers/AppServiceProvider.php
  51. 19 0
      app/Services/EmailService.php
  52. 80 0
      app/Services/StatusHashtagService.php
  53. 18 0
      app/Status.php
  54. 14 1
      app/StatusHashtag.php
  55. 3 0
      app/Transformer/ActivityPub/ProfileTransformer.php
  56. 16 16
      app/Transformer/ActivityPub/Verb/Announce.php
  57. 23 0
      app/Transformer/Api/DirectMessageTransformer.php
  58. 11 2
      app/Transformer/Api/RelationshipTransformer.php
  59. 38 0
      app/Transformer/Api/StatusHashtagTransformer.php
  60. 3 17
      app/Transformer/Api/StatusTransformer.php
  61. 20 30
      app/Util/ActivityPub/Helpers.php
  62. 45 28
      app/Util/ActivityPub/Inbox.php
  63. 4 4
      app/Util/ActivityPub/Validator/Accept.php
  64. 3 1
      app/Util/Lexer/Extractor.php
  65. 40 5
      app/Util/Lexer/RestrictedNames.php
  66. 4 3
      app/Util/Media/Image.php
  67. 31 1
      app/Util/RateLimit/User.php
  68. 47 0
      app/Util/Site/Config.php
  69. 0 10
      app/WebSub.php
  70. 3 0
      composer.json
  71. 243 172
      composer.lock
  72. 0 2
      config/app.php
  73. 2 0
      config/cache.php
  74. 2 0
      config/database.php
  75. 1 1
      config/federation.php
  76. 33 5
      config/instance.php
  77. 1 1
      config/pixelfed.php
  78. 3 3
      contrib/docker/Dockerfile.apache
  79. 10 9
      contrib/docker/Dockerfile.fpm
  80. 1 1
      contrib/docker/php.ini
  81. 47 20
      contrib/nginx.conf
  82. 35 0
      database/migrations/2019_07_05_034644_create_hashtag_follows_table.php
  83. 32 0
      database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php
  84. 33 0
      database/migrations/2019_07_11_234836_create_profile_sponsors_table.php
  85. 28 0
      database/migrations/2019_07_16_010525_remove_web_subs_table.php
  86. 45 0
      database/migrations/2019_08_07_184030_create_places_table.php
  87. 37 0
      database/migrations/2019_08_12_074612_add_unique_to_statuses_table.php
  88. 324 230
      package-lock.json
  89. 9 5
      package.json
  90. BIN
      public/css/app.css
  91. BIN
      public/css/appdark.css
  92. BIN
      public/css/landing.css
  93. BIN
      public/img/Macbook__ipad__iphone.svg
  94. BIN
      public/js/ace.js
  95. BIN
      public/js/activity.js
  96. BIN
      public/js/app.js
  97. BIN
      public/js/collectioncompose.js
  98. BIN
      public/js/collections.js
  99. BIN
      public/js/components.js
  100. BIN
      public/js/compose.js

+ 1 - 0
.env.example

@@ -25,6 +25,7 @@ SESSION_DRIVER=redis
 SESSION_LIFETIME=120
 SESSION_LIFETIME=120
 QUEUE_DRIVER=redis
 QUEUE_DRIVER=redis
 
 
+REDIS_SCHEME=tcp
 REDIS_HOST=127.0.0.1
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 REDIS_PORT=6379

+ 1 - 0
.env.testing

@@ -25,6 +25,7 @@ SESSION_DRIVER=redis
 SESSION_LIFETIME=120
 SESSION_LIFETIME=120
 QUEUE_DRIVER=redis
 QUEUE_DRIVER=redis
 
 
+REDIS_SCHEME=tcp
 REDIS_HOST=127.0.0.1
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 REDIS_PORT=6379

+ 127 - 0
CHANGELOG.md

@@ -0,0 +1,127 @@
+# Release Notes
+
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.9.4...dev)
+    
+
+## [v0.9.X (TBD)](https://github.com/pixelfed/pixelfed/compare/v0.9.4...dev)
+
+### Added
+- Add StatusService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [425ec91](https://github.com/pixelfed/pixelfed/commit/425ec91)
+- Add PublicTimelineService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [734e892](https://github.com/pixelfed/pixelfed/commit/734e892)
+- Add RelationshipSettings trait [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [bf8340f](https://github.com/pixelfed/pixelfed/commit/bf8340f)
+- Add Remote Follows [#1388](https://github.com/pixelfed/pixelfed/pull/1388)
+- Add Relationship Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [b10e03d](https://github.com/pixelfed/pixelfed/commit/b10e03d)
+- Add Configuration Editor to Admin Dashboard [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [323dca1](https://github.com/pixelfed/pixelfed/commit/323dca1)
+- Add Migration, adding profile_id to users table [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [bdfe633](https://github.com/pixelfed/pixelfed/commit/bdfe633)
+- Add Media configuration [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
+- Add Content Warnings to comments [#1430](https://github.com/pixelfed/pixelfed/pull/1430), [42d81fc](https://github.com/pixelfed/pixelfed/commit/42d81fc) [8d4b3bd](https://github.com/pixelfed/pixelfed/commit/8d4b3bd) [73e162e4](https://github.com/pixelfed/pixelfed/commit/3e162e4)
+- Add new rate limits [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [1f1df2d](https://github.com/pixelfed/pixelfed/commit/1f1df2d)
+- Add RegenerateThumbnails command to force thumbnail regeneration [#1437](https://github.com/pixelfed/pixelfed/pull/1437) [a3be4cd](https://github.com/pixelfed/pixelfed/commit/a3be4cd)
+- Add Pages Editor to Admin Dashboard [#1438](https://github.com/pixelfed/pixelfed/pull/1438) [ef3e30d](https://github.com/pixelfed/pixelfed/commit/ef3e30d) [718375a](https://github.com/pixelfed/pixelfed/commit/718375a) [79524a0](https://github.com/pixelfed/pixelfed/commit/79524a0) [13ceef0](https://github.com/pixelfed/pixelfed/commit/13ceef0) [2fbcd6d](https://github.com/pixelfed/pixelfed/commit/2fbcd6d) [bb207a4](https://github.com/pixelfed/pixelfed/commit/bb207a4) [ef07e31](https://github.com/pixelfed/pixelfed/commit/ef07e31) [aca5114](https://github.com/pixelfed/pixelfed/commit/aca5114) [59fcfc2](https://github.com/pixelfed/pixelfed/commit/59fcfc2) [e3cfd81](https://github.com/pixelfed/pixelfed/commit/e3cfd81) [7ade78b](https://github.com/pixelfed/pixelfed/commit/7ade78b) [4539afa](https://github.com/pixelfed/pixelfed/commit/4539afa) [1dbfcae](https://github.com/pixelfed/pixelfed/commit/1dbfcae)
+
+### Changed
+- Update SearchController, fix AP verb typo [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [dc8acf9](https://github.com/pixelfed/pixelfed/commit/dc8acf9)
+- Update StatusTransformer, increase media cache ttl to 14 days [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [f35718b](https://github.com/pixelfed/pixelfed/commit/f35718b)
+- Update webpack config, extract vendor librarys [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [b42db89](https://github.com/pixelfed/pixelfed/commit/b42db89)
+- Update admin statuses view, make table header light [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [44afcc7](https://github.com/pixelfed/pixelfed/commit/44afcc7)
+- Update settings, move disable/delete to Security Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [ca0d638](https://github.com/pixelfed/pixelfed/commit/ca0d638)
+- Update Installer command [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [506dd8b](https://github.com/pixelfed/pixelfed/commit/506dd8b)
+- Update UserObserver [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [4ee3d10](https://github.com/pixelfed/pixelfed/commit/4ee3d10)
+- Update AuthLogin listener [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [c27c751](https://github.com/pixelfed/pixelfed/commit/c27c751) [1e8b092](https://github.com/pixelfed/pixelfed/commit/1e8b092)
+- Update Image Optimization to not store EXIF by default [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
+- Update Settings, hide OAuth/Developer pages when not enabled [#1413](https://github.com/pixelfed/pixelfed/pull/1413)
+- Update Presenter Components, move alt tag and filters to ```<img>``` element [#1415](https://github.com/pixelfed/pixelfed/pull/1415)
+- Update Api Controllers, add missing caption limit to ```composePost()``` and missing ```is_nsfw``` attribute to comment queries [#1429](https://github.com/pixelfed/pixelfed/pull/1429), [1cff278](https://github.com/pixelfed/pixelfed/commit/1cff278)
+- Update instances admin view, add scan button to find new instances [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [a94a3ee](https://github.com/pixelfed/pixelfed/commit/a94a3ee)
+- Update registration page, add links to terms and privacy pages [#1488](https://github.com/pixelfed/pixelfed/pull/1488)
+
+### Removed
+- Remove Classic Compose UI [#1434](https://github.com/pixelfed/pixelfed/pull/1434), [72bffd1](https://github.com/pixelfed/pixelfed/commit/72bffd1) [a2640af](https://github.com/pixelfed/pixelfed/commit/a2640af)
+- 
+    
+
+## [v0.9.4 (2019-06-03)](https://github.com/pixelfed/pixelfed/compare/v0.9.0...v0.9.4)
+
+PSA: Due to the removal of Google Recaptcha, a one-time manual intervention is required. Please try the following after installing with composer:
+
+```
+rm -rf bootstrap/cache/*
+composer dump-autoload
+php artisan config:cache
+```
+
+### Added
+- Notification service
+- Notification card on timeline
+- Double-tap to like posts (no animation yet)
+- Moderator Mode for timelines
+- Emoji reaction bar
+- Like and reply to comments
+- Hello Loops! Short videos will now loop and be discoverable from the Discover page.
+- Labs: Optional profile recommendations
+- Labs: Show full caption instead of "read more" button
+- Labs: Simple "distraction-free" timeline -- no buttons, just images and captions
+
+### Changed
+- Refactored notification view into a Vue component
+- Preparations for Circles, DMs, and other upcoming functionality
+- Default limit of 7500 follows
+- Default limit of 20 follows per hour
+- Default limit of 5 mentions per comment/caption
+- Default limit of 30 hashtags per comment/caption
+- Default limit of 2 links per comment/caption
+- Thumbnail info overlays on profiles should now scale down to small screens (#1234)
+- Moment UI containers are now properly sized (#1236)
+- Album posts now have contrast for next/prev arrows (#1238)
+- Filter previews now fit the image instead of stretching it (#1239)
+
+### Removed
+- Google Recaptcha is no longer supported (#1231)
+- Lightbox has been deprecated in favor of double-tap-to-like; it will return as a dedicated button in the future (#1277)
+    
+
+## [v0.9.0 (2019-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.8.6...v0.9.0)
+
+### Added
+- Allow users to delete existing profile photos.
+- Preliminary support for managing developer tokens, as well as authorizing apps
+- Unmute and unblock users more easily. Profiles now reflect muting/blocking status.
+- Lazy-loading images with `loading="lazy"`, as supported in Blink
+- Added Network Timeline which includes non-local posts
+- Add broadcast events for real-time updates
+- Compose view now shows upload progress bar
+- You can now audit logged-in devices
+- Added WIP installer
+- Moment UI! This alternative profile view is less square and more full-width pictures.
+
+### Changed
+- Allow admins to view reported private posts
+- Show sensitivity and privacy/audience in status views
+- Cleanup of legacy code
+- `commentsDisabled` has been replaced with preliminary support for Litepub Capability Enforcement (LiCE)
+- `rel="me"` now added to profile websites
+- Posts from locked accounts now default to followers-only
+
+### Removed
+- Removed identicons due to SVG compatibility issues with federation. New users will instead be assigned a default avatar.
+    
+
+## [v0.8.6 (2019-04-06)](https://github.com/pixelfed/pixelfed/compare/v0.8.5...v0.8.6)
+
+### Added
+- Add COSTAR - Confirm Object Sentiment Transform and Reduce
+
+COSTAR is a filtering system that allows admins to define environment variables that will dynamically apply certain policies to posts of a defined scope, similar to Pleroma's MRF system.
+
+Scopes:
+- Domain: apply to posts from a specific website
+- Actor: apply to posts from a specific profile/user
+- Keyword: apply to posts containing a specific string
+
+Policies:
+- Block: Default blocks the defined scope
+- CW: Automatically rewrites the scope to apply a warning
+- Unlist: Removes the scope from public timelines
+
+
+

+ 17 - 0
CONTRIBUTING.md

@@ -0,0 +1,17 @@
+# Contributing
+
+## Bug Reports
+To encourage active collaboration, Pixelfed strongly encourages pull requests, not just bug reports. "Bug reports" may also be sent in the form of a pull request containing a failing test.
+    
+However, if you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix.
+    
+Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem.
+
+## Core Development Discussion
+Informal discussion regarding bugs, new features, and implementation of existing features takes place in the ```#pixelfed-dev``` channel on the Freenode IRC network.
+
+## Compiled Assets
+If you are submitting a change that will affect a compiled file, such as most of the files in ```resources/assets/sass``` or ```resources/assets/js``` of the pixelfed/pixelfed repository, do not commit the compiled files. Due to their large size, they cannot realistically be reviewed by a maintainer. This could be exploited as a way to inject malicious code into Pixelfed. In order to defensively prevent this, all compiled files will be generated and committed by Pixelfed maintainers.
+
+## Security Vulnerabilities
+If you discover a security vulnerability within Pixelfed, please send an email to Daniel Supernault at hello@pixelfed.org. All security vulnerabilities will be promptly addressed.

+ 1 - 1
README.md

@@ -17,7 +17,7 @@ A free and ethical photo sharing platform, powered by ActivityPub federation.
 
 
 ## Official Documentation
 ## Official Documentation
 
 
-Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://pixelfed.github.io/docs/master/).
+Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://docs.pixelfed.org/).
 
 
 ## License
 ## License
 
 

+ 27 - 0
app/Collection.php

@@ -2,6 +2,7 @@
 
 
 namespace App;
 namespace App;
 
 
+use Illuminate\Support\Str;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Pixelfed\Snowflake\HasSnowflakePrimary;
 use Pixelfed\Snowflake\HasSnowflakePrimary;
 
 
@@ -16,8 +17,34 @@ class Collection extends Model
      */
      */
     public $incrementing = false;
     public $incrementing = false;
 
 
+    public $fillable = ['profile_id', 'published_at'];
+
+    public $dates = ['published_at'];
+
 	public function profile()
 	public function profile()
 	{
 	{
 		return $this->belongsTo(Profile::class);
 		return $this->belongsTo(Profile::class);
 	}
 	}
+
+    public function items()
+    {
+        return $this->hasMany(CollectionItem::class);
+    }
+
+    public function posts()
+    {
+        return $this->hasManyThrough(
+            Status::class,
+            CollectionItem::class,
+            'collection_id',
+            'id',
+            'id',
+            'object_id'
+        );
+    }
+
+    public function url()
+    {
+        return url("/c/{$this->id}");
+    }
 }
 }

+ 7 - 0
app/CollectionItem.php

@@ -9,6 +9,13 @@ class CollectionItem extends Model
 {
 {
 	use HasSnowflakePrimary;
 	use HasSnowflakePrimary;
 
 
+    public $fillable = [
+        'collection_id',
+        'object_type',
+        'object_id',
+        'order'
+    ];
+    
     /**
     /**
      * Indicates if the IDs are auto-incrementing.
      * Indicates if the IDs are auto-incrementing.
      *
      *

+ 50 - 0
app/Console/Commands/BannedEmailCheck.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\User;
+use App\Services\EmailService;
+
+class BannedEmailCheck extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'email:bancheck';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Checks user emails for banned domains';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $users = User::whereNull('status')->get()->filter(function($u) {
+            return EmailService::isBanned($u->email) == true;
+        });
+
+        foreach($users as $user) {
+            $this->info('Found banned domain: ' . $user->email . PHP_EOL);
+        }
+    }
+}

+ 16 - 4
app/Console/Commands/CatchUnoptimizedMedia.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Console\Commands;
 namespace App\Console\Commands;
 
 
+use DB;
 use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Media;
 use App\Media;
 use Illuminate\Console\Command;
 use Illuminate\Console\Command;
@@ -39,9 +40,20 @@ class CatchUnoptimizedMedia extends Command
      */
      */
     public function handle()
     public function handle()
     {
     {
-        $medias = Media::whereNotNull('status_id')->whereNull('processed_at')->take(250)->get();
-        foreach ($medias as $media) {
-            ImageOptimize::dispatch($media);
-        }
+        DB::transaction(function() {
+            Media::whereNull('processed_at')
+                ->whereNull('remote_url')
+                ->whereNotNull('status_id')
+                ->whereNotNull('media_path')
+                ->whereIn('mime', [
+                    'image/jpeg',
+                    'image/png',
+                ])
+                ->chunk(50, function($medias) {
+                    foreach ($medias as $media) {
+                        ImageOptimize::dispatch($media);
+                    }
+                });
+         });
     }
     }
 }
 }

+ 109 - 0
app/Console/Commands/FixHashtags.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use DB;
+use App\{
+    Hashtag,
+    Status,
+    StatusHashtag
+};
+
+class FixHashtags extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'fix:hashtags';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Fix Hashtags';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+
+        $this->info('       ____  _           ______         __  ');
+        $this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
+        $this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
+        $this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
+        $this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
+        $this->info(' ');
+        $this->info(' ');
+        $this->info('Pixelfed version: ' . config('pixelfed.version'));
+        $this->info(' ');
+        $this->info('Running Fix Hashtags command');
+        $this->info(' ');
+
+        $missingCount = StatusHashtag::doesntHave('profile')->doesntHave('status')->count();
+        if($missingCount > 0) {
+            $this->info("Found {$missingCount} orphaned StatusHashtag records to delete ...");
+            $this->info(' ');
+            $bar = $this->output->createProgressBar($missingCount);
+            $bar->start();
+            foreach(StatusHashtag::doesntHave('profile')->doesntHave('status')->get() as $tag) {
+                $tag->delete();
+                $bar->advance();
+            }
+            $bar->finish();
+            $this->info(' ');
+        } else {
+            $this->info(' ');
+            $this->info('Found no orphaned hashtags to delete!');
+        }
+        
+
+        $this->info(' ');
+
+        $count = StatusHashtag::whereNull('status_visibility')->count();
+        if($count > 0) {
+            $this->info("Found {$count} hashtags to fix ...");
+            $this->info(' ');
+        } else {
+            $this->info('Found no hashtags to fix!');
+            $this->info(' ');
+            return;
+        }
+
+        $bar = $this->output->createProgressBar($count);
+        $bar->start();
+
+        StatusHashtag::with('status')
+        ->whereNull('status_visibility')
+        ->chunk(50, function($tags) use($bar) {
+            foreach($tags as $tag) {
+                if(!$tag->status || !$tag->status->scope) {
+                    continue;
+                }
+                $tag->status_visibility = $tag->status->scope;
+                $tag->save();
+                $bar->advance();
+            }
+        });
+
+        $bar->finish();
+        $this->info(' ');
+        $this->info(' ');
+    }
+}

+ 158 - 0
app/Console/Commands/ImportCities.php

@@ -0,0 +1,158 @@
+<?php
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Place;
+use DB;
+use Illuminate\Support\Str;
+
+class ImportCities extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'import:cities {chunk=1000}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Import Cities to database';
+
+    /**
+     * Checksum of city dataset.
+     *
+     */
+    const CHECKSUM = 'e203c0247538788b2a91166c7cf4b95f58291d998f514e9306d315aa72b09e48bfd3ddf310bf737afc4eefadca9083b8ff796c67796c6bd8e882a3d268bd16af';
+
+    /**
+     * List of shortened countries.
+     *
+     * @var array
+     */
+    protected $countries = [
+        'AE' => 'UAE',
+        'BA' => 'Bosnia-Herzegovina',
+        'BO' => 'Bolivia',
+        'CD' => 'Democratic Republic of Congo',
+        'CG' => 'Republic of Congo',
+        'FM' => 'Micronesia',
+        'GB' => 'United Kingdom',
+        'IR' => 'Iran',
+        'KP' => 'DRPK',
+        'KR' => 'South Korea',
+        'LA' => 'Laos',
+        'MD' => 'Moldova',
+        'PS' => 'Palestine',
+        'RU' => 'Russia',
+        'SH' => 'Saint Helena',
+        'SY' => 'Syria',
+        'TW' => 'Taiwan',
+        'TZ' => 'Tanzania',
+        'US' => 'USA',
+        'VE' => 'Venezuela',
+        'XK' => 'Kosovo'
+    ];
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+        ini_set('memory_limit', '256M');
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $path = storage_path('app/cities.json');
+
+        if(hash_file('sha512', $path) !== self::CHECKSUM) {
+            $this->error('Invalid or corrupt storage/app/cities.json data.');
+            $this->line('');
+            $this->info('Run the following command to fix:');
+            $this->info('git checkout storage/app/cities.json');
+            return;
+        }
+
+        if (!is_file($path)) {
+            $this->error('Missing storage/app/cities.json file!');
+            return;
+        }
+
+        if (Place::count() > 0) {
+            DB::table('places')->truncate();
+        }
+
+        $this->info('Importing city data into database ...');
+
+        $cities = json_decode(file_get_contents($path));
+        $cityCount = count($cities);
+
+        $this->line('');
+        $this->info("Found {$cityCount} cities to insert ...");
+        $this->line('');
+        
+        $bar = $this->output->createProgressBar($cityCount);
+        $bar->start();
+        
+        $buffer = [];
+        $count = 0;
+        
+        foreach ($cities as $city) {
+            $buffer[] = [
+                "name" => $city->name, 
+                "slug" => Str::slug($city->name), 
+                "country" => $this->codeToCountry($city->country), 
+                "lat" => $city->lat, 
+                "long" => $city->lng
+            ];
+
+            $count++;
+
+            if ($count % $this->argument('chunk') == 0) {
+                $this->insertBuffer($buffer);
+                $bar->advance(count($buffer));
+                $buffer = [];
+            }
+        }
+        $this->insertBuffer($buffer);
+
+        $bar->advance(count($buffer));
+
+        $bar->finish();
+
+        $this->line('');
+        $this->line('');
+        $this->info('Successfully imported ' . $cityCount . ' entries!');
+        $this->line('');
+        return;
+    }
+
+    private function insertBuffer($buffer)
+    {
+        DB::table('places')->insert($buffer);
+    }
+
+    private function codeToCountry($code)
+    {
+        $countries = $this->countries;
+        if(isset($countries[$code])) {
+            return $countries[$code];
+        }
+
+        $country = (new \League\ISO3166\ISO3166)->alpha2($code);
+        $this->countries[$code] = $country['name'];
+        return $this->countries[$code];
+    }
+}

+ 9 - 3
app/Console/Commands/MediaGarbageCollector.php

@@ -39,12 +39,16 @@ class MediaGarbageCollector extends Command
      */
      */
     public function handle()
     public function handle()
     {
     {
-        $gc = Media::whereNull('status_id')
-        ->where('created_at', '<', Carbon::now()->subHours(6)->toDateTimeString())
+        $limit = 20000;
+        
+        $gc = Media::doesntHave('status')
+        ->where('created_at', '<', Carbon::now()->subHours(1)->toDateTimeString())
         ->orderBy('created_at','asc')
         ->orderBy('created_at','asc')
-        ->take(500)
+        ->take($limit)
         ->get();
         ->get();
 
 
+        $bar = $this->output->createProgressBar($gc->count());
+        $bar->start();
         foreach($gc as $media) {
         foreach($gc as $media) {
             $path = storage_path("app/$media->media_path");
             $path = storage_path("app/$media->media_path");
             $thumb = storage_path("app/$media->thumbnail_path");
             $thumb = storage_path("app/$media->thumbnail_path");
@@ -55,6 +59,8 @@ class MediaGarbageCollector extends Command
                 unlink($thumb);
                 unlink($thumb);
             }
             }
             $media->forceDelete();
             $media->forceDelete();
+            $bar->advance();
         }
         }
+        $bar->finish();
     }
     }
 }
 }

+ 63 - 0
app/Console/Commands/StatusDedupe.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Status;
+use DB;
+use App\Jobs\StatusPipeline\StatusDelete;
+
+class StatusDedupe extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'status:dedup';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Removes duplicate statuses from before unique uri migration';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        DB::table('statuses')
+            ->selectRaw('id, uri, count(uri) as occurences')
+            ->whereNull('deleted_at')
+            ->whereNotNull('uri')
+            ->groupBy('uri')
+            ->orderBy('created_at')
+            ->having('occurences', '>', 1)
+            ->chunk(50, function($statuses) {
+                foreach($statuses as $status) {
+                    $this->info("Found duplicate: $status->uri");
+                    Status::whereUri($status->uri)
+                        ->where('id', '!=', $status->id)
+                        ->get()
+                        ->map(function($status) {
+                            $this->info("Deleting Duplicate ID: $status->id");
+                            StatusDelete::dispatch($status);
+                        });
+                }
+            });
+    }
+}

+ 2 - 2
app/Hashtag.php

@@ -20,8 +20,8 @@ class Hashtag extends Model
       );
       );
     }
     }
 
 
-    public function url()
+    public function url($suffix = '')
     {
     {
-        return config('routes.hashtag.base').$this->slug;
+        return config('routes.hashtag.base').$this->slug.$suffix;
     }
     }
 }
 }

+ 19 - 0
app/HashtagFollow.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class HashtagFollow extends Model
+{
+    protected $fillable = [
+    	'user_id',
+    	'profile_id',
+    	'hashtag_id'
+    ];
+
+    public function hashtag()
+    {
+    	return $this->belongsTo(Hashtag::class);
+    }
+}

+ 434 - 466
app/Http/Controllers/AccountController.php

@@ -2,476 +2,444 @@
 
 
 namespace App\Http\Controllers;
 namespace App\Http\Controllers;
 
 
-use App\EmailVerification;
-use App\Follower;
-use App\FollowRequest;
-use App\Jobs\FollowPipeline\FollowPipeline;
-use App\Mail\ConfirmEmail;
-use App\Notification;
-use App\Profile;
-use App\User;
-use App\UserFilter;
-use Auth;
-use Cache;
+use Auth; 
+use Cache; 
+use Mail; 
+use Redis;
 use Carbon\Carbon;
 use Carbon\Carbon;
+use App\Mail\ConfirmEmail;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
-use Mail;
-use Redis;
 use PragmaRX\Google2FA\Google2FA;
 use PragmaRX\Google2FA\Google2FA;
+use App\Jobs\FollowPipeline\FollowPipeline;
+use App\{
+	EmailVerification,
+	Follower,
+	FollowRequest,
+	Notification,
+	Profile,
+	User,
+	UserFilter
+};
 
 
 class AccountController extends Controller
 class AccountController extends Controller
 {
 {
-    protected $filters = [
-      'user.mute',
-      'user.block',
-    ];
-
-    public function __construct()
-    {
-        $this->middleware('auth');
-    }
-
-    public function notifications(Request $request)
-    {
-        return view('account.activity');
-    }
-
-    public function followingActivity(Request $request)
-    {
-        $this->validate($request, [
-          'page' => 'nullable|min:1|max:3',
-          'a'    => 'nullable|alpha_dash',
-      ]);
-        $profile = Auth::user()->profile;
-        $action = $request->input('a');
-        $allowed = ['like', 'follow'];
-        $timeago = Carbon::now()->subMonths(3);
-        $following = $profile->following->pluck('id');
-        $notifications = Notification::whereIn('actor_id', $following)
-          ->whereIn('action', $allowed)
-          ->where('actor_id', '<>', $profile->id)
-          ->where('profile_id', '<>', $profile->id)
-          ->whereDate('created_at', '>', $timeago)
-          ->orderBy('notifications.created_at', 'desc')
-          ->simplePaginate(30);
-
-        return view('account.following', compact('profile', 'notifications'));
-    }
-
-    public function verifyEmail(Request $request)
-    {
-        return view('account.verify_email');
-    }
-
-    public function sendVerifyEmail(Request $request)
-    {
-        $recentAttempt = EmailVerification::whereUserId(Auth::id())
-          ->whereDate('created_at', '>', now()->subHours(12))->count();
-
-        if ($recentAttempt > 0) {
-            return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
-        } 
-
-        EmailVerification::whereUserId(Auth::id())->delete();
-
-        $user = User::whereNull('email_verified_at')->find(Auth::id());
-        $utoken = str_random(40);
-        $rtoken = str_random(128);
-
-        $verify = new EmailVerification();
-        $verify->user_id = $user->id;
-        $verify->email = $user->email;
-        $verify->user_token = $utoken;
-        $verify->random_token = $rtoken;
-        $verify->save();
-
-        Mail::to($user->email)->send(new ConfirmEmail($verify));
-
-        return redirect()->back()->with('status', 'Verification email sent!');
-    }
-
-    public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
-    {
-        $verify = EmailVerification::where('user_token', $userToken)
-          ->where('random_token', $randomToken)
-          ->firstOrFail();
-
-        if (Auth::id() === $verify->user_id &&
-          $verify->user_token === $userToken &&
-          $verify->random_token === $randomToken) {
-            $user = User::find(Auth::id());
-            $user->email_verified_at = Carbon::now();
-            $user->save();
-
-            return redirect('/');
-        } else {
-            abort(403);
-        }
-    }
-
-    public function fetchNotifications(int $id)
-    {
-        $key = config('cache.prefix').":user.{$id}.notifications";
-        $redis = Redis::connection();
-        $notifications = $redis->lrange($key, 0, 30);
-        if (empty($notifications)) {
-            $notifications = Notification::whereProfileId($id)
-          ->orderBy('id', 'desc')->take(30)->get();
-        } else {
-            $notifications = $this->hydrateNotifications($notifications);
-        }
-
-        return $notifications;
-    }
-
-    public function hydrateNotifications($keys)
-    {
-        $prefix = 'notification.';
-        $notifications = collect([]);
-        foreach ($keys as $key) {
-            $notifications->push(Cache::get("{$prefix}{$key}"));
-        }
-
-        return $notifications;
-    }
-
-    public function messages()
-    {
-        return view('account.messages');
-    }
-
-    public function direct()
-    {
-        return view('account.direct');
-    }
-
-    public function showMessage(Request $request, $id)
-    {
-        return view('account.message');
-    }
-
-    public function mute(Request $request)
-    {
-        $this->validate($request, [
-          'type' => 'required|alpha_dash',
-          'item' => 'required|integer|min:1',
-        ]);
-
-        $user = Auth::user()->profile;
-        $type = $request->input('type');
-        $item = $request->input('item');
-        $action = $type . '.mute';
-
-        if (!in_array($action, $this->filters)) {
-            return abort(406);
-        }
-        $filterable = [];
-        switch ($type) {
-          case 'user':
-            $profile = Profile::findOrFail($item);
-            if ($profile->id == $user->id) {
-                return abort(403);
-            }
-            $class = get_class($profile);
-            $filterable['id'] = $profile->id;
-            $filterable['type'] = $class;
-            break;
-
-          default:
-            // code...
-            break;
-        }
-
-        $filter = UserFilter::firstOrCreate([
-          'user_id'         => $user->id,
-          'filterable_id'   => $filterable['id'],
-          'filterable_type' => $filterable['type'],
-          'filter_type'     => 'mute',
-        ]);
-
-        $pid = $user->id;
-        Cache::forget("user:filter:list:$pid");
-        Cache::forget("feature:discover:posts:$pid");
-        Cache::forget("api:local:exp:rec:$pid");
-
-        return redirect()->back();
-    }
-
-    public function unmute(Request $request)
-    {
-        $this->validate($request, [
-          'type' => 'required|alpha_dash',
-          'item' => 'required|integer|min:1',
-        ]);
-
-        $user = Auth::user()->profile;
-        $type = $request->input('type');
-        $item = $request->input('item');
-        $action = $type . '.mute';
-
-        if (!in_array($action, $this->filters)) {
-            return abort(406);
-        }
-        $filterable = [];
-        switch ($type) {
-          case 'user':
-            $profile = Profile::findOrFail($item);
-            if ($profile->id == $user->id) {
-                return abort(403);
-            }
-            $class = get_class($profile);
-            $filterable['id'] = $profile->id;
-            $filterable['type'] = $class;
-            break;
-
-          default:
-            abort(400);
-            break;
-        }
-
-        $filter = UserFilter::whereUserId($user->id)
-            ->whereFilterableId($filterable['id'])
-            ->whereFilterableType($filterable['type'])
-            ->whereFilterType('mute')
-            ->first();
-
-        if($filter) {
-            $filter->delete();
-        }
-
-        $pid = $user->id;
-        Cache::forget("user:filter:list:$pid");
-        Cache::forget("feature:discover:posts:$pid");
-        Cache::forget("api:local:exp:rec:$pid");
-
-        if($request->wantsJson()) {
-            return response()->json([200]);
-        } else {
-            return redirect()->back();
-        }
-    }
-
-    public function block(Request $request)
-    {
-        $this->validate($request, [
-          'type' => 'required|alpha_dash',
-          'item' => 'required|integer|min:1',
-        ]);
-
-        $user = Auth::user()->profile;
-        $type = $request->input('type');
-        $item = $request->input('item');
-        $action = $type.'.block';
-        if (!in_array($action, $this->filters)) {
-            return abort(406);
-        }
-        $filterable = [];
-        switch ($type) {
-          case 'user':
-            $profile = Profile::findOrFail($item);
-            if ($profile->id == $user->id) {
-                return abort(403);
-            }
-            $class = get_class($profile);
-            $filterable['id'] = $profile->id;
-            $filterable['type'] = $class;
-
-            Follower::whereProfileId($profile->id)->whereFollowingId($user->id)->delete();
-            Notification::whereProfileId($user->id)->whereActorId($profile->id)->delete();
-            break;
-
-          default:
-            // code...
-            break;
-        }
-
-        $filter = UserFilter::firstOrCreate([
-          'user_id'         => $user->id,
-          'filterable_id'   => $filterable['id'],
-          'filterable_type' => $filterable['type'],
-          'filter_type'     => 'block',
-        ]);
-
-        $pid = $user->id;
-        Cache::forget("user:filter:list:$pid");
-        Cache::forget("feature:discover:posts:$pid");
-        Cache::forget("api:local:exp:rec:$pid");
-
-        return redirect()->back();
-    }
-
-
-    public function unblock(Request $request)
-    {
-        $this->validate($request, [
-          'type' => 'required|alpha_dash',
-          'item' => 'required|integer|min:1',
-        ]);
-
-        $user = Auth::user()->profile;
-        $type = $request->input('type');
-        $item = $request->input('item');
-        $action = $type . '.block';
-        if (!in_array($action, $this->filters)) {
-            return abort(406);
-        }
-        $filterable = [];
-        switch ($type) {
-          case 'user':
-            $profile = Profile::findOrFail($item);
-            if ($profile->id == $user->id) {
-                return abort(403);
-            }
-            $class = get_class($profile);
-            $filterable['id'] = $profile->id;
-            $filterable['type'] = $class;
-            break;
-
-          default:
-            abort(400);
-            break;
-        }
-
-
-        $filter = UserFilter::whereUserId($user->id)
-            ->whereFilterableId($filterable['id'])
-            ->whereFilterableType($filterable['type'])
-            ->whereFilterType('block')
-            ->first();
-
-        if($filter) {
-            $filter->delete();
-        }
-
-        $pid = $user->id;
-        Cache::forget("user:filter:list:$pid");
-        Cache::forget("feature:discover:posts:$pid");
-        Cache::forget("api:local:exp:rec:$pid");
-        
-        return redirect()->back();
-    }
-
-    public function followRequests(Request $request)
-    {
-        $pid = Auth::user()->profile->id;
-        $followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10);
-        return view('account.follow-requests', compact('followers'));
-    }
-
-    public function followRequestHandle(Request $request)
-    {
-        $this->validate($request, [
-            'action' => 'required|string|max:10',
-            'id' => 'required|integer|min:1'
-        ]);
-
-        $pid = Auth::user()->profile->id;
-        $action = $request->input('action') === 'accept' ? 'accept' : 'reject';
-        $id = $request->input('id');
-        $followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
-        $follower = $followRequest->follower;
-
-        switch ($action) {
-            case 'accept':
-                $follow = new Follower();
-                $follow->profile_id = $follower->id;
-                $follow->following_id = $pid;
-                $follow->save();
-                FollowPipeline::dispatch($follow);
-                $followRequest->delete();
-                break;
-
-            case 'reject':
-                $followRequest->is_rejected = true;
-                $followRequest->save();
-                break;
-        }
-
-        return response()->json(['msg' => 'success'], 200);
-    }
-
-    public function sudoMode(Request $request)
-    {
-        return view('auth.sudo');
-    }
-
-    public function sudoModeVerify(Request $request)
-    {
-        $this->validate($request, [
-            'password' => 'required|string|max:500'
-        ]);
-        $user = Auth::user();
-        $password = $request->input('password');
-        $next = $request->session()->get('redirectNext', '/');
-        if(password_verify($password, $user->password) === true) {
-            $request->session()->put('sudoMode', time());
-            return redirect($next);
-        } else {
-            return redirect()
-                ->back()
-                ->withErrors(['password' => __('auth.failed')]);
-        }
-    }
-
-    public function twoFactorCheckpoint(Request $request)
-    {
-        return view('auth.checkpoint');
-    }
-
-    public function twoFactorVerify(Request $request)
-    {
-        $this->validate($request, [
-            'code'  => 'required|string|max:32'
-        ]);
-        $user = Auth::user();
-        $code = $request->input('code');
-        $google2fa = new Google2FA();
-        $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
-        if($verify) {
-            $request->session()->push('2fa.session.active', true);
-            return redirect('/');
-        } else {
-
-            if($this->twoFactorBackupCheck($request, $code, $user)) {
-                return redirect('/');
-            }
-
-            if($request->session()->has('2fa.attempts')) {
-                $count = (int) $request->session()->has('2fa.attempts');
-                $request->session()->push('2fa.attempts', $count + 1);
-            } else {
-                $request->session()->push('2fa.attempts', 1);
-            }
-            return redirect()->back()->withErrors([
-                'code' => 'Invalid code'
-            ]);
-        }
-    }
-
-    protected function twoFactorBackupCheck($request, $code, User $user)
-    {
-            $backupCodes = $user->{'2fa_backup_codes'};
-            if($backupCodes) {
-                $codes = json_decode($backupCodes, true);
-                foreach ($codes as $c) {
-                    if(hash_equals($c, $code)) {
-                        // remove code
-                        $codes = array_flatten(array_diff($codes, [$code]));
-                        $user->{'2fa_backup_codes'} = json_encode($codes);
-                        $user->save();
-                        $request->session()->push('2fa.session.active', true);
-                        return true;
-                    } else {
-                        return false;
-                    }
-                }
-            } else {
-                return false;
-            }  
-    }
-
-    public function accountRestored(Request $request)
-    {
-        //
-    }
+	protected $filters = [
+		'user.mute',
+		'user.block',
+	];
+
+	public function __construct()
+	{
+		$this->middleware('auth');
+	}
+
+	public function notifications(Request $request)
+	{
+		return view('account.activity');
+	}
+
+	public function followingActivity(Request $request)
+	{
+		$this->validate($request, [
+			'page' => 'nullable|min:1|max:3',
+			'a'    => 'nullable|alpha_dash',
+		]);
+
+		$action = $request->input('a');
+		$allowed = ['like', 'follow'];
+		$timeago = Carbon::now()->subMonths(3);
+
+		$profile = Auth::user()->profile;
+		$following = $profile->following->pluck('id');
+
+		$notifications = Notification::whereIn('actor_id', $following)
+		->whereIn('action', $allowed)
+		->where('actor_id', '<>', $profile->id)
+		->where('profile_id', '<>', $profile->id)
+		->whereDate('created_at', '>', $timeago)
+		->orderBy('notifications.created_at', 'desc')
+		->simplePaginate(30);
+
+		return view('account.following', compact('profile', 'notifications'));
+	}
+
+	public function verifyEmail(Request $request)
+	{
+		return view('account.verify_email');
+	}
+
+	public function sendVerifyEmail(Request $request)
+	{
+		$recentAttempt = EmailVerification::whereUserId(Auth::id())
+		->whereDate('created_at', '>', now()->subHours(12))->count();
+
+		if ($recentAttempt > 0) {
+			return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
+		} 
+
+		EmailVerification::whereUserId(Auth::id())->delete();
+
+		$user = User::whereNull('email_verified_at')->find(Auth::id());
+		$utoken = str_random(64);
+		$rtoken = str_random(128);
+
+		$verify = new EmailVerification();
+		$verify->user_id = $user->id;
+		$verify->email = $user->email;
+		$verify->user_token = $utoken;
+		$verify->random_token = $rtoken;
+		$verify->save();
+
+		Mail::to($user->email)->send(new ConfirmEmail($verify));
+
+		return redirect()->back()->with('status', 'Verification email sent!');
+	}
+
+	public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
+	{
+		$verify = EmailVerification::where('user_token', $userToken)
+		->where('created_at', '>', now()->subWeeks(2))
+		->where('random_token', $randomToken)
+		->firstOrFail();
+
+		if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) {
+			$user = User::find(Auth::id());
+			$user->email_verified_at = Carbon::now();
+			$user->save();
+
+			return redirect('/');
+		} else {
+			abort(403);
+		}
+	}
+
+	public function messages()
+	{
+		return view('account.messages');
+	}
+
+	public function direct()
+	{
+		return view('account.direct');
+	}
+
+	public function showMessage(Request $request, $id)
+	{
+		return view('account.message');
+	}
+
+	public function mute(Request $request)
+	{
+		$this->validate($request, [
+			'type' => 'required|alpha_dash',
+			'item' => 'required|integer|min:1',
+		]);
+
+		$user = Auth::user()->profile;
+		$type = $request->input('type');
+		$item = $request->input('item');
+		$action = $type . '.mute';
+
+		if (!in_array($action, $this->filters)) {
+			return abort(406);
+		}
+		$filterable = [];
+		switch ($type) {
+			case 'user':
+			$profile = Profile::findOrFail($item);
+			if ($profile->id == $user->id) {
+				return abort(403);
+			}
+			$class = get_class($profile);
+			$filterable['id'] = $profile->id;
+			$filterable['type'] = $class;
+			break;
+		}
+
+		$filter = UserFilter::firstOrCreate([
+			'user_id'         => $user->id,
+			'filterable_id'   => $filterable['id'],
+			'filterable_type' => $filterable['type'],
+			'filter_type'     => 'mute',
+		]);
+
+		$pid = $user->id;
+		Cache::forget("user:filter:list:$pid");
+		Cache::forget("feature:discover:posts:$pid");
+		Cache::forget("api:local:exp:rec:$pid");
+
+		return redirect()->back();
+	}
+
+	public function unmute(Request $request)
+	{
+		$this->validate($request, [
+			'type' => 'required|alpha_dash',
+			'item' => 'required|integer|min:1',
+		]);
+
+		$user = Auth::user()->profile;
+		$type = $request->input('type');
+		$item = $request->input('item');
+		$action = $type . '.mute';
+
+		if (!in_array($action, $this->filters)) {
+			return abort(406);
+		}
+		$filterable = [];
+		switch ($type) {
+			case 'user':
+			$profile = Profile::findOrFail($item);
+			if ($profile->id == $user->id) {
+				return abort(403);
+			}
+			$class = get_class($profile);
+			$filterable['id'] = $profile->id;
+			$filterable['type'] = $class;
+			break;
+
+			default:
+			abort(400);
+			break;
+		}
+
+		$filter = UserFilter::whereUserId($user->id)
+		->whereFilterableId($filterable['id'])
+		->whereFilterableType($filterable['type'])
+		->whereFilterType('mute')
+		->first();
+
+		if($filter) {
+			$filter->delete();
+		}
+
+		$pid = $user->id;
+		Cache::forget("user:filter:list:$pid");
+		Cache::forget("feature:discover:posts:$pid");
+		Cache::forget("api:local:exp:rec:$pid");
+
+		if($request->wantsJson()) {
+			return response()->json([200]);
+		} else {
+			return redirect()->back();
+		}
+	}
+
+	public function block(Request $request)
+	{
+		$this->validate($request, [
+			'type' => 'required|alpha_dash',
+			'item' => 'required|integer|min:1',
+		]);
+
+		$user = Auth::user()->profile;
+		$type = $request->input('type');
+		$item = $request->input('item');
+		$action = $type.'.block';
+		if (!in_array($action, $this->filters)) {
+			return abort(406);
+		}
+		$filterable = [];
+		switch ($type) {
+			case 'user':
+			$profile = Profile::findOrFail($item);
+			if ($profile->id == $user->id) {
+				return abort(403);
+			}
+			$class = get_class($profile);
+			$filterable['id'] = $profile->id;
+			$filterable['type'] = $class;
+
+			Follower::whereProfileId($profile->id)->whereFollowingId($user->id)->delete();
+			Notification::whereProfileId($user->id)->whereActorId($profile->id)->delete();
+			break;
+		}
+
+		$filter = UserFilter::firstOrCreate([
+			'user_id'         => $user->id,
+			'filterable_id'   => $filterable['id'],
+			'filterable_type' => $filterable['type'],
+			'filter_type'     => 'block',
+		]);
+
+		$pid = $user->id;
+		Cache::forget("user:filter:list:$pid");
+		Cache::forget("feature:discover:posts:$pid");
+		Cache::forget("api:local:exp:rec:$pid");
+
+		return redirect()->back();
+	}
+
+
+	public function unblock(Request $request)
+	{
+		$this->validate($request, [
+			'type' => 'required|alpha_dash',
+			'item' => 'required|integer|min:1',
+		]);
+
+		$user = Auth::user()->profile;
+		$type = $request->input('type');
+		$item = $request->input('item');
+		$action = $type . '.block';
+		if (!in_array($action, $this->filters)) {
+			return abort(406);
+		}
+		$filterable = [];
+		switch ($type) {
+			case 'user':
+			$profile = Profile::findOrFail($item);
+			if ($profile->id == $user->id) {
+				return abort(403);
+			}
+			$class = get_class($profile);
+			$filterable['id'] = $profile->id;
+			$filterable['type'] = $class;
+			break;
+
+			default:
+			abort(400);
+			break;
+		}
+
+
+		$filter = UserFilter::whereUserId($user->id)
+		->whereFilterableId($filterable['id'])
+		->whereFilterableType($filterable['type'])
+		->whereFilterType('block')
+		->first();
+
+		if($filter) {
+			$filter->delete();
+		}
+
+		$pid = $user->id;
+		Cache::forget("user:filter:list:$pid");
+		Cache::forget("feature:discover:posts:$pid");
+		Cache::forget("api:local:exp:rec:$pid");
+
+		return redirect()->back();
+	}
+
+	public function followRequests(Request $request)
+	{
+		$pid = Auth::user()->profile->id;
+		$followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10);
+		return view('account.follow-requests', compact('followers'));
+	}
+
+	public function followRequestHandle(Request $request)
+	{
+		$this->validate($request, [
+			'action' => 'required|string|max:10',
+			'id' => 'required|integer|min:1'
+		]);
+
+		$pid = Auth::user()->profile->id;
+		$action = $request->input('action') === 'accept' ? 'accept' : 'reject';
+		$id = $request->input('id');
+		$followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
+		$follower = $followRequest->follower;
+
+		switch ($action) {
+			case 'accept':
+			$follow = new Follower();
+			$follow->profile_id = $follower->id;
+			$follow->following_id = $pid;
+			$follow->save();
+			FollowPipeline::dispatch($follow);
+			$followRequest->delete();
+			break;
+
+			case 'reject':
+			$followRequest->is_rejected = true;
+			$followRequest->save();
+			break;
+		}
+
+		return response()->json(['msg' => 'success'], 200);
+	}
+
+	public function sudoMode(Request $request)
+	{
+		return view('auth.sudo');
+	}
+
+	public function sudoModeVerify(Request $request)
+	{
+		$this->validate($request, [
+			'password' => 'required|string|max:500'
+		]);
+		$user = Auth::user();
+		$password = $request->input('password');
+		$next = $request->session()->get('redirectNext', '/');
+		if(password_verify($password, $user->password) === true) {
+			$request->session()->put('sudoMode', time());
+			return redirect($next);
+		} else {
+			return redirect()
+			->back()
+			->withErrors(['password' => __('auth.failed')]);
+		}
+	}
+
+	public function twoFactorCheckpoint(Request $request)
+	{
+		return view('auth.checkpoint');
+	}
+
+	public function twoFactorVerify(Request $request)
+	{
+		$this->validate($request, [
+			'code'  => 'required|string|max:32'
+		]);
+		$user = Auth::user();
+		$code = $request->input('code');
+		$google2fa = new Google2FA();
+		$verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
+		if($verify) {
+			$request->session()->push('2fa.session.active', true);
+			return redirect('/');
+		} else {
+
+			if($this->twoFactorBackupCheck($request, $code, $user)) {
+				return redirect('/');
+			}
+
+			if($request->session()->has('2fa.attempts')) {
+				$count = (int) $request->session()->has('2fa.attempts');
+				$request->session()->push('2fa.attempts', $count + 1);
+			} else {
+				$request->session()->push('2fa.attempts', 1);
+			}
+			return redirect()->back()->withErrors([
+				'code' => 'Invalid code'
+			]);
+		}
+	}
+
+	protected function twoFactorBackupCheck($request, $code, User $user)
+	{
+		$backupCodes = $user->{'2fa_backup_codes'};
+		if($backupCodes) {
+			$codes = json_decode($backupCodes, true);
+			foreach ($codes as $c) {
+				if(hash_equals($c, $code)) {
+					$codes = array_flatten(array_diff($codes, [$code]));
+					$user->{'2fa_backup_codes'} = json_encode($codes);
+					$user->save();
+					$request->session()->push('2fa.session.active', true);
+					return true;
+				} else {
+					return false;
+				}
+			}
+		} else {
+			return false;
+		}  
+	}
+
+	public function accountRestored(Request $request)
+	{
+	}
 }
 }

+ 2 - 31
app/Http/Controllers/AdminController.php

@@ -105,10 +105,9 @@ class AdminController extends Controller
     {
     {
         $col = $request->query('col') ?? 'id';
         $col = $request->query('col') ?? 'id';
         $dir = $request->query('dir') ?? 'desc';
         $dir = $request->query('dir') ?? 'desc';
-        $stats = $this->collectUserStats($request);
-        $users = User::withCount('statuses')->orderBy($col, $dir)->simplePaginate(10);
+        $users = User::select('id', 'username', 'status')->withCount('statuses')->orderBy($col, $dir)->simplePaginate(10);
 
 
-        return view('admin.users.home', compact('users', 'stats'));
+        return view('admin.users.home', compact('users'));
     }
     }
 
 
     public function editUser(Request $request, $id)
     public function editUser(Request $request, $id)
@@ -158,34 +157,6 @@ class AdminController extends Controller
       return view('admin.reports.show', compact('report'));
       return view('admin.reports.show', compact('report'));
     }
     }
 
 
-    protected function collectUserStats($request)
-    { 
-      $total_duration = $request->query('total_duration') ?? '30';
-      $new_duration = $request->query('new_duration') ?? '7';
-      $stats = [];
-      $stats['total'] = [
-        'count' => User::where('created_at', '>', Carbon::now()->subDays($total_duration))->count(),
-        'points' => 0//User::selectRaw(''.$day.'created_at) day, count(*) as count')->where('created_at','>', Carbon::now()->subDays($total_duration))->groupBy('day')->pluck('count')
-      ];
-      $stats['new'] = [
-        'count' => User::where('created_at', '>', Carbon::now()->subDays($new_duration))->count(),
-        'points' => 0//User::selectRaw(''.$day.'created_at) day, count(*) as count')->where('created_at','>', Carbon::now()->subDays($new_duration))->groupBy('day')->pluck('count')
-      ];
-      $stats['active'] = [
-        'count' => Status::groupBy('profile_id')->count()
-      ];
-      $stats['profile'] = [
-        'local' => Profile::whereNull('remote_url')->count(),
-        'remote' => Profile::whereNotNull('remote_url')->count()
-      ];
-      $stats['avg'] = [
-        'likes' => floor(Like::average('profile_id')),
-        'posts' => floor(Status::avg('profile_id'))
-      ];
-      return $stats;
-
-    }
-
     public function profiles(Request $request)
     public function profiles(Request $request)
     {
     {
       $this->validate($request, [
       $this->validate($request, [

+ 118 - 0
app/Http/Controllers/Api/AdminApiController.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Jobs\StatusPipeline\StatusDelete;
+use Auth, Cache;
+use Carbon\Carbon;
+use App\{
+    Like,
+    Media,
+    Profile,
+    Status
+};
+
+use App\Services\NotificationService;
+
+class AdminApiController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware(['auth', 'admin']);
+    }
+
+    public function activity(Request $request)
+    {
+        $activity = [];
+        
+        $limit = request()->input('limit', 20);
+
+        $activity['captions'] = Status::select(
+            'id', 
+            'caption', 
+            'rendered', 
+            'uri', 
+            'profile_id',
+            'type',
+            'in_reply_to_id',
+            'reblog_of_id',
+            'is_nsfw',
+            'scope',
+            'created_at'
+        )->whereNull('in_reply_to_id')
+        ->whereNull('reblog_of_id')
+        ->orderByDesc('created_at')
+        ->paginate($limit);
+
+        $activity['comments'] = Status::select(
+            'id', 
+            'caption', 
+            'rendered', 
+            'uri', 
+            'profile_id',
+            'type',
+            'in_reply_to_id',
+            'reblog_of_id',
+            'is_nsfw',
+            'scope',
+            'created_at'
+        )->whereNotNull('in_reply_to_id')
+        ->whereNull('reblog_of_id')
+        ->orderByDesc('created_at')
+        ->paginate($limit);
+
+        return response()->json($activity, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function moderateStatus(Request $request)
+    {
+        abort(400, 'Unpublished API');
+        return;
+        $this->validate($request, [
+            'type' => 'required|string|in:status,profile',
+            'id'   => 'required|integer|min:1',
+            'action' => 'required|string|in:cw,unlink,unlist,suspend,delete'
+        ]);
+
+        $type = $request->input('type');
+        $id = $request->input('id');
+        $action = $request->input('action');
+
+        if ($type == 'status') {
+            $status = Status::findOrFail($id);
+            switch ($action) {
+                case 'cw':
+                    $status->is_nsfw = true;
+                    $status->save();
+                    break;
+                case 'unlink':
+                    $status->rendered = $status->caption;
+                    $status->save();
+                    break;
+                case 'unlist':
+                    $status->scope = 'unlisted';
+                    $status->visibility = 'unlisted';
+                    $status->save();
+                    break;
+                
+                default:
+                    break;
+            }
+        } else if ($type == 'profile') {
+            $profile = Profile::findOrFail($id);
+            switch ($action) {
+
+                case 'delete':
+                    StatusDelete::dispatch($status);
+                    break;
+                
+                default:
+                    break;
+            }
+        }
+
+    }
+
+}

+ 6 - 23
app/Http/Controllers/Api/BaseApiController.php

@@ -59,14 +59,11 @@ class BaseApiController extends Controller
             $res = $this->fractal->createData($resource)->toArray();
             $res = $this->fractal->createData($resource)->toArray();
         } else {
         } else {
             $this->validate($request, [
             $this->validate($request, [
-                'page' => 'nullable|integer|min:1',
+                'page' => 'nullable|integer|min:1|max:10',
                 'limit' => 'nullable|integer|min:1|max:10'
                 'limit' => 'nullable|integer|min:1|max:10'
             ]);
             ]);
             $limit = $request->input('limit') ?? 10;
             $limit = $request->input('limit') ?? 10;
             $page = $request->input('page') ?? 1;
             $page = $request->input('page') ?? 1;
-            if($page > 3) {
-                return response()->json([]);
-            }
             $end = (int) $page * $limit;
             $end = (int) $page * $limit;
             $start = (int) $end - $limit;
             $start = (int) $end - $limit;
             $res = NotificationService::get($pid, $start, $end);
             $res = NotificationService::get($pid, $start, $end);
@@ -121,7 +118,7 @@ class BaseApiController extends Controller
         $since_id = $request->since_id ?? false;
         $since_id = $request->since_id ?? false;
         $only_media = $request->only_media ?? false;
         $only_media = $request->only_media ?? false;
         $user = Auth::user();
         $user = Auth::user();
-        $account = Profile::findOrFail($id);
+        $account = Profile::whereNull('status')->findOrFail($id);
         $statuses = $account->statuses()->getQuery(); 
         $statuses = $account->statuses()->getQuery(); 
         if($only_media == true) {
         if($only_media == true) {
             $statuses = $statuses
             $statuses = $statuses
@@ -153,15 +150,6 @@ class BaseApiController extends Controller
         return response()->json($res);
         return response()->json($res);
     }
     }
 
 
-    public function followSuggestions(Request $request)
-    {
-        $followers = Auth::user()->profile->recommendFollowers();
-        $resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
-        $res = $this->fractal->createData($resource)->toArray();
-
-        return response()->json($res);
-    }
-
     public function avatarUpdate(Request $request)
     public function avatarUpdate(Request $request)
     {
     {
         $this->validate($request, [
         $this->validate($request, [
@@ -200,14 +188,9 @@ class BaseApiController extends Controller
 
 
     public function showTempMedia(Request $request, int $profileId, $mediaId)
     public function showTempMedia(Request $request, int $profileId, $mediaId)
     {
     {
-        if (!$request->hasValidSignature()) {
-            abort(401);
-        }
-        $profile = Auth::user()->profile;
-        if($profile->id !== $profileId) {
-            abort(403);
-        }
-        $media = Media::whereProfileId($profile->id)->findOrFail($mediaId);
+        abort_if(!$request->hasValidSignature(), 404); 
+        abort_if(Auth::user()->profile_id !== $profileId, 404); 
+        $media = Media::whereProfileId(Auth::user()->profile_id)->findOrFail($mediaId);
         $path = storage_path('app/'.$media->media_path);
         $path = storage_path('app/'.$media->media_path);
         return response()->file($path);
         return response()->file($path);
     }
     }
@@ -244,7 +227,7 @@ class BaseApiController extends Controller
         }
         }
 
 
         $monthHash = hash('sha1', date('Y').date('m'));
         $monthHash = hash('sha1', date('Y').date('m'));
-        $userHash = hash('sha1', $user->id.(string) $user->created_at);
+        $userHash = hash('sha1', $user->id . (string) $user->created_at);
 
 
         $photo = $request->file('file');
         $photo = $request->file('file');
 
 

+ 23 - 28
app/Http/Controllers/ApiController.php

@@ -6,10 +6,12 @@ use App\Http\Controllers\Api\BaseApiController;
 use App\{
 use App\{
     Follower,
     Follower,
     Like,
     Like,
+    Place,
     Profile,
     Profile,
     UserFilter
     UserFilter
 };
 };
 use Auth, Cache, Redis;
 use Auth, Cache, Redis;
+use App\Util\Site\Config;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use App\Services\SuggestionService;
 use App\Services\SuggestionService;
 
 
@@ -23,34 +25,7 @@ class ApiController extends BaseApiController
 
 
     public function siteConfiguration(Request $request)
     public function siteConfiguration(Request $request)
     {
     {
-        $res = Cache::remember('api:site:configuration', now()->addMinutes(30), function() {
-            return [
-                'uploader' => [
-                    'max_photo_size' => config('pixelfed.max_photo_size'),
-                    'max_caption_length' => config('pixelfed.max_caption_length'),
-                    'album_limit' => config('pixelfed.max_album_length'),
-                    'image_quality' => config('pixelfed.image_quality'),
-
-                    'optimize_image' => config('pixelfed.optimize_image'),
-                    'optimize_video' => config('pixelfed.optimize_video'),
-
-                    'media_types' => config('pixelfed.media_types'),
-                    'enforce_account_limit' => config('pixelfed.enforce_account_limit')
-                ],
-
-                'activitypub' => [
-                    'enabled' => config('federation.activitypub.enabled'),
-                    'remote_follow' => config('federation.activitypub.remoteFollow')
-                ],
-
-                'ab' => [
-                    'lc' => config('exp.lc'),
-                    'rec' => config('exp.rec'),
-                    'loops' => config('exp.loops')
-                ],
-            ];
-        });
-        return response()->json($res);
+        return response()->json(Config::get());
     }
     }
 
 
     public function userRecommendations(Request $request)
     public function userRecommendations(Request $request)
@@ -104,4 +79,24 @@ class ApiController extends BaseApiController
         return response()->json($res->all());
         return response()->json($res->all());
     }
     }
 
 
+    public function composeLocationSearch(Request $request)
+    {
+        $this->validate($request, [
+            'q' => 'required|string'
+        ]);
+
+        $places = Place::where('name', 'like', '%' . $request->input('q') . '%')
+            ->take(25)
+            ->get()
+            ->map(function($r) {
+                return [
+                    'id' => $r->id,
+                    'name' => $r->name,
+                    'country' => $r->country,
+                    'url'   => $r->url()
+                ];
+        });
+        return $places;
+    }
+
 }
 }

+ 21 - 7
app/Http/Controllers/Auth/RegisterController.php

@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use App\Services\EmailService;
 
 
 class RegisterController extends Controller
 class RegisterController extends Controller
 {
 {
@@ -53,6 +54,8 @@ class RegisterController extends Controller
     protected function validator(array $data)
     protected function validator(array $data)
     {
     {
         $this->validateUsername($data['username']);
         $this->validateUsername($data['username']);
+        $this->validateEmail($data['email']);
+
         $usernameRules = [
         $usernameRules = [
             'required',
             'required',
             'min:2',
             'min:2',
@@ -73,7 +76,7 @@ class RegisterController extends Controller
             'name'     => 'required|string|max:'.config('pixelfed.max_name_length'),
             'name'     => 'required|string|max:'.config('pixelfed.max_name_length'),
             'username' => $usernameRules,
             'username' => $usernameRules,
             'email'    => 'required|string|email|max:255|unique:users',
             'email'    => 'required|string|email|max:255|unique:users',
-            'password' => 'required|string|min:6|confirmed',
+            'password' => 'required|string|min:8|confirmed',
         ];
         ];
 
 
         return Validator::make($data, $rules);
         return Validator::make($data, $rules);
@@ -105,6 +108,14 @@ class RegisterController extends Controller
         }
         }
     }
     }
 
 
+    public function validateEmail($email)
+    {
+        $banned = EmailService::isBanned($email);
+        if($banned) {
+            return abort(403, 'Invalid email.');
+        }
+    }
+
     /**
     /**
      * Show the application registration form.
      * Show the application registration form.
      *
      *
@@ -112,14 +123,17 @@ class RegisterController extends Controller
      */
      */
     public function showRegistrationForm()
     public function showRegistrationForm()
     {
     {
-        $count = User::count();
-        $limit = config('pixelfed.max_users');
-        if($limit && $limit <= $count) {
-            $view = 'site.closed-registration';
+        if(config('pixelfed.open_registration')) {
+            $limit = config('pixelfed.max_users');
+            if($limit) {
+                abort_if($limit <= User::count(), 404);
+                return view('auth.register');
+            } else {
+                return view('auth.register');
+            }
         } else {
         } else {
-            $view = config('pixelfed.open_registration') == true ? 'auth.register' : 'site.closed-registration';
+            abort(404);
         }
         }
-        return view($view);
     }
     }
 
 
     /**
     /**

+ 16 - 1
app/Http/Controllers/AvatarController.php

@@ -73,7 +73,7 @@ class AvatarController extends Controller
 
 
     public function buildPath($id)
     public function buildPath($id)
     {
     {
-        $padded = str_pad($id, 12, 0, STR_PAD_LEFT);
+        $padded = str_pad($id, 19, 0, STR_PAD_LEFT);
         $parts = str_split($padded, 3);
         $parts = str_split($padded, 3);
         foreach ($parts as $k => $part) {
         foreach ($parts as $k => $part) {
             if ($k == 0) {
             if ($k == 0) {
@@ -93,6 +93,21 @@ class AvatarController extends Controller
                 $prefix = storage_path('app/'.$avatarpath);
                 $prefix = storage_path('app/'.$avatarpath);
                 $this->checkDir($prefix);
                 $this->checkDir($prefix);
             }
             }
+            if ($k == 4) {
+                $avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4];
+                $prefix = storage_path('app/'.$avatarpath);
+                $this->checkDir($prefix);
+            }
+            if ($k == 5) {
+                $avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5];
+                $prefix = storage_path('app/'.$avatarpath);
+                $this->checkDir($prefix);
+            }
+            if ($k == 6) {
+                $avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5].'/'.$parts[6];
+                $prefix = storage_path('app/'.$avatarpath);
+                $this->checkDir($prefix);
+            }
         }
         }
 
 
         return $avatarpath;
         return $avatarpath;

+ 199 - 1
app/Http/Controllers/CollectionController.php

@@ -3,8 +3,206 @@
 namespace App\Http\Controllers;
 namespace App\Http\Controllers;
 
 
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Auth;
+use App\{
+    Collection,
+    CollectionItem,
+    Profile,
+    Status
+};
+use League\Fractal;
+use App\Transformer\Api\{
+    AccountTransformer,
+    StatusTransformer,
+};
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
 
 
 class CollectionController extends Controller
 class CollectionController extends Controller
 {
 {
-    //
+    public function create(Request $request)
+    {
+        abort_if(!Auth::check(), 403);
+        $profile = Auth::user()->profile;
+
+        $collection = Collection::firstOrCreate([
+            'profile_id' => $profile->id,
+            'published_at' => null
+        ]);
+        return view('collection.create', compact('collection'));
+    }
+
+    public function show(Request $request, int $collection)
+    {
+        $collection = Collection::with('profile')->whereNotNull('published_at')->findOrFail($collection);
+        if($collection->profile->status != null) {
+            abort(404);
+        }
+        if($collection->visibility !== 'public') {
+            abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
+        }
+    	return view('collection.show', compact('collection'));
+    }
+
+    public function index(Request $request)
+    {
+        abort_if(!Auth::check(), 403);
+    	return $request->all();
+    }
+
+    public function store(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $this->validate($request, [
+            'title'         => 'nullable',
+            'description'   => 'nullable',
+            'visibility'    => 'required|alpha|in:public,private'
+        ]);
+
+        $profile = Auth::user()->profile;   
+        $collection = Collection::whereProfileId($profile->id)->findOrFail($id);
+        $collection->title = e($request->input('title'));
+        $collection->description = e($request->input('description'));
+        $collection->visibility = e($request->input('visibility'));
+        $collection->save();
+
+        return 200;
+    }
+
+    public function publish(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $this->validate($request, [
+            'title'         => 'nullable',
+            'description'   => 'nullable',
+            'visibility'    => 'required|alpha|in:public,private'
+        ]);
+        $profile = Auth::user()->profile;   
+        $collection = Collection::whereProfileId($profile->id)->findOrFail($id);
+        if($collection->items()->count() == 0) {
+            abort(404);
+        }
+        $collection->title = e($request->input('title'));
+        $collection->description = e($request->input('description'));
+        $collection->visibility = e($request->input('visibility'));
+        $collection->published_at = now();
+        $collection->save();
+
+        return $collection->url();
+    }
+
+    public function delete(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $user = Auth::user();
+
+        $collection = Collection::whereProfileId($user->profile_id)->findOrFail($id);
+        $collection->items()->delete();
+        $collection->delete();
+
+        if($request->wantsJson()) {
+            return 200;
+        }
+
+        return redirect('/');
+    }
+
+    public function storeId(Request $request)
+    {
+        $this->validate($request, [
+            'collection_id' => 'required|int|min:1|exists:collections,id',
+            'post_id'       => 'required|int|min:1|exists:statuses,id'
+        ]);
+        
+        $profileId = Auth::user()->profile_id;
+        $collectionId = $request->input('collection_id');
+        $postId = $request->input('post_id');
+
+        $collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
+        $count = $collection->items()->count();
+
+        if($count >= 18) {
+            abort(400, 'You can only add 18 posts per collection');
+        }
+
+        $status = Status::whereScope('public')
+            ->whereIn('type', ['photo', 'photo:album', 'video'])
+            ->findOrFail($postId);
+
+        $item = CollectionItem::firstOrCreate([
+            'collection_id' => $collection->id,
+            'object_type'   => 'App\Status',
+            'object_id'     => $status->id
+        ],[
+            'order'         => $count,
+        ]);
+
+        return 200;
+    }
+
+    public function get(Request $request, int $id)
+    {
+        $profile = Auth::check() ? Auth::user()->profile : [];
+
+        $collection = Collection::whereVisibility('public')->findOrFail($id);
+        if($collection->published_at == null) {
+            if(!Auth::check() || $profile->id !== $collection->profile_id) {
+                abort(404);
+            }
+        }
+
+        return [
+            'id'            => $collection->id,
+            'title'         => $collection->title,
+            'description'   => $collection->description,
+            'visibility'    => $collection->visibility
+        ];
+    }
+
+    public function getItems(Request $request, int $id)
+    {
+        $collection = Collection::findOrFail($id);
+        if($collection->visibility !== 'public') {
+            abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
+        }
+        $posts = $collection->posts()->orderBy('order', 'asc')->paginate(18);
+
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Collection($posts, new StatusTransformer());
+        $res = $fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function getUserCollections(Request $request, int $id)
+    {
+        $profile = Profile::whereNull('status')
+            ->whereNull('domain')
+            ->findOrFail($id);
+
+        if($profile->is_private) {
+            abort_if(!Auth::check(), 404);
+            abort_if(!$profile->followedBy(Auth::user()->profile) && $profile->id != Auth::user()->profile_id, 404);
+        }
+
+        return $profile
+            ->collections()
+            ->has('posts')
+            ->with('posts')
+            ->whereVisibility('public')
+            ->whereNotNull('published_at')
+            ->orderByDesc('published_at')
+            ->paginate(9)
+            ->map(function($collection) {
+                return [
+                    'id' => $collection->id,
+                    'title' => $collection->title,
+                    'description' => $collection->description,
+                    'thumb' => $collection->posts()->first()->thumb(),
+                    'url' => $collection->url(),
+                    'published_at' => $collection->published_at
+                ];
+        });
+    }
 }
 }

+ 11 - 0
app/Http/Controllers/CommentController.php

@@ -13,6 +13,7 @@ use App\Jobs\StatusPipeline\NewStatusPipeline;
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\Autolink;
 use App\Profile;
 use App\Profile;
 use App\Status;
 use App\Status;
+use App\UserFilter;
 use League\Fractal;
 use League\Fractal;
 use App\Transformer\Api\StatusTransformer;
 use App\Transformer\Api\StatusTransformer;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Serializer\ArraySerializer;
@@ -57,6 +58,16 @@ class CommentController extends Controller
             return;
             return;
         }
         }
 
 
+        $filtered = UserFilter::whereUserId($status->profile_id)
+            ->whereFilterableType('App\Profile')
+            ->whereIn('filter_type', ['block'])
+            ->whereFilterableId($profile->id)
+            ->exists();
+
+        if($filtered == true) {
+            return;
+        }
+
         $reply = DB::transaction(function() use($comment, $status, $profile) {
         $reply = DB::transaction(function() use($comment, $status, $profile) {
             $autolink = Autolink::create()->autolink($comment);
             $autolink = Autolink::create()->autolink($comment);
             $reply = new Status();
             $reply = new Status();

+ 28 - 51
app/Http/Controllers/DiscoverController.php

@@ -6,6 +6,7 @@ use App\{
   DiscoverCategory,
   DiscoverCategory,
   Follower,
   Follower,
   Hashtag,
   Hashtag,
+  HashtagFollow,
   Profile,
   Profile,
   Status, 
   Status, 
   StatusHashtag, 
   StatusHashtag, 
@@ -17,6 +18,7 @@ use App\Transformer\Api\StatusStatelessTransformer;
 use League\Fractal;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Services\StatusHashtagService;
 
 
 class DiscoverController extends Controller
 class DiscoverController extends Controller
 {
 {
@@ -36,57 +38,11 @@ class DiscoverController extends Controller
 
 
     public function showTags(Request $request, $hashtag)
     public function showTags(Request $request, $hashtag)
     {
     {
-        abort_if(!Auth::check(), 403);
-
-        $tag = Hashtag::whereSlug($hashtag)
-          ->firstOrFail();
-
-        $page = 1;
-        $key = 'discover:tag-'.$tag->id.':page-'.$page;
-        $keyMinutes = 15;
-
-        $posts = Cache::remember($key, now()->addMinutes($keyMinutes), function() use ($tag, $request) {
-          $tags = StatusHashtag::select('status_id')
-            ->whereHashtagId($tag->id)
-            ->orderByDesc('id')
-            ->take(48)
-            ->pluck('status_id');
-
-          return Status::select(
-            'id', 
-            'uri',
-            'caption',
-            'rendered',
-            'profile_id', 
-            'type',
-            'in_reply_to_id',
-            'reblog_of_id',
-            'is_nsfw',
-            'scope',
-            'local',
-            'created_at',
-            'updated_at'
-          )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
-          ->with('media')
-          ->whereLocal(true)
-          ->whereNull('uri')
-          ->whereIn('id', $tags)
-          ->whereNull('in_reply_to_id')
-          ->whereNull('reblog_of_id')
-          ->whereNull('url')
-          ->whereNull('uri')
-          ->withCount(['likes', 'comments'])
-          ->whereIsNsfw(false)
-          ->whereVisibility('public')
-          ->orderBy('id', 'desc')
-          ->get();
-        });
+        abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
 
 
-        if($posts->count() == 0) {
-          abort(404);
-        }
-        
-        return view('discover.tags.show', compact('tag', 'posts'));
+        $tag = Hashtag::whereSlug($hashtag)->firstOrFail();
+        $tagCount = StatusHashtagService::count($tag->id);
+        return view('discover.tags.show', compact('tag', 'tagCount'));
     }
     }
 
 
     public function showCategory(Request $request, $slug)
     public function showCategory(Request $request, $slug)
@@ -156,7 +112,6 @@ class DiscoverController extends Controller
         return $res;
         return $res;
     }
     }
 
 
-
     public function loopWatch(Request $request)
     public function loopWatch(Request $request)
     {
     {
         abort_if(!Auth::check(), 403);
         abort_if(!Auth::check(), 403);
@@ -171,4 +126,26 @@ class DiscoverController extends Controller
 
 
         return response()->json(200);
         return response()->json(200);
     }
     }
+
+    public function getHashtags(Request $request)
+    {
+      $auth = Auth::check();
+      abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
+
+      $this->validate($request, [
+        'hashtag' => 'required|alphanum|min:2|max:124',
+        'page' => 'nullable|integer|min:1|max:' . ($auth ? 19 : 3)
+      ]);
+
+      $page = $request->input('page') ?? '1';
+      $end = $page > 1 ? $page * 9 : 0;
+      $tag = $request->input('hashtag');
+
+      $hashtag = Hashtag::whereName($tag)->firstOrFail();
+      $res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end);
+      if($page == 1) {
+        $res['follows'] = HashtagFollow::whereUserId(Auth::id())->whereHashtagId($hashtag->id)->exists();
+      }
+      return $res;
+    }
 }
 }

+ 33 - 59
app/Http/Controllers/FederationController.php

@@ -11,7 +11,8 @@ use App\{
     AccountLog,
     AccountLog,
     Like,
     Like,
     Profile,
     Profile,
-    Status
+    Status,
+    User
 };
 };
 use App\Transformer\ActivityPub\ProfileOutbox;
 use App\Transformer\ActivityPub\ProfileOutbox;
 use App\Util\Lexer\Nickname;
 use App\Util\Lexer\Nickname;
@@ -34,47 +35,22 @@ class FederationController extends Controller
         abort_if(!Auth::check(), 403);
         abort_if(!Auth::check(), 403);
     }
     }
 
 
+    // deprecated, remove in 0.10
     public function authorizeFollow(Request $request)
     public function authorizeFollow(Request $request)
     {
     {
-        $this->authCheck();
-        $this->validate($request, [
-            'acct' => 'required|string|min:3|max:255',
-        ]);
-        $acct = $request->input('acct');
-        $nickname = Nickname::normalizeProfileUrl($acct);
-
-        return view('federation.authorizefollow', compact('acct', 'nickname'));
+        abort(404);
     }
     }
 
 
+    // deprecated, remove in 0.10
     public function remoteFollow()
     public function remoteFollow()
     {
     {
-        $this->authCheck();
-
-        return view('federation.remotefollow');
+        abort(404);
     }
     }
 
 
+    // deprecated, remove in 0.10
     public function remoteFollowStore(Request $request)
     public function remoteFollowStore(Request $request)
     {
     {
-        return;
-
-        $this->authCheck();
-        $this->validate($request, [
-            'url' => 'required|string',
-        ]);
-
-        abort_if(!config('federation.activitypub.remoteFollow'), 403);
-
-        $follower = Auth::user()->profile;
-        $url = $request->input('url');
-        $url = Helpers::validateUrl($url);
-
-        if(!$url) {
-            return;
-        }
-
-        RemoteFollowPipeline::dispatch($follower, $url);
-
-        return response(['success' => true, 'follower' => $follower]);
+        abort(404);
     }
     }
 
 
     public function nodeinfoWellKnown()
     public function nodeinfoWellKnown()
@@ -100,8 +76,8 @@ class FederationController extends Controller
         $res = Cache::remember('api:nodeinfo', now()->addMinutes(15), function () {
         $res = Cache::remember('api:nodeinfo', now()->addMinutes(15), function () {
             $activeHalfYear = Cache::remember('api:nodeinfo:ahy', now()->addHours(12), function() {
             $activeHalfYear = Cache::remember('api:nodeinfo:ahy', now()->addHours(12), function() {
                 $count = collect([]);
                 $count = collect([]);
-                // $likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
-                // $count = $count->merge($likes);
+                $likes = Like::select('profile_id')->with('actor')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->get()->filter(function($like) {return $like->actor && $like->actor->domain == null;})->pluck('profile_id')->toArray();
+                $count = $count->merge($likes);
                 $statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
                 $statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
                 $count = $count->merge($statuses);
                 $count = $count->merge($statuses);
                 $profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
                 $profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
@@ -110,8 +86,8 @@ class FederationController extends Controller
             });
             });
             $activeMonth = Cache::remember('api:nodeinfo:am', now()->addHours(12), function() {
             $activeMonth = Cache::remember('api:nodeinfo:am', now()->addHours(12), function() {
                 $count = collect([]);
                 $count = collect([]);
-                // $likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
-                // $count = $count->merge($likes);
+                $likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->get()->filter(function($like) {return $like->actor && $like->actor->domain == null;})->pluck('profile_id')->toArray();
+                $count = $count->merge($likes);
                 $statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
                 $statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
                 $count = $count->merge($statuses);
                 $count = $count->merge($statuses);
                 $profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
                 $profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
@@ -120,11 +96,12 @@ class FederationController extends Controller
             });
             });
             return [
             return [
                 'metadata' => [
                 'metadata' => [
-                    'nodeName' => config('app.name'),
+                    'nodeName' => config('pixelfed.domain.app'),
                     'software' => [
                     'software' => [
                         'homepage'  => 'https://pixelfed.org',
                         'homepage'  => 'https://pixelfed.org',
                         'repo'      => 'https://github.com/pixelfed/pixelfed',
                         'repo'      => 'https://github.com/pixelfed/pixelfed',
                     ],
                     ],
+                    'config' => \App\Util\Site\Config::get()
                 ],
                 ],
                 'protocols'         => [
                 'protocols'         => [
                     'activitypub',
                     'activitypub',
@@ -138,12 +115,12 @@ class FederationController extends Controller
                     'version'       => config('pixelfed.version'),
                     'version'       => config('pixelfed.version'),
                 ],
                 ],
                 'usage' => [
                 'usage' => [
-                    'localPosts'    => \App\Status::whereLocal(true)->whereHas('media')->count(),
-                    'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
+                    'localPosts'    => Status::whereLocal(true)->count(),
+                    'localComments' => 0,
                     'users'         => [
                     'users'         => [
-                        'total'          => \App\Profile::whereNull('status')->whereNull('domain')->count(),
-                        'activeHalfyear' => $activeHalfYear,
-                        'activeMonth'    => $activeMonth,
+                        'total'          => User::count(),
+                        'activeHalfyear' => (int) $activeHalfYear,
+                        'activeMonth'    => (int) $activeMonth,
                     ],
                     ],
                 ],
                 ],
                 'version' => '2.0',
                 'version' => '2.0',
@@ -162,10 +139,12 @@ class FederationController extends Controller
         $this->validate($request, ['resource'=>'required|string|min:3|max:255']);
         $this->validate($request, ['resource'=>'required|string|min:3|max:255']);
 
 
         $resource = $request->input('resource');
         $resource = $request->input('resource');
-        $hash = hash('sha256', $resource);
         $parsed = Nickname::normalizeProfileUrl($resource);
         $parsed = Nickname::normalizeProfileUrl($resource);
+        if($parsed['domain'] !== config('pixelfed.domain.app')) {
+            abort(404);
+        }
         $username = $parsed['username'];
         $username = $parsed['username'];
-        $profile = Profile::whereUsername($username)->firstOrFail();
+        $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
         if($profile->status != null) {
         if($profile->status != null) {
             return ProfileController::accountCheck($profile);
             return ProfileController::accountCheck($profile);
         }
         }
@@ -179,7 +158,7 @@ class FederationController extends Controller
         abort_if(!config('federation.webfinger.enabled'), 404);
         abort_if(!config('federation.webfinger.enabled'), 404);
 
 
         $path = route('well-known.webfinger');
         $path = route('well-known.webfinger');
-        $xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="{$path}?resource={uri}"/></XRD>';
+        $xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>';
 
 
         return response($xml)->header('Content-Type', 'application/xrd+xml');
         return response($xml)->header('Content-Type', 'application/xrd+xml');
     }
     }
@@ -315,19 +294,16 @@ class FederationController extends Controller
             ->whereIsPrivate(false)
             ->whereIsPrivate(false)
             ->firstOrFail();
             ->firstOrFail();
             
             
-        return [];
-
         if($profile->status != null) {
         if($profile->status != null) {
-            return [];
+            abort(404);
         }
         }
+
         $obj = [
         $obj = [
             '@context' => 'https://www.w3.org/ns/activitystreams',
             '@context' => 'https://www.w3.org/ns/activitystreams',
             'id'       => $request->getUri(),
             'id'       => $request->getUri(),
             'type'     => 'OrderedCollectionPage',
             'type'     => 'OrderedCollectionPage',
-            'totalItems' => $profile->following()->count(),
-            'orderedItems' => $profile->following->map(function($f) {
-                return $f->permalink();
-            })
+            'totalItems' => 0,
+            'orderedItems' => []
         ];
         ];
         return response()->json($obj); 
         return response()->json($obj); 
     }
     }
@@ -341,20 +317,18 @@ class FederationController extends Controller
             ->whereIsPrivate(false)
             ->whereIsPrivate(false)
             ->firstOrFail();
             ->firstOrFail();
 
 
-        return [];
-
         if($profile->status != null) {
         if($profile->status != null) {
-            return [];
+            abort(404);
         }
         }
+
         $obj = [
         $obj = [
             '@context' => 'https://www.w3.org/ns/activitystreams',
             '@context' => 'https://www.w3.org/ns/activitystreams',
             'id'       => $request->getUri(),
             'id'       => $request->getUri(),
             'type'     => 'OrderedCollectionPage',
             'type'     => 'OrderedCollectionPage',
-            'totalItems' => $profile->followers()->count(),
-            'orderedItems' => $profile->followers->map(function($f) {
-                return $f->permalink();
-            })
+            'totalItems' => 0,
+            'orderedItems' => []
         ];
         ];
+
         return response()->json($obj); 
         return response()->json($obj); 
     }
     }
 }
 }

+ 18 - 12
app/Http/Controllers/FollowerController.php

@@ -23,16 +23,11 @@ class FollowerController extends Controller
     public function store(Request $request)
     public function store(Request $request)
     {
     {
         $this->validate($request, [
         $this->validate($request, [
-            'item'    => 'required|integer',
+            'item'    => 'required|string',
         ]);
         ]);
-        $item = $request->input('item');
+        $item = (int) $request->input('item');
         $this->handleFollowRequest($item);
         $this->handleFollowRequest($item);
-        if($request->wantsJson()) {
-            return response()->json([
-                200
-            ], 200);
-        }
-        return redirect()->back();
+        return response()->json(200);
     }
     }
 
 
     protected function handleFollowRequest($item)
     protected function handleFollowRequest($item)
@@ -53,7 +48,7 @@ class FollowerController extends Controller
             abort(400, 'You cannot follow this user.');
             abort(400, 'You cannot follow this user.');
         }
         }
 
 
-        $isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
+        $isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
 
 
         if($private == true && $isFollowing == 0 || $remote == true) {
         if($private == true && $isFollowing == 0 || $remote == true) {
             if($user->following()->count() >= Follower::MAX_FOLLOWING) {
             if($user->following()->count() >= Follower::MAX_FOLLOWING) {
@@ -83,13 +78,23 @@ class FollowerController extends Controller
             $follower->profile_id = $user->id;
             $follower->profile_id = $user->id;
             $follower->following_id = $target->id;
             $follower->following_id = $target->id;
             $follower->save();
             $follower->save();
+
+            if($remote == true && config('federation.activitypub.remoteFollow') == true) {
+                $this->sendFollow($user, $target);
+            } 
             FollowPipeline::dispatch($follower);
             FollowPipeline::dispatch($follower);
         } else {
         } else {
-            $follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->firstOrFail();
-            if($remote == true) {
+            $request = FollowRequest::whereFollowerId($user->id)->whereFollowingId($target->id)->exists();
+            $follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
+            if($remote == true && $request && !$follower) {
+                $this->sendFollow($user, $target);
+            }
+            if($remote == true && $follower) {
                 $this->sendUndoFollow($user, $target);
                 $this->sendUndoFollow($user, $target);
             }
             }
-            $follower->delete();
+            Follower::whereProfileId($user->id)
+                ->whereFollowingId($target->id)
+                ->delete();
         }
         }
 
 
         Cache::forget('profile:following:'.$target->id);
         Cache::forget('profile:following:'.$target->id);
@@ -109,6 +114,7 @@ class FollowerController extends Controller
 
 
         $payload = [
         $payload = [
             '@context'  => 'https://www.w3.org/ns/activitystreams',
             '@context'  => 'https://www.w3.org/ns/activitystreams',
+            'id'        => $user->permalink('#follow/'.$target->id.''),
             'type'      => 'Follow',
             'type'      => 'Follow',
             'actor'     => $user->permalink(),
             'actor'     => $user->permalink(),
             'object'    => $target->permalink()
             'object'    => $target->permalink()

+ 61 - 0
app/Http/Controllers/HashtagFollowController.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Auth;
+use App\{
+	Hashtag,
+	HashtagFollow,
+	Status
+};
+
+class HashtagFollowController extends Controller
+{
+    public function __construct()
+    {
+    	$this->middleware('auth');
+    }
+
+    public function store(Request $request)
+    {
+    	$this->validate($request, [
+    		'name' => 'required|alpha_num|min:1|max:124|exists:hashtags,name'
+    	]);
+
+    	$user = Auth::user();
+    	$profile = $user->profile;
+    	$tag = $request->input('name');
+
+    	$hashtag = Hashtag::whereName($tag)->firstOrFail();
+
+        $hashtagFollow = HashtagFollow::firstOrCreate([
+            'user_id' => $user->id,
+            'profile_id' => $user->profile_id ?? $user->profile->id,
+            'hashtag_id' => $hashtag->id
+        ]);
+
+        if($hashtagFollow->wasRecentlyCreated) {
+            $state = 'created';
+            // todo: send to HashtagFollowService
+        } else {
+            $state = 'deleted';
+            $hashtagFollow->delete();
+        }
+
+        return [
+            'state' => $state
+        ];
+    }
+
+    public function getTags(Request $request)
+    {
+        return HashtagFollow::with('hashtag')->whereUserId(Auth::id())
+            ->inRandomOrder()
+            ->take(3)
+            ->get()
+            ->map(function($follow, $k) {
+                  return $follow->hashtag->name;
+            });
+    }
+}

+ 21 - 47
app/Http/Controllers/InternalApiController.php

@@ -50,38 +50,7 @@ class InternalApiController extends Controller
     // deprecated
     // deprecated
     public function discover(Request $request)
     public function discover(Request $request)
     {
     {
-        $profile = Auth::user()->profile;
-        $pid = $profile->id;
-        $following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(60), function() use ($pid) {
-            return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
-        });
-        $filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(60), function() use($pid) {
-            return UserFilter::whereUserId($pid)
-            ->whereFilterableType('App\Profile')
-            ->whereIn('filter_type', ['mute', 'block'])
-            ->pluck('filterable_id')->toArray();
-        });
-        $following = array_merge($following, $filters);
-
-        $posts = Status::select('id', 'caption', 'profile_id')
-              ->whereHas('media')
-              ->whereIsNsfw(false)
-              ->whereVisibility('public')
-              ->whereNotIn('profile_id', $following)
-              ->with('media')
-              ->orderBy('created_at', 'desc')
-              ->take(21)
-              ->get();
-
-        $res = [
-            'posts' => $posts->map(function($post) {
-                return [
-                    'url' => $post->url(),
-                    'thumb' => $post->thumb(),
-                ];
-            })
-        ];
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+        return;
     }
     }
 
 
     public function discoverPosts(Request $request)
     public function discoverPosts(Request $request)
@@ -155,22 +124,9 @@ class InternalApiController extends Controller
         return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
         return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
     }
     }
 
 
-    public function notificationMarkAllRead(Request $request)
-    {
-        $profile = Auth::user()->profile;
-
-        $notifications = Notification::whereProfileId($profile->id)->get();
-        foreach($notifications as $n) {
-            $n->read_at = Carbon::now();
-            $n->save();
-        }
-
-        return;
-    }
-
     public function statusReplies(Request $request, int $id)
     public function statusReplies(Request $request, int $id)
     {
     {
-        $parent = Status::findOrFail($id);
+        $parent = Status::whereScope('public')->findOrFail($id);
 
 
         $children = Status::whereInReplyToId($parent->id)
         $children = Status::whereInReplyToId($parent->id)
             ->orderBy('created_at', 'desc')
             ->orderBy('created_at', 'desc')
@@ -283,7 +239,8 @@ class InternalApiController extends Controller
             'media.*.filter_class' => 'nullable|alpha_dash|max:30',
             'media.*.filter_class' => 'nullable|alpha_dash|max:30',
             'media.*.license' => 'nullable|string|max:80',
             'media.*.license' => 'nullable|string|max:80',
             'cw' => 'nullable|boolean',
             'cw' => 'nullable|boolean',
-            'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10'
+            'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
+            'place' => 'nullable'
         ]);
         ]);
 
 
         if(config('costar.enabled') == true) {
         if(config('costar.enabled') == true) {
@@ -327,6 +284,9 @@ class InternalApiController extends Controller
             array_push($mimes, $m->mime);
             array_push($mimes, $m->mime);
         }
         }
 
 
+        if($request->filled('place')) {
+            $status->place_id = $request->input('place')['id'];
+        }
         $status->caption = strip_tags($request->caption);
         $status->caption = strip_tags($request->caption);
         $status->scope = 'draft';
         $status->scope = 'draft';
         $status->profile_id = $profile->id;
         $status->profile_id = $profile->id;
@@ -350,4 +310,18 @@ class InternalApiController extends Controller
         Cache::forget('profile:status_count:'.$profile->id);
         Cache::forget('profile:status_count:'.$profile->id);
         return $status->url();
         return $status->url();
     }
     }
+
+    public function bookmarks(Request $request)
+    {
+        $statuses = Auth::user()->profile
+            ->bookmarks()
+            ->withCount(['likes','comments'])
+            ->orderBy('created_at', 'desc')
+            ->simplePaginate(10);
+
+        $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
 }
 }

+ 23 - 0
app/Http/Controllers/PlaceController.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\{
+	Place,
+	Status
+};
+
+class PlaceController extends Controller
+{
+    public function show(Request $request, $id, $slug)
+    {
+        // TODO: Replace with vue component + apis
+    	$place = Place::whereSlug($slug)->findOrFail($id);
+    	$posts = Status::wherePlaceId($place->id)
+    		->whereScope('public')
+    		->orderByDesc('created_at')
+    		->paginate(10);
+    	return view('discover.places.show', compact('place', 'posts'));
+    }
+}

+ 9 - 17
app/Http/Controllers/ProfileController.php

@@ -6,6 +6,7 @@ use Illuminate\Http\Request;
 use Auth;
 use Auth;
 use Cache;
 use Cache;
 use App\Follower;
 use App\Follower;
+use App\FollowRequest;
 use App\Profile;
 use App\Profile;
 use App\User;
 use App\User;
 use App\UserFilter;
 use App\UserFilter;
@@ -67,8 +68,12 @@ class ProfileController extends Controller
         $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
         $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
 
 
         if ($isPrivate == true || $isBlocked == true) {
         if ($isPrivate == true || $isBlocked == true) {
-            return view('profile.private', compact('user', 'is_following'));
+            $requested = Auth::check() ? FollowRequest::whereFollowerId(Auth::user()->profile_id)
+                ->whereFollowingId($user->id)
+                ->exists() : false;
+            return view('profile.private', compact('user', 'is_following', 'requested'));
         } 
         } 
+
         $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
         $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
         $profile = $user;
         $profile = $user;
         $settings = [
         $settings = [
@@ -241,22 +246,9 @@ class ProfileController extends Controller
         return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following', 'is_admin', 'settings'));
         return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following', 'is_admin', 'settings'));
     }
     }
 
 
-    public function savedBookmarks(Request $request, $username)
+    public function meRedirect()
     {
     {
-        if (Auth::check() === false || $username !== Auth::user()->username) {
-            abort(403);
-        }
-        $user = $profile = Auth::user()->profile;
-        if($profile->status != null) {
-            return $this->accountCheck($profile);
-        }
-        $settings = User::whereUsername($username)->firstOrFail()->settings;
-        $owner = true;
-        $following = false;
-        $timeline = $user->bookmarks()->withCount(['likes','comments'])->orderBy('created_at', 'desc')->simplePaginate(10);
-        $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
-        $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
-        return view('profile.bookmarks', compact('user', 'profile', 'settings', 'owner', 'following', 'timeline', 'is_following', 'is_admin'));
+        abort_if(!Auth::check(), 404);
+        return redirect(Auth::user()->url());
     }
     }
-
 }
 }

+ 17 - 0
app/Http/Controllers/ProfileSponsorController.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\ProfileSponsor;
+use Auth;
+
+class ProfileSponsorController extends Controller
+{
+	public function get(Request $request, $id)
+	{
+		$profile = ProfileSponsor::whereProfileId($id)->first();
+		$res = $profile ? $profile->sponsors : [];
+		return response()->json($res);
+	}
+}

+ 33 - 3
app/Http/Controllers/PublicApiController.php

@@ -113,10 +113,22 @@ class PublicApiController extends Controller
         $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
         $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
         $status = Status::whereProfileId($profile->id)->whereCommentsDisabled(false)->findOrFail($postId);
         $status = Status::whereProfileId($profile->id)->whereCommentsDisabled(false)->findOrFail($postId);
         $this->scopeCheck($profile, $status);
         $this->scopeCheck($profile, $status);
+
+        if(Auth::check()) {
+            $pid = Auth::user()->profile->id;
+            $filtered = UserFilter::whereUserId($pid)
+            ->whereFilterableType('App\Profile')
+            ->whereIn('filter_type', ['mute', 'block'])
+            ->pluck('filterable_id')->toArray();
+        } else {
+            $filtered = [];
+        }
+
         if($request->filled('min_id') || $request->filled('max_id')) {
         if($request->filled('min_id') || $request->filled('max_id')) {
             if($request->filled('min_id')) {
             if($request->filled('min_id')) {
                 $replies = $status->comments()
                 $replies = $status->comments()
                 ->whereNull('reblog_of_id')
                 ->whereNull('reblog_of_id')
+                ->whereNotIn('profile_id', $filtered)
                 ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->where('id', '>=', $request->min_id)
                 ->where('id', '>=', $request->min_id)
                 ->orderBy('id', 'desc')
                 ->orderBy('id', 'desc')
@@ -125,6 +137,7 @@ class PublicApiController extends Controller
             if($request->filled('max_id')) {
             if($request->filled('max_id')) {
                 $replies = $status->comments()
                 $replies = $status->comments()
                 ->whereNull('reblog_of_id')
                 ->whereNull('reblog_of_id')
+                ->whereNotIn('profile_id', $filtered)
                 ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->where('id', '<=', $request->max_id)
                 ->where('id', '<=', $request->max_id)
                 ->orderBy('id', 'desc')
                 ->orderBy('id', 'desc')
@@ -133,6 +146,7 @@ class PublicApiController extends Controller
         } else {
         } else {
             $replies = $status->comments()
             $replies = $status->comments()
             ->whereNull('reblog_of_id')
             ->whereNull('reblog_of_id')
+            ->whereNotIn('profile_id', $filtered)
             ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
             ->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
             ->orderBy('id', 'desc')
             ->orderBy('id', 'desc')
             ->paginate($limit);
             ->paginate($limit);
@@ -211,6 +225,10 @@ class PublicApiController extends Controller
           'limit'       => 'nullable|integer|max:20'
           'limit'       => 'nullable|integer|max:20'
         ]);
         ]);
 
 
+        if(config('instance.timeline.local.is_public') == false && !Auth::check()) {
+            abort(403, 'Authentication required.');
+        }
+
         $page = $request->input('page');
         $page = $request->input('page');
         $min = $request->input('min_id');
         $min = $request->input('min_id');
         $max = $request->input('max_id');
         $max = $request->input('max_id');
@@ -251,9 +269,11 @@ class PublicApiController extends Controller
                         'local',
                         'local',
                         'reply_count',
                         'reply_count',
                         'comments_disabled',
                         'comments_disabled',
+                        'place_id',
                         'created_at',
                         'created_at',
                         'updated_at'
                         'updated_at'
                       )->where('id', $dir, $id)
                       )->where('id', $dir, $id)
+                      ->with('profile', 'hashtags', 'mentions')
                       ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       ->whereLocal(true)
                       ->whereLocal(true)
                       ->whereNull('uri')
                       ->whereNull('uri')
@@ -280,8 +300,10 @@ class PublicApiController extends Controller
                         'reply_count',
                         'reply_count',
                         'comments_disabled',
                         'comments_disabled',
                         'created_at',
                         'created_at',
+                        'place_id',
                         'updated_at'
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
+                      ->with('profile', 'hashtags', 'mentions')
                       ->whereLocal(true)
                       ->whereLocal(true)
                       ->whereNull('uri')
                       ->whereNull('uri')
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNotIn('profile_id', $filtered)
@@ -325,12 +347,14 @@ class PublicApiController extends Controller
             return $following->push($pid)->toArray();
             return $following->push($pid)->toArray();
         });
         });
 
 
-        $private = Cache::remember('profiles:private', 1440, function() {
+        $private = Cache::remember('profiles:private', now()->addMinutes(1440), function() {
             return Profile::whereIsPrivate(true)
             return Profile::whereIsPrivate(true)
                 ->orWhere('unlisted', true)
                 ->orWhere('unlisted', true)
                 ->orWhere('status', '!=', null)
                 ->orWhere('status', '!=', null)
                 ->pluck('id');
                 ->pluck('id');
         });
         });
+        
+        $private = $private->diff($following)->flatten();
 
 
         $filters = UserFilter::whereUserId($pid)
         $filters = UserFilter::whereUserId($pid)
                   ->whereFilterableType('App\Profile')
                   ->whereFilterableType('App\Profile')
@@ -355,9 +379,11 @@ class PublicApiController extends Controller
                         'local',
                         'local',
                         'reply_count',
                         'reply_count',
                         'comments_disabled',
                         'comments_disabled',
+                        'place_id',
                         'created_at',
                         'created_at',
                         'updated_at'
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
+                      ->with('profile', 'hashtags', 'mentions')
                       ->where('id', $dir, $id)
                       ->where('id', $dir, $id)
                       ->whereIn('profile_id', $following)
                       ->whereIn('profile_id', $following)
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNotIn('profile_id', $filtered)
@@ -382,9 +408,11 @@ class PublicApiController extends Controller
                         'local',
                         'local',
                         'reply_count',
                         'reply_count',
                         'comments_disabled',
                         'comments_disabled',
+                        'place_id',
                         'created_at',
                         'created_at',
                         'updated_at'
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
+                      ->with('profile', 'hashtags', 'mentions')
                       ->whereIn('profile_id', $following)
                       ->whereIn('profile_id', $following)
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNull('in_reply_to_id')
                       ->whereNull('in_reply_to_id')
@@ -499,7 +527,9 @@ class PublicApiController extends Controller
 
 
     public function relationships(Request $request)
     public function relationships(Request $request)
     {
     {
-        abort_if(!Auth::check(), 403);
+        if(!Auth::check()) {
+            return response()->json([]);
+        }
 
 
         $this->validate($request, [
         $this->validate($request, [
             'id'    => 'required|array|min:1|max:20',
             'id'    => 'required|array|min:1|max:20',
@@ -509,7 +539,7 @@ class PublicApiController extends Controller
         $filtered = $ids->filter(function($v) { 
         $filtered = $ids->filter(function($v) { 
             return $v != Auth::user()->profile->id;
             return $v != Auth::user()->profile->id;
         });
         });
-        $relations = Profile::findOrFail($filtered->all());
+        $relations = Profile::whereNull('status')->findOrFail($filtered->all());
         $fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer());
         $fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer());
         $res = $this->fractal->createData($fractal)->toArray();
         $res = $this->fractal->createData($fractal)->toArray();
         return response()->json($res);
         return response()->json($res);

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

@@ -37,6 +37,7 @@ class SearchController extends Controller
         $tokens = Cache::remember('api:search:tag:'.$hash, now()->addMinutes(5), function () use ($tag) {
         $tokens = Cache::remember('api:search:tag:'.$hash, now()->addMinutes(5), function () use ($tag) {
             $tokens = [];
             $tokens = [];
             if(Helpers::validateUrl($tag) != false && config('federation.activitypub.enabled') == true && config('federation.activitypub.remoteFollow') == true) {
             if(Helpers::validateUrl($tag) != false && config('federation.activitypub.enabled') == true && config('federation.activitypub.remoteFollow') == true) {
+                abort_if(Helpers::validateLocalUrl($tag), 404);
                 $remote = Helpers::fetchFromUrl($tag);
                 $remote = Helpers::fetchFromUrl($tag);
                 if(isset($remote['type']) && in_array($remote['type'], ['Note', 'Person']) == true) {
                 if(isset($remote['type']) && in_array($remote['type'], ['Note', 'Person']) == true) {
                     $type = $remote['type'];
                     $type = $remote['type'];
@@ -50,8 +51,9 @@ class SearchController extends Controller
                             'tokens' => [$item->username],
                             'tokens' => [$item->username],
                             'name'   => $item->name,
                             'name'   => $item->name,
                             'entity' => [
                             'entity' => [
-                                'id' => $item->id,
+                                'id' => (string) $item->id,
                                 'following' => $item->followedBy(Auth::user()->profile),
                                 'following' => $item->followedBy(Auth::user()->profile),
+                                'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
                                 'thumb' => $item->avatarUrl()
                                 'thumb' => $item->avatarUrl()
                             ]
                             ]
                         ]];
                         ]];
@@ -143,6 +145,7 @@ class SearchController extends Controller
                     'tokens' => [$item->caption],
                     'tokens' => [$item->caption],
                     'name'   => $item->caption,
                     'name'   => $item->caption,
                     'thumb'  => $item->thumb(),
                     'thumb'  => $item->thumb(),
+                    'filter' => $item->firstMedia()->filter_class
                 ];
                 ];
             });
             });
             $tokens['posts'] = $posts;
             $tokens['posts'] = $posts;

+ 3 - 3
app/Http/Controllers/Settings/ExportSettings.php

@@ -103,7 +103,7 @@ trait ExportSettings
     	$this->validate($request, [
     	$this->validate($request, [
     		'type' => 'required|string|in:ap,api'
     		'type' => 'required|string|in:ap,api'
     	]);
     	]);
-    	$limit = 300;
+    	$limit = 500;
 
 
     	$profile = Auth::user()->profile;
     	$profile = Auth::user()->profile;
     	$type = 'ap';
     	$type = 'ap';
@@ -116,7 +116,7 @@ trait ExportSettings
 
 
     	$filename = 'outbox.json';
     	$filename = 'outbox.json';
 		if($type == 'ap') {
 		if($type == 'ap') {
-			$data = Cache::remember('account:export:profile:statuses:ap:'.Auth::user()->profile->id, now()->addDays(7), function() {
+			$data = Cache::remember('account:export:profile:statuses:ap:'.Auth::user()->profile->id, now()->addHours(1), function() {
 				$profile = Auth::user()->profile->statuses;
 				$profile = Auth::user()->profile->statuses;
 				$fractal = new Fractal\Manager();
 				$fractal = new Fractal\Manager();
 				$fractal->setSerializer(new ArraySerializer());
 				$fractal->setSerializer(new ArraySerializer());
@@ -125,7 +125,7 @@ trait ExportSettings
 			});
 			});
 		} else {
 		} else {
 			$filename = 'api-statuses.json';
 			$filename = 'api-statuses.json';
-			$data = Cache::remember('account:export:profile:statuses:api:'.Auth::user()->profile->id, now()->addDays(7), function() {
+			$data = Cache::remember('account:export:profile:statuses:api:'.Auth::user()->profile->id, now()->addHours(1), function() {
 				$profile = Auth::user()->profile->statuses;
 				$profile = Auth::user()->profile->statuses;
 				$fractal = new Fractal\Manager();
 				$fractal = new Fractal\Manager();
 				$fractal->setSerializer(new ArraySerializer());
 				$fractal->setSerializer(new ArraySerializer());

+ 21 - 7
app/Http/Controllers/Settings/RelationshipSettings.php

@@ -18,15 +18,29 @@ trait RelationshipSettings
 
 
 	public function relationshipsHome(Request $request)
 	public function relationshipsHome(Request $request)
 	{
 	{
-		$mode = $request->input('mode') == 'following' ? 'following' : 'followers';
-		$profile = Auth::user()->profile;
+		$this->validate($request, [
+			'mode' => 'nullable|string|in:following,followers,hashtags'
+		]);
 
 
-		$following = $followers = [];
+		$mode = $request->input('mode');
+		$profile = Auth::user()->profile;
 
 
-		if($mode == 'following') {
-			$data = $profile->following()->simplePaginate(10);
-		} else {
-			$data = $profile->followers()->simplePaginate(10);
+		switch ($mode) {
+			case 'following':
+				$data = $profile->following()->simplePaginate(10);
+				break;
+
+			case 'followers':
+				$data = $profile->followers()->simplePaginate(10);
+				break;
+
+			case 'hashtags':
+				$data = $profile->hashtagFollowing()->with('hashtag')->simplePaginate(10);
+				break;
+			
+			default:
+				$data = [];
+				break;
 		}
 		}
 
 
 		return view('settings.relationships.home', compact('profile', 'mode', 'data'));
 		return view('settings.relationships.home', compact('profile', 'mode', 'data'));

+ 68 - 6
app/Http/Controllers/SettingsController.php

@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
 
 
 use App\AccountLog;
 use App\AccountLog;
 use App\Following;
 use App\Following;
+use App\ProfileSponsor;
 use App\Report;
 use App\Report;
 use App\UserFilter;
 use App\UserFilter;
 use Auth, Cookie, DB, Cache, Purify;
 use Auth, Cookie, DB, Cache, Purify;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 use App\Http\Controllers\Settings\{
 use App\Http\Controllers\Settings\{
     ExportSettings,
     ExportSettings,
     LabsSettings,
     LabsSettings,
@@ -90,12 +92,18 @@ class SettingsController extends Controller
 
 
     public function removeAccountTemporary(Request $request)
     public function removeAccountTemporary(Request $request)
     {
     {
+        $user = Auth::user();
+        abort_if(!config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
+
         return view('settings.remove.temporary');
         return view('settings.remove.temporary');
     }
     }
 
 
     public function removeAccountTemporarySubmit(Request $request)
     public function removeAccountTemporarySubmit(Request $request)
     {
     {
         $user = Auth::user();
         $user = Auth::user();
+        abort_if(!config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
         $profile = $user->profile;
         $profile = $user->profile;
         $user->status = 'disabled';
         $user->status = 'disabled';
         $profile->status = 'disabled';
         $profile->status = 'disabled';
@@ -108,9 +116,8 @@ class SettingsController extends Controller
 
 
     public function removeAccountPermanent(Request $request)
     public function removeAccountPermanent(Request $request)
     {
     {
-        if(config('pixelfed.account_deletion') == false) {
-            abort(404);
-        }
+        $user = Auth::user();
+        abort_if($user->is_admin, 403);
         return view('settings.remove.permanent');
         return view('settings.remove.permanent');
     }
     }
 
 
@@ -120,9 +127,8 @@ class SettingsController extends Controller
             abort(404);
             abort(404);
         }
         }
         $user = Auth::user();
         $user = Auth::user();
-        if($user->is_admin == true) {
-            return abort(400, 'You cannot delete an admin account.');
-        }
+        abort_if(!config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
         $profile = $user->profile;
         $profile = $user->profile;
         $ts = Carbon::now()->addMonth();
         $ts = Carbon::now()->addMonth();
         $user->status = 'delete';
         $user->status = 'delete';
@@ -166,5 +172,61 @@ class SettingsController extends Controller
 
 
         return response()->json([200])->cookie($cookie);
         return response()->json([200])->cookie($cookie);
     }
     }
+
+    public function sponsor()
+    {
+        $default = [
+            'patreon' => null,
+            'liberapay' => null,
+            'opencollective' => null
+        ];
+        $sponsors = ProfileSponsor::whereProfileId(Auth::user()->profile->id)->first();
+        $sponsors = $sponsors ? json_decode($sponsors->sponsors, true) : $default;
+        return view('settings.sponsor', compact('sponsors'));
+    }
+
+    public function sponsorStore(Request $request)
+    {
+        $this->validate($request, [
+            'patreon' => 'nullable|string',
+            'liberapay' => 'nullable|string',
+            'opencollective' => 'nullable|string'
+        ]);
+
+        $patreon = Str::startsWith($request->input('patreon'), 'https://') ? 
+            substr($request->input('patreon'), 8) : 
+            $request->input('patreon');
+
+        $liberapay = Str::startsWith($request->input('liberapay'), 'https://') ? 
+            substr($request->input('liberapay'), 8) : 
+            $request->input('liberapay');
+            
+        $opencollective = Str::startsWith($request->input('opencollective'), 'https://') ? 
+            substr($request->input('opencollective'), 8) : 
+            $request->input('opencollective');
+
+        $patreon = Str::startsWith($patreon, 'patreon.com/') ? e($patreon) : null;
+        $liberapay = Str::startsWith($liberapay, 'liberapay.com/') ? e($liberapay) : null;
+        $opencollective = Str::startsWith($opencollective, 'opencollective.com/') ? e($opencollective) : null;
+
+        if(empty($patreon) && empty($liberapay) && empty($opencollective)) {
+            return redirect(route('settings'))->with('error', 'An error occured. Please try again later.');;
+        }
+
+        $res = [
+            'patreon' => $patreon,
+            'liberapay' => $liberapay,
+            'opencollective' => $opencollective
+        ];
+
+        $sponsors = ProfileSponsor::firstOrCreate([
+            'profile_id' => Auth::user()->profile_id ?? Auth::user()->profile->id
+        ]);
+        $sponsors->sponsors = json_encode($res);
+        $sponsors->save();
+        $sponsors = $res;
+        return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');;
+    }
+
 }
 }
 
 

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

@@ -43,6 +43,7 @@ class SiteController extends Controller
     public function about()
     public function about()
     {
     {
         return Cache::remember('site:about', now()->addHours(12), function() {
         return Cache::remember('site:about', now()->addHours(12), function() {
+            app()->setLocale('en');
             $page = Page::whereSlug('/site/about')->whereActive(true)->first();
             $page = Page::whereSlug('/site/about')->whereActive(true)->first();
             $stats = [
             $stats = [
                 'posts' => Status::whereLocal(true)->count(),
                 'posts' => Status::whereLocal(true)->count(),

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

@@ -1,95 +0,0 @@
-<?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);
-    }
-}

+ 11 - 1
app/Jobs/CommentPipeline/CommentPipeline.php

@@ -4,7 +4,8 @@ namespace App\Jobs\CommentPipeline;
 
 
 use App\{
 use App\{
     Notification,
     Notification,
-    Status
+    Status,
+    UserFilter
 };
 };
 use App\Services\NotificationService;
 use App\Services\NotificationService;
 use DB, Cache, Log, Redis;
 use DB, Cache, Log, Redis;
@@ -56,6 +57,15 @@ class CommentPipeline implements ShouldQueue
         if ($actor->id === $target->id || $status->comments_disabled == true) {
         if ($actor->id === $target->id || $status->comments_disabled == true) {
             return true;
             return true;
         }
         }
+        $filtered = UserFilter::whereUserId($target->id)
+            ->whereFilterableType('App\Profile')
+            ->whereIn('filter_type', ['mute', 'block'])
+            ->whereFilterableId($actor->id)
+            ->exists();
+
+        if($filtered == true) {
+            return;
+        }
 
 
         DB::transaction(function() use($target, $actor, $comment) {
         DB::transaction(function() use($target, $actor, $comment) {
             $notification = new Notification();
             $notification = new Notification();

+ 3 - 1
app/Jobs/DeletePipeline/DeleteAccountPipeline.php

@@ -80,8 +80,10 @@ class DeleteAccountPipeline implements ShouldQueue
             Bookmark::whereProfileId($user->profile->id)->forceDelete();
             Bookmark::whereProfileId($user->profile->id)->forceDelete();
 
 
             EmailVerification::whereUserId($user->id)->forceDelete();
             EmailVerification::whereUserId($user->id)->forceDelete();
-
             $id = $user->profile->id;
             $id = $user->profile->id;
+
+            StatusHashtag::whereProfileId($id)->delete();
+            
             FollowRequest::whereFollowingId($id)->orWhere('follower_id', $id)->forceDelete();
             FollowRequest::whereFollowingId($id)->orWhere('follower_id', $id)->forceDelete();
 
 
             Follower::whereProfileId($id)->orWhere('following_id', $id)->forceDelete();
             Follower::whereProfileId($id)->orWhere('following_id', $id)->forceDelete();

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

@@ -57,7 +57,7 @@ class StatusActivityPubDeliver implements ShouldQueue
 
 
         $audience = $status->profile->getAudienceInbox();
         $audience = $status->profile->getAudienceInbox();
 
 
-        if(empty($audience) || $status->scope != 'public') {
+        if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
             // Return on profiles with no remote followers
             // Return on profiles with no remote followers
             return;
             return;
         }
         }

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

@@ -104,7 +104,7 @@ class StatusDelete implements ShouldQueue
             Report::whereObjectType('App\Status')
             Report::whereObjectType('App\Status')
                 ->whereObjectId($status->id)
                 ->whereObjectId($status->id)
                 ->delete();
                 ->delete();
-            $status->delete();
+            $status->forceDelete();
         });
         });
 
 
         return true;
         return true;

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

@@ -89,6 +89,9 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
         $status = $this->status;
 
 
         foreach ($tags as $tag) {
         foreach ($tags as $tag) {
+            if(mb_strlen($tag) > 124) {
+                continue;
+            }
             DB::transaction(function () use ($status, $tag) {
             DB::transaction(function () use ($status, $tag) {
                 $slug = str_slug($tag, '-', false);
                 $slug = str_slug($tag, '-', false);
                 $hashtag = Hashtag::firstOrCreate(
                 $hashtag = Hashtag::firstOrCreate(
@@ -98,7 +101,8 @@ class StatusEntityLexer implements ShouldQueue
                     [
                     [
                         'status_id' => $status->id, 
                         'status_id' => $status->id, 
                         'hashtag_id' => $hashtag->id,
                         'hashtag_id' => $hashtag->id,
-                        'profile_id' => $status->profile_id
+                        'profile_id' => $status->profile_id,
+                        'status_visibility' => $status->visibility,
                     ]
                     ]
                 );
                 );
             });
             });

+ 3 - 2
app/Media.php

@@ -30,13 +30,14 @@ class Media extends Model
     public function url()
     public function url()
     {
     {
         if(!empty($this->remote_media) && $this->remote_url) {
         if(!empty($this->remote_media) && $this->remote_url) {
+            //$url = \App\Services\MediaProxyService::get($this->remote_url, $this->mime);
             $url = $this->remote_url;
             $url = $this->remote_url;
         } else {
         } else {
             $path = $this->media_path;
             $path = $this->media_path;
-            $url = $this->cdn_url ?? Storage::url($path);
+            $url = $this->cdn_url ?? config('app.url') . Storage::url($path);
         }
         }
 
 
-        return url($url);
+        return $url;
     }
     }
 
 
     public function thumbnailUrl()
     public function thumbnailUrl()

+ 64 - 0
app/Observers/StatusHashtagObserver.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Observers;
+
+use App\StatusHashtag;
+use App\Services\StatusHashtagService;
+
+class StatusHashtagObserver
+{
+    /**
+     * Handle the notification "created" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function created(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "updated" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function updated(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "deleted" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function deleted(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "restored" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function restored(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "force deleted" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function forceDeleted(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
+    }
+}

+ 31 - 0
app/Place.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use Pixelfed\Snowflake\HasSnowflakePrimary;
+
+class Place extends Model
+{
+	protected $visible = ['id', 'name', 'country', 'slug'];
+
+	public function url()
+	{
+		return url('/discover/places/' . $this->id . '/' . $this->slug);
+	}
+
+	public function posts()
+	{
+		return $this->hasMany(Status::class);
+	}
+
+	public function postCount()
+	{
+		return $this->posts()->count();
+	}
+
+	public function statuses()
+	{
+		return $this->hasMany(Status::class, 'id', 'place_id');
+	}
+}

+ 28 - 3
app/Profile.php

@@ -4,12 +4,20 @@ namespace App;
 
 
 use Auth, Cache, Storage;
 use Auth, Cache, Storage;
 use App\Util\Lexer\PrettyNumber;
 use App\Util\Lexer\PrettyNumber;
+use Pixelfed\Snowflake\HasSnowflakePrimary;
 use Illuminate\Database\Eloquent\{Model, SoftDeletes};
 use Illuminate\Database\Eloquent\{Model, SoftDeletes};
 
 
 class Profile extends Model
 class Profile extends Model
 {
 {
-    use SoftDeletes;
-
+    use HasSnowflakePrimary, SoftDeletes;
+
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = false;
+    
     protected $dates = ['deleted_at'];
     protected $dates = ['deleted_at'];
     protected $hidden = ['private_key'];
     protected $hidden = ['private_key'];
     protected $visible = ['id', 'user_id', 'username', 'name'];
     protected $visible = ['id', 'user_id', 'username', 'name'];
@@ -132,7 +140,7 @@ class Profile extends Model
             $version = hash('sha256', $avatar->change_count);
             $version = hash('sha256', $avatar->change_count);
             $path = "{$path}?v={$version}";
             $path = "{$path}?v={$version}";
 
 
-            return url(Storage::url($path));
+            return config('app.url') . Storage::url($path);
         });
         });
 
 
         return $url;
         return $url;
@@ -278,4 +286,21 @@ class Profile extends Model
             'hashtag_id'
             'hashtag_id'
         );
         );
     }
     }
+
+    public function hashtagFollowing()
+    {
+        return $this->hasMany(HashtagFollow::class);
+    }
+
+    public function collections()
+    {
+        return $this->hasMany(Collection::class);
+    }
+
+    public function hasFollowRequestById(int $id)
+    {
+        return FollowRequest::whereFollowerId($id)
+            ->whereFollowingId($this->id)
+            ->exists();
+    }
 }
 }

+ 15 - 0
app/ProfileSponsor.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ProfileSponsor extends Model
+{
+    public $fillable = ['profile_id'];
+
+    public function profile()
+    {
+    	return $this->belongsTo(Profile::class);
+    }
+}

+ 3 - 0
app/Providers/AppServiceProvider.php

@@ -5,11 +5,13 @@ namespace App\Providers;
 use App\Observers\{
 use App\Observers\{
     AvatarObserver,
     AvatarObserver,
     NotificationObserver,
     NotificationObserver,
+    StatusHashtagObserver,
     UserObserver
     UserObserver
 };
 };
 use App\{
 use App\{
     Avatar,
     Avatar,
     Notification,
     Notification,
+    StatusHashtag,
     User
     User
 };
 };
 use Auth, Horizon, URL;
 use Auth, Horizon, URL;
@@ -31,6 +33,7 @@ class AppServiceProvider extends ServiceProvider
 
 
         Avatar::observe(AvatarObserver::class);
         Avatar::observe(AvatarObserver::class);
         Notification::observe(NotificationObserver::class);
         Notification::observe(NotificationObserver::class);
+        StatusHashtag::observe(StatusHashtagObserver::class);
         User::observe(UserObserver::class);
         User::observe(UserObserver::class);
 
 
         Horizon::auth(function ($request) {
         Horizon::auth(function ($request) {

File diff suppressed because it is too large
+ 19 - 0
app/Services/EmailService.php


+ 80 - 0
app/Services/StatusHashtagService.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Services;
+
+use Cache, Redis;
+use App\{Status, StatusHashtag};
+use App\Transformer\Api\StatusHashtagTransformer;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+
+class StatusHashtagService {
+
+	const CACHE_KEY = 'pf:services:status-hashtag:collection:';
+
+	public static function get($id, $page = 1, $stop = 9)
+	{
+		return StatusHashtag::whereHashtagId($id)
+			->whereStatusVisibility('public')
+			->whereHas('media')
+			->skip($stop)
+			->latest()
+			->take(9)
+			->pluck('status_id')
+			->map(function ($i, $k) use ($id) {
+				return self::getStatus($i, $id);
+			})
+			->all();
+	}
+
+	public static function coldGet($id, $start = 0, $stop = 2000)
+	{
+		$stop = $stop > 2000 ? 2000 : $stop;
+		$ids = StatusHashtag::whereHashtagId($id)
+			->whereStatusVisibility('public')
+			->whereHas('media')
+			->latest()
+			->skip($start)
+			->take($stop)
+			->pluck('status_id');
+		foreach($ids as $key) {
+			self::set($id, $key);
+		}
+		return $ids;
+	}
+
+	public static function set($key, $val)
+	{
+		return Redis::zadd(self::CACHE_KEY . $key, $val, $val);
+	}
+
+	public static function del($key)
+	{
+		return Redis::zrem(self::CACHE_KEY . $key, $key);
+	}
+
+	public static function count($id)
+	{
+		$count = Redis::zcount(self::CACHE_KEY . $id, '-inf', '+inf');
+		if(empty($count)) {
+			$count = StatusHashtag::whereHashtagId($id)->count();
+		}
+		return $count;
+	}
+
+	public static function getStatus($statusId, $hashtagId)
+	{
+		return Cache::remember('pf:services:status-hashtag:post:'.$statusId.':hashtag:'.$hashtagId, now()->addMonths(3), function() use($statusId, $hashtagId) {
+			$statusHashtag = StatusHashtag::with('profile', 'status', 'hashtag')
+				->whereStatusVisibility('public')
+				->whereStatusId($statusId)
+				->whereHashtagId($hashtagId)
+				->first();
+			$fractal = new Fractal\Manager();
+			$fractal->setSerializer(new ArraySerializer());
+			$resource = new Fractal\Resource\Item($statusHashtag, new StatusHashtagTransformer());
+			return $fractal->createData($resource)->toArray();
+		});
+	}
+}

+ 18 - 0
app/Status.php

@@ -379,15 +379,33 @@ class Status extends Model
                 break;
                 break;
 
 
             case 'unlisted':
             case 'unlisted':
+                $res['to'] = [
+                    $this->profile->permalink('/followers')
+                ];
+                $res['cc'] = [
+                    "https://www.w3.org/ns/activitystreams#Public"
+                ];
                 break;
                 break;
 
 
             case 'private':
             case 'private':
+                $res['to'] = [
+                    $this->profile->permalink('/followers')
+                ];
+                $res['cc'] = [];
                 break;
                 break;
 
 
+            // TODO: Update scope when DMs are supported
             case 'direct':
             case 'direct':
+                $res['to'] = [];
+                $res['cc'] = [];
                 break;
                 break;
         }
         }
         return $res[$audience];
         return $res[$audience];
     }
     }
 
 
+    public function place()
+    {
+        return $this->belongsTo(Place::class);
+    }
+
 }
 }

+ 14 - 1
app/StatusHashtag.php

@@ -9,7 +9,8 @@ class StatusHashtag extends Model
     public $fillable = [
     public $fillable = [
     	'status_id', 
     	'status_id', 
     	'hashtag_id', 
     	'hashtag_id', 
-    	'profile_id'
+    	'profile_id',
+    	'status_visibility'
     ];
     ];
 
 
 	public function status()
 	public function status()
@@ -26,4 +27,16 @@ class StatusHashtag extends Model
 	{
 	{
 		return $this->belongsTo(Profile::class);
 		return $this->belongsTo(Profile::class);
 	}
 	}
+
+	public function media()
+	{
+        return $this->hasManyThrough(
+            Media::class,
+            Status::class,
+            'id',
+            'status_id',
+            'status_id',
+            'id'
+        );
+	}
 }
 }

+ 3 - 0
app/Transformer/ActivityPub/ProfileTransformer.php

@@ -15,6 +15,9 @@ class ProfileTransformer extends Fractal\TransformerAbstract
             'https://w3id.org/security/v1',
             'https://w3id.org/security/v1',
             [
             [
               'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
               'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+              'PropertyValue'             => 'schema:PropertyValue',
+              'schema'                    => 'http://schema.org#',
+              'value'                     => 'schema:value'
             ],
             ],
           ],
           ],
           'id'                        => $profile->permalink(),
           'id'                        => $profile->permalink(),

+ 16 - 16
app/Transformer/ActivityPub/Verb/Announce.php

@@ -7,20 +7,20 @@ use League\Fractal;
 
 
 class Announce extends Fractal\TransformerAbstract
 class Announce extends Fractal\TransformerAbstract
 {
 {
-    public function transform(Status $status)
-    {
-    	return [
-    		'@context'  => 'https://www.w3.org/ns/activitystreams',
-    		'id'		=> $status->permalink(),
-    		'type' 		=> 'Announce',
-    		'actor'		=> $status->profile->permalink(),
-    		'to' 		=> ['https://www.w3.org/ns/activitystreams#Public'],
-    		'cc' 		=> [
-    			$status->profile->permalink(),
-    			$status->profile->follower_url ?? $status->profile->permalink('/followers')
-    		],
-    		'published' => $status->created_at->format(DATE_ISO8601),
-    		'object'	=> $status->parent()->url(),
-    	];
-    }
+	public function transform(Status $status)
+	{
+		return [
+			'@context'  => 'https://www.w3.org/ns/activitystreams',
+			'id'		=> $status->permalink(),
+			'type' 		=> 'Announce',
+			'actor'		=> $status->profile->permalink(),
+			'to' 		=> ['https://www.w3.org/ns/activitystreams#Public'],
+			'cc' 		=> [
+				$status->profile->permalink(),
+				$status->profile->follower_url ?? $status->profile->permalink('/followers')
+			],
+			'published' => $status->created_at->format(DATE_ISO8601),
+			'object'	=> $status->parent()->url(),
+		];
+	}
 }
 }

+ 23 - 0
app/Transformer/Api/DirectMessageTransformer.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use League\Fractal;
+use App\DirectMessage;
+
+class DirectMessageTransformer extends Fractal\TransformerAbstract
+{
+	public function transform(DirectMessage $dm)
+	{
+		return [
+			'id' 					=> $dm->id,
+			'to_id' 				=> $dm->to_id,
+			'from_id' 				=> $dm->from_id,
+			'from_profile_ids' 		=> $dm->from_profile_ids,
+			'group_message' 		=> $dm->group_message,
+			'status_id' 			=> $dm->status_id,
+			'read_at' 				=> $dm->read_at,
+			'created_at' 			=> $dm->created_at
+		];
+	}
+}

+ 11 - 2
app/Transformer/Api/RelationshipTransformer.php

@@ -3,7 +3,10 @@
 namespace App\Transformer\Api;
 namespace App\Transformer\Api;
 
 
 use Auth;
 use Auth;
-use App\Profile;
+use App\{
+    FollowRequest,
+    Profile
+};
 use League\Fractal;
 use League\Fractal;
 
 
 class RelationshipTransformer extends Fractal\TransformerAbstract
 class RelationshipTransformer extends Fractal\TransformerAbstract
@@ -12,6 +15,12 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
     {
     {
         $auth = Auth::check();
         $auth = Auth::check();
         $user = $auth ? Auth::user()->profile : false;
         $user = $auth ? Auth::user()->profile : false;
+        $requested = false;
+        if($user) {
+            $requested = FollowRequest::whereFollowerId($user->id)
+                ->whereFollowingId($profile->id)
+                ->exists();
+        }
         return [
         return [
             'id' => (string) $profile->id,
             'id' => (string) $profile->id,
             'following' => $auth ? $user->follows($profile) : false,
             'following' => $auth ? $user->follows($profile) : false,
@@ -19,7 +28,7 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
             'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
             'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
             'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
             'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
             'muting_notifications' => null,
             'muting_notifications' => null,
-            'requested' => null,
+            'requested' => $requested,
             'domain_blocking' => null,
             'domain_blocking' => null,
             'showing_reblogs' => null,
             'showing_reblogs' => null,
             'endorsed' => false
             'endorsed' => false

+ 38 - 0
app/Transformer/Api/StatusHashtagTransformer.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\{Hashtag, Status, StatusHashtag};
+use League\Fractal;
+
+class StatusHashtagTransformer extends Fractal\TransformerAbstract
+{
+	public function transform(StatusHashtag $statusHashtag)
+	{
+		$hashtag = $statusHashtag->hashtag;
+		$status = $statusHashtag->status;
+		$profile = $statusHashtag->profile;
+		
+		return [
+			'status' => [
+				'id'			=> (int) $status->id,
+				'type' 			=> $status->type,
+				'url' 			=> $status->url(),
+				'thumb' 		=> $status->thumb(),
+				'filter' 		=> $status->firstMedia()->filter_class,
+				'sensitive' 	=> (bool) $status->is_nsfw,
+				'like_count' 	=> $status->likes_count,
+				'share_count' 	=> $status->reblogs_count,
+				'user' => [
+					'username' 	=> $profile->username,
+					'url'		=> $profile->url(),
+				],
+				'visibility' 	=> $status->visibility ?? $status->scope
+			],
+			'hashtag' => [
+				'name' 			=> $hashtag->name,
+				'url'  			=> $hashtag->url(),
+			]
+		];
+	}
+}

+ 3 - 17
app/Transformer/Api/StatusTransformer.php

@@ -10,9 +10,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
 {
 {
     protected $defaultIncludes = [
     protected $defaultIncludes = [
         'account',
         'account',
-        'mentions',
         'media_attachments',
         'media_attachments',
-        'tags',
     ];
     ];
 
 
     public function transform(Status $status)
     public function transform(Status $status)
@@ -41,13 +39,15 @@ class StatusTransformer extends Fractal\TransformerAbstract
              ],
              ],
             'language'                  => null,
             'language'                  => null,
             'pinned'                    => null,
             'pinned'                    => null,
-
+            'mentions'                  => [],
+            'tags'                      => [],
             'pf_type'                   => $status->type ?? $status->setType(),
             'pf_type'                   => $status->type ?? $status->setType(),
             'reply_count'               => (int) $status->reply_count,
             'reply_count'               => (int) $status->reply_count,
             'comments_disabled'         => $status->comments_disabled ? true : false,
             'comments_disabled'         => $status->comments_disabled ? true : false,
             'thread'                    => false,
             'thread'                    => false,
             'replies'                   => [],
             'replies'                   => [],
             'parent'                    => [],
             'parent'                    => [],
+            'place'                     => $status->place
         ];
         ];
     }
     }
 
 
@@ -58,13 +58,6 @@ class StatusTransformer extends Fractal\TransformerAbstract
         return $this->item($account, new AccountTransformer());
         return $this->item($account, new AccountTransformer());
     }
     }
 
 
-    public function includeMentions(Status $status)
-    {
-        $mentions = $status->mentions;
-
-        return $this->collection($mentions, new MentionTransformer());
-    }
-
     public function includeMediaAttachments(Status $status)
     public function includeMediaAttachments(Status $status)
     {
     {
         return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
         return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
@@ -74,11 +67,4 @@ class StatusTransformer extends Fractal\TransformerAbstract
             }
             }
         });
         });
     }
     }
-
-    public function includeTags(Status $status)
-    {
-        $tags = $status->hashtags;
-
-        return $this->collection($tags, new HashtagTransformer());
-    }
 }
 }

+ 20 - 30
app/Util/ActivityPub/Helpers.php

@@ -125,7 +125,7 @@ class Helpers {
 	{
 	{
 		$audience = self::normalizeAudience($data);
 		$audience = self::normalizeAudience($data);
 		$url = $profile->permalink();
 		$url = $profile->permalink();
-		return in_array($url, $audience);
+		return in_array($url, $audience['to']) || in_array($url, $audience['cc']);
 	}
 	}
 
 
 	public static function validateUrl($url)
 	public static function validateUrl($url)
@@ -181,7 +181,7 @@ class Helpers {
 	public static function zttpUserAgent()
 	public static function zttpUserAgent()
 	{
 	{
 		return [
 		return [
-			'Accept'     => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+			'Accept'     => 'application/activity+json',
 			'User-Agent' => 'PixelfedBot - https://pixelfed.org',
 			'User-Agent' => 'PixelfedBot - https://pixelfed.org',
 		];
 		];
 	}
 	}
@@ -220,7 +220,7 @@ class Helpers {
 			$id = (int) last(explode('/', $url));
 			$id = (int) last(explode('/', $url));
 			return Status::findOrFail($id);
 			return Status::findOrFail($id);
 		} else {
 		} else {
-			$cached = Status::whereUrl($url)->first();
+			$cached = Status::whereUri($url)->orWhere('url', $url)->first();
 			if($cached) {
 			if($cached) {
 				return $cached;
 				return $cached;
 			}
 			}
@@ -241,7 +241,7 @@ class Helpers {
 
 
 			$scope = 'private';
 			$scope = 'private';
 			
 			
-			$cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
+			$cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false;
 
 
 			if(isset($res['to']) == true) {
 			if(isset($res['to']) == true) {
 				if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
 				if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
@@ -280,12 +280,10 @@ class Helpers {
 					$unlisted = false;
 					$unlisted = false;
 				}
 				}
 
 
-				$cw = config('costar.domain.cw');
-				if(in_array(parse_url($url, PHP_URL_HOST), $cw) == true) {
+				$cwDomains = config('costar.domain.cw');
+				if(in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) {
 					$cw = true;
 					$cw = true;
-				} else {
-					$cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
-				}
+				} 
 			}
 			}
 
 
 			if(!self::validateUrl($res['id']) ||
 			if(!self::validateUrl($res['id']) ||
@@ -328,7 +326,9 @@ class Helpers {
 				$status->scope = $scope;
 				$status->scope = $scope;
 				$status->visibility = $scope;
 				$status->visibility = $scope;
 				$status->save();
 				$status->save();
-				self::importNoteAttachment($res, $status);
+				if($reply_to == null) {
+					self::importNoteAttachment($res, $status);
+				}
 				return $status;
 				return $status;
 			});
 			});
 
 
@@ -361,29 +361,16 @@ class Helpers {
 			if(in_array($type, $allowed) == false || $valid == false) {
 			if(in_array($type, $allowed) == false || $valid == false) {
 				continue;
 				continue;
 			}
 			}
-			$info = pathinfo($url);
 
 
-			// pleroma attachment fix
-			$url = str_replace(' ', '%20', $url);
-
-			$img = file_get_contents($url, false, stream_context_create(['ssl' => ["verify_peer"=>true,"verify_peer_name"=>true]]));
-			$file = '/tmp/pxmi-'.str_random(32);
-			file_put_contents($file, $img);
-			$fdata = new File($file);
-			$path = Storage::putFile($storagePath, $fdata, 'public');
 			$media = new Media();
 			$media = new Media();
 			$media->remote_media = true;
 			$media->remote_media = true;
 			$media->status_id = $status->id;
 			$media->status_id = $status->id;
 			$media->profile_id = $status->profile_id;
 			$media->profile_id = $status->profile_id;
 			$media->user_id = null;
 			$media->user_id = null;
-			$media->media_path = $path;
-			$media->size = $fdata->getSize();
-			$media->mime = $fdata->getMimeType();
+			$media->media_path = $url;
+			$media->remote_url = $url;
+			$media->mime = $type;
 			$media->save();
 			$media->save();
-
-			ImageThumbnail::dispatch($media);
-			ImageOptimize::dispatch($media);
-			unlink($file);
 		}
 		}
 		
 		
 		$status->viewType();
 		$status->viewType();
@@ -401,7 +388,10 @@ class Helpers {
 
 
 		if($local == true) {
 		if($local == true) {
 			$id = last(explode('/', $url));
 			$id = last(explode('/', $url));
-			return Profile::whereUsername($id)->firstOrFail();
+			return Profile::whereNull('status')
+				->whereNull('domain')
+				->whereUsername($id)
+				->firstOrFail();
 		}
 		}
 		$res = self::fetchProfileFromUrl($url);
 		$res = self::fetchProfileFromUrl($url);
 		if(isset($res['id']) == false) {
 		if(isset($res['id']) == false) {
@@ -423,8 +413,8 @@ class Helpers {
 			$profile = new Profile();
 			$profile = new Profile();
 			$profile->domain = $domain;
 			$profile->domain = $domain;
 			$profile->username = (string) Purify::clean($remoteUsername);
 			$profile->username = (string) Purify::clean($remoteUsername);
-			$profile->name = Purify::clean($res['name']) ?? 'user';
-			$profile->bio = Purify::clean($res['summary']);
+			$profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user';
+			$profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null;
 			$profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
 			$profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
 			$profile->inbox_url = $res['inbox'];
 			$profile->inbox_url = $res['inbox'];
 			$profile->outbox_url = $res['outbox'];
 			$profile->outbox_url = $res['outbox'];
@@ -460,4 +450,4 @@ class Helpers {
 		$response = curl_exec($ch);
 		$response = curl_exec($ch);
 		return;
 		return;
 	}
 	}
-}
+}

+ 45 - 28
app/Util/ActivityPub/Inbox.php

@@ -15,8 +15,10 @@ use App\{
 use Carbon\Carbon;
 use Carbon\Carbon;
 use App\Util\ActivityPub\Helpers;
 use App\Util\ActivityPub\Helpers;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\LikePipeline\LikePipeline;
+use App\Jobs\FollowPipeline\FollowPipeline;
 
 
 use App\Util\ActivityPub\Validator\{
 use App\Util\ActivityPub\Validator\{
+    Accept,
     Follow
     Follow
 };
 };
 
 
@@ -101,7 +103,7 @@ class Inbox
 
 
     public function actorFirstOrCreate($actorUrl)
     public function actorFirstOrCreate($actorUrl)
     {
     {
-        return Helpers::profileFirstOrNew($actorUrl);
+        return Helpers::profileFetch($actorUrl);
     }
     }
 
 
     public function handleCreateActivity()
     public function handleCreateActivity()
@@ -135,15 +137,13 @@ class Inbox
 
 
     public function handleNoteCreate()
     public function handleNoteCreate()
     {
     {
-        return;
-
         $activity = $this->payload['object'];
         $activity = $this->payload['object'];
         $actor = $this->actorFirstOrCreate($this->payload['actor']);
         $actor = $this->actorFirstOrCreate($this->payload['actor']);
         if(!$actor || $actor->domain == null) {
         if(!$actor || $actor->domain == null) {
             return;
             return;
         }
         }
 
 
-        if(Helpers::userInAudience($this->profile, $this->payload) == false) {
+        if($actor->followers()->count() == 0) {
             return;
             return;
         }
         }
 
 
@@ -176,7 +176,7 @@ class Inbox
                 'following_id' => $target->id,
                 'following_id' => $target->id,
                 'local_profile' => empty($actor->domain)
                 'local_profile' => empty($actor->domain)
             ]);
             ]);
-            if($follower->wasRecentlyCreated == true) {
+            if($follower->wasRecentlyCreated == true && $target->domain == null) {
                 // send notification
                 // send notification
                 Notification::firstOrCreate([
                 Notification::firstOrCreate([
                     'profile_id' => $target->id,
                     'profile_id' => $target->id,
@@ -188,14 +188,19 @@ class Inbox
                     'item_type' => 'App\Profile'
                     'item_type' => 'App\Profile'
                 ]);
                 ]);
             }
             }
-            $payload = $this->payload;
+
             // send Accept to remote profile
             // send Accept to remote profile
             $accept = [
             $accept = [
                 '@context' => 'https://www.w3.org/ns/activitystreams',
                 '@context' => 'https://www.w3.org/ns/activitystreams',
                 'id'       => $target->permalink().'#accepts/follows/' . $follower->id,
                 'id'       => $target->permalink().'#accepts/follows/' . $follower->id,
                 'type'     => 'Accept',
                 'type'     => 'Accept',
                 'actor'    => $target->permalink(),
                 'actor'    => $target->permalink(),
-                'object'   => $payload
+                'object'   => [
+                    'id'        => $actor->permalink('#follows/' . $follower->id),
+                    'actor'     => $actor->permalink(),
+                    'type'      => 'Follow',
+                    'object'    => $target->permalink()
+                ]
             ];
             ];
             Helpers::sendSignedObject($target, $actor->inbox_url, $accept);
             Helpers::sendSignedObject($target, $actor->inbox_url, $accept);
         }
         }
@@ -242,28 +247,40 @@ class Inbox
 
 
     public function handleAcceptActivity()
     public function handleAcceptActivity()
     {
     {
-        $actor = $this->payload['actor'];
-        $obj = $this->payload['object'];
-        switch ($obj['type']) {
-            case 'Follow':
-                $accept = [
-                    '@context' => 'https://www.w3.org/ns/activitystreams',
-                    'id'       => $target->permalink().'#accepts/follows/' . $follower->id,
-                    'type'     => 'Accept',
-                    'actor'    => $target->permalink(),
-                    'object'   => [
-                        'id' => $actor->permalink('#follows/'.$target->id),
-                        'type'  => 'Follow',
-                        'actor' => $actor->permalink(),
-                        'object' => $target->permalink()
-                    ]
-                ];
-                break;
-            
-            default:
-                # code...
-                break;
+
+        $actor = $this->payload['object']['actor'];
+        $obj = $this->payload['object']['object'];
+        $type = $this->payload['object']['type'];
+
+        if($type !== 'Follow') {
+            return;
         }
         }
+
+        $actor = Helpers::validateLocalUrl($actor);
+        $target = Helpers::validateUrl($obj);
+
+        if(!$actor || !$target) {
+            return;
+        }
+        $actor = Helpers::profileFetch($actor);
+        $target = Helpers::profileFetch($target);
+
+        $request = FollowRequest::whereFollowerId($actor->id)
+            ->whereFollowingId($target->id)
+            ->whereIsRejected(false)
+            ->first();
+
+        if(!$request) {
+            return;
+        }
+
+        $follower = Follower::firstOrCreate([
+            'profile_id' => $actor->id,
+            'following_id' => $target->id,
+        ]);
+        FollowPipeline::dispatch($follower);
+
+        $request->delete();
     }
     }
 
 
     public function handleDeleteActivity()
     public function handleDeleteActivity()

+ 4 - 4
app/Util/ActivityPub/Validator/Accept.php

@@ -16,15 +16,15 @@ class Accept {
 				'required',
 				'required',
 				Rule::in(['Accept'])
 				Rule::in(['Accept'])
 			],
 			],
-			'actor' => 'required|url|active_url',
+			'actor' => 'required|url',
 			'object' => 'required',
 			'object' => 'required',
-			'object.id' => 'required|url|active_url',
+			'object.id' => 'required|url',
 			'object.type' => [
 			'object.type' => [
 				'required',
 				'required',
 				Rule::in(['Follow'])
 				Rule::in(['Follow'])
 			],
 			],
-			'object.actor' => 'required|url|active_url',
-			'object.object' => 'required|url|active_url|same:actor',
+			'object.actor' => 'required|url',
+			'object.object' => 'required|url|same:actor',
 		])->passes();
 		])->passes();
 
 
 		return $valid;
 		return $valid;

+ 3 - 1
app/Util/Lexer/Extractor.php

@@ -264,7 +264,9 @@ class Extractor extends Regex
             if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
             if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
                 continue;
                 continue;
             }
             }
-
+            if(mb_strlen($hashtag[0]) > 124) {
+                continue;
+            }
             $tags[] = [
             $tags[] = [
                 'hashtag' => $hashtag[0],
                 'hashtag' => $hashtag[0],
                 'indices' => [$start_position, $end_position],
                 'indices' => [$start_position, $end_position],

+ 40 - 5
app/Util/Lexer/RestrictedNames.php

@@ -5,10 +5,6 @@ namespace App\Util\Lexer;
 class RestrictedNames
 class RestrictedNames
 {
 {
     public static $blacklist = [
     public static $blacklist = [
-     'about',
-     'abuse',
-     'administrator',
-     'app',
      'autoconfig',
      'autoconfig',
      'blog',
      'blog',
      'broadcasthost',
      'broadcasthost',
@@ -97,7 +93,11 @@ class RestrictedNames
 
 
      // Reserved routes
      // Reserved routes
      'a',
      'a',
+     'app',
+     'about',
+     'abuse',
      'account',
      'account',
+     'admins',
      'api',
      'api',
      'audio',
      'audio',
      'auth',
      'auth',
@@ -124,6 +124,7 @@ class RestrictedNames
      'css',
      'css',
      'd',
      'd',
      'dashboard',
      'dashboard',
+     'dmca',
      'db',
      'db',
      'deck',
      'deck',
      'dev',
      'dev',
@@ -136,15 +137,27 @@ class RestrictedNames
      'docs',
      'docs',
      'docs',
      'docs',
      'drive',
      'drive',
+     'drives',
      'driver',
      'driver',
+     'e',
      'error',
      'error',
      'explore',
      'explore',
+     'export',
+     'exports',
+     'f',
      'feed',
      'feed',
      'font',
      'font',
      'fonts',
      'fonts',
+     'follow',
+     'follows',
+     'followme',
+     'follow-me',
+     'follow_me',
      'g',
      'g',
      'gdpr',
      'gdpr',
      'graph',
      'graph',
+     'ghost',
+     'ghosts',
      'group',
      'group',
      'groups',
      'groups',
      'h',
      'h',
@@ -164,7 +177,12 @@ class RestrictedNames
      'images',
      'images',
      'invite',
      'invite',
      'invites',
      'invites',
+     'import',
+     'imports',
+     'j',
      'js',
      'js',
+     'k',
+     'key',
      'l',
      'l',
      'lab',
      'lab',
      'labs',
      'labs',
@@ -186,6 +204,7 @@ class RestrictedNames
      'news',
      'news',
      'news',
      'news',
      'newsfeed',
      'newsfeed',
+     'o',
      'oauth',
      'oauth',
      'official',
      'official',
      'p',
      'p',
@@ -197,13 +216,23 @@ class RestrictedNames
      'photos',
      'photos',
      'password',
      'password',
      'privacy',
      'privacy',
+     'private',
+     'q',
+     'quote',
+     'query',
+     'r',
+     'register',
+     'registers',
+     'review',
      'reset',
      'reset',
      'report',
      'report',
      'results',
      'results',
      'reports',
      'reports',
      'robot',
      'robot',
      'robots',
      'robots',
+     's',
      'search',
      'search',
+     'sell',
      'send',
      'send',
      'settings',
      'settings',
      'status',
      'status',
@@ -217,20 +246,24 @@ class RestrictedNames
      'support',
      'support',
      'svg',
      'svg',
      'svgs',
      'svgs',
+     't',
      'terms',
      'terms',
      'telescope',
      'telescope',
      'timeline',
      'timeline',
      'timelines',
      'timelines',
      'tour',
      'tour',
      'tv',
      'tv',
+     'u',
      'user',
      'user',
      'users',
      'users',
      'username',
      'username',
      'usernames',
      'usernames',
      'v',
      'v',
+     'valet',
      'video',
      'video',
      'videos',
      'videos',
      'vendor',
      'vendor',
+     'w',
      'waiter',
      'waiter',
      'wall',
      'wall',
      'whats-new',
      'whats-new',
@@ -240,7 +273,9 @@ class RestrictedNames
      'ws',
      'ws',
      'wss',
      'wss',
      'www',
      'www',
-     'valet',
+     'x',
+     'y',
+     'z',
      '400',
      '400',
      '401',
      '401',
      '403',
      '403',

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

@@ -110,14 +110,15 @@ class Image
         $orientation = $ratio['orientation'];
         $orientation = $ratio['orientation'];
 
 
         try {
         try {
-            $img = Intervention::make($file)->orientate();
+            $img = Intervention::make($file);
+            $metadata = $img->exif();
+            $img->orientate();
             if($thumbnail) {
             if($thumbnail) {
                 $img->resize($aspect['width'], $aspect['height'], function ($constraint) {
                 $img->resize($aspect['width'], $aspect['height'], function ($constraint) {
                     $constraint->aspectRatio();
                     $constraint->aspectRatio();
                 });
                 });
             } else {
             } else {
                 if(config('media.exif.database', false) == true) {
                 if(config('media.exif.database', false) == true) {
-                    $metadata = $img->exif();
                     $media->metadata = json_encode($metadata);
                     $media->metadata = json_encode($metadata);
                 }
                 }
 
 
@@ -130,7 +131,7 @@ class Image
 
 
             $quality = config('pixelfed.image_quality');
             $quality = config('pixelfed.image_quality');
             $img->save($newPath, $quality);
             $img->save($newPath, $quality);
-
+            $img->destroy();
             if (!$thumbnail) {
             if (!$thumbnail) {
                 $media->orientation = $orientation;
                 $media->orientation = $orientation;
             }
             }

+ 31 - 1
app/Util/RateLimit/User.php

@@ -11,7 +11,7 @@ trait User {
 
 
 	public function getMaxPostsPerHourAttribute()
 	public function getMaxPostsPerHourAttribute()
 	{
 	{
-		return 20;
+		return 50;
 	}
 	}
 
 
 	public function getMaxPostsPerDayAttribute()
 	public function getMaxPostsPerDayAttribute()
@@ -49,8 +49,38 @@ trait User {
 		return 500;
 		return 500;
 	}
 	}
 
 
+	public function getMaxUserBansPerDayAttribute()
+	{
+		return 100;
+	}
+
 	public function getMaxInstanceBansPerDayAttribute()
 	public function getMaxInstanceBansPerDayAttribute()
 	{
 	{
 		return 100;
 		return 100;
 	}
 	}
+
+	public function getMaxHashtagFollowsPerHourAttribute()
+	{
+		return 20;
+	}
+
+	public function getMaxHashtagFollowsPerDayAttribute()
+	{
+		return 100;
+	}
+
+	public function getMaxCollectionsPerHourAttribute()
+	{
+		return 10;
+	}
+
+	public function getMaxCollectionsPerDayAttribute()
+	{
+		return 20;
+	}
+
+	public function getMaxCollectionsPerMonthAttribute()
+	{
+		return 100;
+	}
 }
 }

+ 47 - 0
app/Util/Site/Config.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Util\Site;
+
+use Cache;
+
+class Config {
+
+	public static function get() {
+		return Cache::remember('api:site:configuration', now()->addMinutes(30), function() {
+			return [
+				'uploader' => [
+					'max_photo_size' => config('pixelfed.max_photo_size'),
+					'max_caption_length' => config('pixelfed.max_caption_length'),
+					'album_limit' => config('pixelfed.max_album_length'),
+					'image_quality' => config('pixelfed.image_quality'),
+
+					'optimize_image' => config('pixelfed.optimize_image'),
+					'optimize_video' => config('pixelfed.optimize_video'),
+
+					'media_types' => config('pixelfed.media_types'),
+					'enforce_account_limit' => config('pixelfed.enforce_account_limit')
+				],
+
+				'activitypub' => [
+					'enabled' => config('federation.activitypub.enabled'),
+					'remote_follow' => config('federation.activitypub.remoteFollow')
+				],
+
+				'ab' => [
+					'lc' => config('exp.lc'),
+					'rec' => config('exp.rec'),
+					'loops' => config('exp.loops')
+				],
+
+				'site' => [
+					'domain' => config('pixelfed.domain.app'),
+					'url'    => config('app.url')
+				]
+			];
+		});
+	}
+
+	public static function json() {
+		return json_encode(self::get(), JSON_FORCE_OBJECT);
+	}
+}

+ 0 - 10
app/WebSub.php

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

+ 3 - 0
composer.json

@@ -23,6 +23,7 @@
         "laravel/tinker": "^1.0",
         "laravel/tinker": "^1.0",
         "league/flysystem-aws-s3-v3": "~1.0",
         "league/flysystem-aws-s3-v3": "~1.0",
         "league/flysystem-cached-adapter": "~1.0",
         "league/flysystem-cached-adapter": "~1.0",
+        "league/iso3166": "^2.1",
         "moontoast/math": "^1.1",
         "moontoast/math": "^1.1",
         "pbmedia/laravel-ffmpeg": "4.0.0",
         "pbmedia/laravel-ffmpeg": "4.0.0",
         "phpseclib/phpseclib": "~2.0",
         "phpseclib/phpseclib": "~2.0",
@@ -38,10 +39,12 @@
         "stevebauman/purify": "2.0.*"
         "stevebauman/purify": "2.0.*"
     },
     },
     "require-dev": {
     "require-dev": {
+        "barryvdh/laravel-debugbar": "dev-master",
         "filp/whoops": "^2.0",
         "filp/whoops": "^2.0",
         "fzaninotto/faker": "^1.4",
         "fzaninotto/faker": "^1.4",
         "mockery/mockery": "^1.0",
         "mockery/mockery": "^1.0",
         "nunomaduro/collision": "^2.0",
         "nunomaduro/collision": "^2.0",
+        "nunomaduro/phpinsights": "^1.7",
         "phpunit/phpunit": "^7.5"
         "phpunit/phpunit": "^7.5"
     },
     },
     "autoload": {
     "autoload": {

File diff suppressed because it is too large
+ 243 - 172
composer.lock


+ 0 - 2
config/app.php

@@ -150,7 +150,6 @@ return [
         /*
         /*
          * Package Service Providers...
          * Package Service Providers...
          */
          */
-        Jackiedo\DotenvEditor\DotenvEditorServiceProvider::class,
 
 
         /*
         /*
          * Application Service Providers...
          * Application Service Providers...
@@ -211,7 +210,6 @@ return [
         'Validator'    => Illuminate\Support\Facades\Validator::class,
         'Validator'    => Illuminate\Support\Facades\Validator::class,
         'View'         => Illuminate\Support\Facades\View::class,
         'View'         => Illuminate\Support\Facades\View::class,
 
 
-        'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
         'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
         'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
         'Purify'       => Stevebauman\Purify\Facades\Purify::class,
         'Purify'       => Stevebauman\Purify\Facades\Purify::class,
         'FFMpeg'       => Pbmedia\LaravelFFMpeg\FFMpegFacade::class,
         'FFMpeg'       => Pbmedia\LaravelFFMpeg\FFMpegFacade::class,

+ 2 - 0
config/cache.php

@@ -73,6 +73,8 @@ return [
             'client' => 'predis',
             'client' => 'predis',
 
 
             'default' => [
             'default' => [
+                'scheme'   => env('REDIS_SCHEME', 'tcp'),
+                'path'     => env('REDIS_PATH'),
                 'host'     => env('REDIS_HOST', 'localhost'),
                 'host'     => env('REDIS_HOST', 'localhost'),
                 'password' => env('REDIS_PASSWORD', null),
                 'password' => env('REDIS_PASSWORD', null),
                 'port'     => env('REDIS_PORT', 6379),
                 'port'     => env('REDIS_PORT', 6379),

+ 2 - 0
config/database.php

@@ -109,6 +109,8 @@ return [
         'client' => 'predis',
         'client' => 'predis',
 
 
         'default' => [
         'default' => [
+            'scheme'   => env('REDIS_SCHEME', 'tcp'),
+            'path'     => env('REDIS_PATH'),
             'host'     => env('REDIS_HOST', '127.0.0.1'),
             'host'     => env('REDIS_HOST', '127.0.0.1'),
             'password' => env('REDIS_PASSWORD', null),
             'password' => env('REDIS_PASSWORD', null),
             'port'     => env('REDIS_PORT', 6379),
             'port'     => env('REDIS_PORT', 6379),

+ 1 - 1
config/federation.php

@@ -17,7 +17,7 @@ return [
 		'inbox' => env('AP_INBOX', true),
 		'inbox' => env('AP_INBOX', true),
 		'sharedInbox' => env('AP_SHAREDINBOX', false),
 		'sharedInbox' => env('AP_SHAREDINBOX', false),
 
 
-		'remoteFollow' => false,
+		'remoteFollow' => env('AP_REMOTE_FOLLOW', false),
 
 
 		'delivery' => [
 		'delivery' => [
 			'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),
 			'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),

+ 33 - 5
config/instance.php

@@ -1,15 +1,43 @@
 <?php
 <?php
 
 
 return [
 return [
-	'email' => env('INSTANCE_CONTACT_EMAIL'),
+
+	'announcement' => [
+		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', false),
+		'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
+	],
 
 
 	'contact' => [
 	'contact' => [
 		'enabled' => env('INSTANCE_CONTACT_FORM', false),
 		'enabled' => env('INSTANCE_CONTACT_FORM', false),
 		'max_per_day' => env('INSTANCE_CONTACT_MAX_PER_DAY', 1),
 		'max_per_day' => env('INSTANCE_CONTACT_MAX_PER_DAY', 1),
 	],
 	],
 
 
-	'announcement' => [
-		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true),
-		'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
-	]
+	'discover' => [
+		'loops' => [
+			'enabled' => false
+		],
+		'tags' => [
+			'is_public' => env('INSTANCE_PUBLIC_HASHTAGS', false)
+		],
+	],
+	
+	'email' => env('INSTANCE_CONTACT_EMAIL'),
+
+	'timeline' => [
+		'local' => [
+			'is_public' => env('INSTANCE_PUBLIC_LOCAL_TIMELINE', true)
+		]
+	],
+
+	'page' => [
+		'404' => [
+			'header' => env('PAGE_404_HEADER', 'Sorry, this page isn\'t available.'),
+			'body' => env('PAGE_404_BODY', 'The link you followed may be broken, or the page may have been removed. <a href="/">Go back to Pixelfed.</a>')
+		],
+		'503' => [
+			'header' => env('PAGE_503_HEADER', 'Service Unavailable'),
+			'body' => env('PAGE_503_BODY', 'Our service is in maintenance mode, please try again later.')
+		]
+	],
+
 ];
 ];

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your Pixelfed instance.
     | This value is the version of your Pixelfed instance.
     |
     |
     */
     */
-    'version' => '0.9.4',
+    'version' => '0.10.0',
 
 
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------

+ 3 - 3
contrib/docker/Dockerfile.apache

@@ -1,4 +1,4 @@
-FROM php:7.3-apache
+FROM php:7.3-apache-buster
 
 
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
@@ -7,13 +7,13 @@ RUN apt-get update \
  && apt-get install -y --no-install-recommends apt-utils \
  && apt-get install -y --no-install-recommends apt-utils \
  && apt-get install -y --no-install-recommends git gosu \
  && apt-get install -y --no-install-recommends git gosu \
       optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
       optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
-      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-3 \
+      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
       libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
       libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && locale-gen && update-locale \
  && locale-gen && update-locale \
  && docker-php-source extract \
  && docker-php-source extract \
  && docker-php-ext-configure gd \
  && docker-php-ext-configure gd \
-      --with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
+      --enable-freetype \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-webp-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-webp-dir=/usr/lib/x86_64-linux-gnu/ \

+ 10 - 9
contrib/docker/Dockerfile.fpm

@@ -1,24 +1,25 @@
-FROM php:7.2-fpm
+FROM php:7.3-fpm-buster
 
 
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
 
 
 RUN apt-get update \
 RUN apt-get update \
+ && apt-get install -y --no-install-recommends apt-utils \
  && apt-get install -y --no-install-recommends git gosu \
  && apt-get install -y --no-install-recommends git gosu \
-      optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev \
-      libfreetype6 libjpeg62-turbo libpng16-16 libxpm4 libvpx4 libmagickwand-6.q16-3 \
-      libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libvpx-dev libmagickwand-dev \
+      optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
+      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
+      libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && locale-gen && update-locale \
  && locale-gen && update-locale \
  && docker-php-source extract \
  && docker-php-source extract \
  && docker-php-ext-configure gd \
  && docker-php-ext-configure gd \
-      --with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
+      --enable-freetype \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
-      --with-vpx-dir=/usr/lib/x86_64-linux-gnu/ \
- && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip \
+      --with-webp-dir=/usr/lib/x86_64-linux-gnu/ \
+ && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip curl \
  && pecl install imagick \
  && pecl install imagick \
- && docker-php-ext-enable imagick pcntl imagick gd exif zip \
+ && docker-php-ext-enable imagick pcntl imagick gd exif zip curl \
  && curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /usr/bin/composer \
  && curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /usr/bin/composer \
  && echo "${COMPOSER_CHECKSUM}  /usr/bin/composer" | sha256sum -c - \
  && echo "${COMPOSER_CHECKSUM}  /usr/bin/composer" | sha256sum -c - \
  && chmod 755 /usr/bin/composer \
  && chmod 755 /usr/bin/composer \
@@ -32,7 +33,7 @@ ENV PATH="~/.composer/vendor/bin:./vendor/bin:${PATH}"
 COPY . /var/www/
 COPY . /var/www/
 
 
 WORKDIR /var/www/
 WORKDIR /var/www/
-RUN cp -r storage storage.skel \
+RUN mkdir public.ext && cp -r storage storage.skel \
  && cp contrib/docker/php.ini /usr/local/etc/php/conf.d/pixelfed.ini \
  && cp contrib/docker/php.ini /usr/local/etc/php/conf.d/pixelfed.ini \
  && composer install --prefer-dist --no-interaction \
  && composer install --prefer-dist --no-interaction \
  && rm -rf html && ln -s public html
  && rm -rf html && ln -s public html

+ 1 - 1
contrib/docker/php.ini

@@ -1,5 +1,5 @@
 file_uploads = On
 file_uploads = On
-memory_limit = 64M
+memory_limit = 128M
 upload_max_filesize = 64M
 upload_max_filesize = 64M
 post_max_size = 64M
 post_max_size = 64M
 max_execution_time = 600
 max_execution_time = 600

+ 47 - 20
contrib/nginx.conf

@@ -1,22 +1,49 @@
 server {
 server {
-	listen 80 default_server;
-	listen [::]:80 default_server;
-	server_name localhost;
-
-	index index.php index.html;
-	root /var/www/html/public;
-
-	location / {
-		try_files $uri $uri/ /$is_args$args;
-	}
-
-	location ~ \.php$ {
-		try_files $uri =404;
-		fastcgi_split_path_info ^(.+\.php)(/.+)$;
-		fastcgi_pass php:9000;
-		fastcgi_index index.php;
-		include fastcgi_params;
-		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
-		fastcgi_param PATH_INFO $fastcgi_path_info;
-	}
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name pixelfed.example;                    # change this to your fqdn
+    root /home/pixelfed/public;                      # path to repo/public
+
+    ssl_certificate /etc/nginx/ssl/server.crt;       # generate your own
+    ssl_certificate_key /etc/nginx/ssl/server.key;   # or use letsencrypt
+
+    ssl_protocols TLSv1.2;
+    ssl_ciphers EECDH+AESGCM:EECDH+CHACHA20:EECDH+AES;
+    ssl_prefer_server_ciphers on;
+
+    add_header X-Frame-Options "SAMEORIGIN";
+    add_header X-XSS-Protection "1; mode=block";
+    add_header X-Content-Type-Options "nosniff";
+
+    index index.html index.htm index.php;
+
+    charset utf-8;
+
+    location / {
+        try_files $uri $uri/ /index.php?$query_string;
+    }
+
+    location = /favicon.ico { access_log off; log_not_found off; }
+    location = /robots.txt  { access_log off; log_not_found off; }
+
+    error_page 404 /index.php;
+
+    location ~ \.php$ {
+        fastcgi_split_path_info ^(.+\.php)(/.+)$;
+        fastcgi_pass unix:/run/php-fpm/php-fpm.sock; # make sure this is correct
+        fastcgi_index index.php;
+        include fastcgi_params;
+        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # or $request_filename
+    }
+
+    location ~ /\.(?!well-known).* {
+        deny all;
+    }
+}
+
+server {                                             # Redirect http to https
+    server_name pixelfed.example;                    # change this to your fqdn
+    listen 80;
+    listen [::]:80;
+    return 301 https://$host$request_uri;
 }
 }

+ 35 - 0
database/migrations/2019_07_05_034644_create_hashtag_follows_table.php

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

+ 32 - 0
database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddStatusVisibilityToStatusHashtagsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('status_hashtags', function (Blueprint $table) {
+            $table->string('status_visibility')->nullable()->index()->after('profile_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('status_hashtags', function (Blueprint $table) {
+            $table->dropColumn('status_visibility');
+        });
+    }
+}

+ 33 - 0
database/migrations/2019_07_11_234836_create_profile_sponsors_table.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateProfileSponsorsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('profile_sponsors', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('profile_id')->unsigned()->unique()->index();
+            $table->json('sponsors')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('profile_sponsors');
+    }
+}

+ 28 - 0
database/migrations/2019_07_16_010525_remove_web_subs_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class RemoveWebSubsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::dropIfExists('web_subs');
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('web_subs');
+    }
+}

+ 45 - 0
database/migrations/2019_08_07_184030_create_places_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreatePlacesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('places', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('slug')->index();
+            $table->string('name')->index();
+            $table->string('country')->index();
+            $table->json('aliases')->nullable();
+            $table->decimal('lat', 9, 6)->nullable();
+            $table->decimal('long', 9, 6)->nullable();
+            $table->unique(['slug', 'country', 'lat', 'long']);
+            $table->timestamps();
+        });
+
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->bigInteger('place_id')->unsigned()->nullable()->index();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('places');
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->dropColumn('place_id');
+        });
+    }
+}

+ 37 - 0
database/migrations/2019_08_12_074612_add_unique_to_statuses_table.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddUniqueToStatusesTable extends Migration
+{
+    public function __construct()
+    {
+        DB::getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string');
+    }
+
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->string('uri')->nullable()->unique()->index()->change();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->string('uri')->nullable()->change();
+        });
+    }
+}

File diff suppressed because it is too large
+ 324 - 230
package-lock.json


+ 9 - 5
package.json

@@ -1,4 +1,5 @@
 {
 {
+    "name": "pixelfed",
     "private": true,
     "private": true,
     "scripts": {
     "scripts": {
         "dev": "npm run development",
         "dev": "npm run development",
@@ -15,26 +16,28 @@
         "bootstrap": ">=4.3.1",
         "bootstrap": ">=4.3.1",
         "cross-env": "^5.2.0",
         "cross-env": "^5.2.0",
         "jquery": "^3.4.1",
         "jquery": "^3.4.1",
-        "lodash": "^4.17.11",
+        "lodash": ">=4.17.13",
         "popper.js": "^1.15.0",
         "popper.js": "^1.15.0",
         "resolve-url-loader": "^2.3.2",
         "resolve-url-loader": "^2.3.2",
-        "sass": "^1.21.0",
+        "sass": "^1.22.3",
         "sass-loader": "^7.1.0",
         "sass-loader": "^7.1.0",
         "vue": "^2.6.10",
         "vue": "^2.6.10",
+        "vue-masonry-css": "^1.0.3",
         "vue-template-compiler": "^2.6.10"
         "vue-template-compiler": "^2.6.10"
     },
     },
     "dependencies": {
     "dependencies": {
-        "bootstrap-vue": "^2.0.0-rc.23",
+        "@trevoreyre/autocomplete-vue": "^2.0.2",
+        "bootstrap-vue": "^2.0.0-rc.26",
         "emoji-mart-vue": "^2.6.6",
         "emoji-mart-vue": "^2.6.6",
         "filesize": "^3.6.1",
         "filesize": "^3.6.1",
         "howler": "^2.1.2",
         "howler": "^2.1.2",
         "infinite-scroll": "^3.0.6",
         "infinite-scroll": "^3.0.6",
         "laravel-echo": "^1.5.4",
         "laravel-echo": "^1.5.4",
-        "laravel-mix": "^4.0.16",
+        "laravel-mix": "^4.1.2",
         "node-sass": "^4.12.0",
         "node-sass": "^4.12.0",
         "opencollective": "^1.0.3",
         "opencollective": "^1.0.3",
         "opencollective-postinstall": "^2.0.2",
         "opencollective-postinstall": "^2.0.2",
-        "plyr": "^3.5.4",
+        "plyr": "^3.5.6",
         "promise-polyfill": "8.1.0",
         "promise-polyfill": "8.1.0",
         "pusher-js": "^4.4.0",
         "pusher-js": "^4.4.0",
         "quill": "^1.3.6",
         "quill": "^1.3.6",
@@ -43,6 +46,7 @@
         "sweetalert": "^2.1.2",
         "sweetalert": "^2.1.2",
         "twitter-text": "^2.0.5",
         "twitter-text": "^2.0.5",
         "vue-content-loader": "^0.2.2",
         "vue-content-loader": "^0.2.2",
+        "vue-cropperjs": "^4.0.0",
         "vue-infinite-loading": "^2.4.4",
         "vue-infinite-loading": "^2.4.4",
         "vue-loading-overlay": "^3.2.0",
         "vue-loading-overlay": "^3.2.0",
         "vue-timeago": "^5.1.2"
         "vue-timeago": "^5.1.2"

BIN
public/css/app.css


BIN
public/css/appdark.css


BIN
public/css/landing.css


BIN
public/img/Macbook__ipad__iphone.svg


BIN
public/js/ace.js


BIN
public/js/activity.js


BIN
public/js/app.js


BIN
public/js/collectioncompose.js


BIN
public/js/collections.js


BIN
public/js/components.js


BIN
public/js/compose.js


Some files were not shown because too many files changed in this diff