Pārlūkot izejas kodu

Merge branch 'staging' into insta-import-optimizeMedia

daniel 8 mēneši atpakaļ
vecāks
revīzija
632f590c3c
100 mainītis faili ar 20557 papildinājumiem un 10495 dzēšanām
  1. 9 5
      .circleci/config.yml
  2. 1 1
      .ddev/commands/redis/redis-cli
  3. 30 8
      .dockerignore
  4. 19 1
      .editorconfig
  5. 1300 147
      .env.docker
  6. 1 0
      .env.example
  7. 4 2
      .env.testing
  8. 7 0
      .gitattributes
  9. 0 125
      .github/workflows/build-docker.yml
  10. 230 0
      .github/workflows/docker.yml
  11. 26 17
      .gitignore
  12. 6 0
      .hadolint.yaml
  13. 4 0
      .markdownlint.json
  14. 12 0
      .shellcheckrc
  15. 14 0
      .vscode/extensions.json
  16. 21 0
      .vscode/settings.json
  17. 302 3
      CHANGELOG.md
  18. 18 0
      CODEOWNERS
  19. 364 0
      Dockerfile
  20. 7 0
      README.md
  21. 1 2
      app/Auth/BearerTokenResponse.php
  22. 57 0
      app/Console/Commands/AccountPostCountStatUpdate.php
  23. 106 0
      app/Console/Commands/AddUserDomainBlock.php
  24. 5 5
      app/Console/Commands/AvatarStorage.php
  25. 1 1
      app/Console/Commands/AvatarStorageDeepClean.php
  26. 52 0
      app/Console/Commands/CaptchaToggleCommand.php
  27. 5 1
      app/Console/Commands/CloudMediaMigrate.php
  28. 51 0
      app/Console/Commands/DeleteRemoteProfile.php
  29. 96 0
      app/Console/Commands/DeleteUserDomainBlock.php
  30. 7 7
      app/Console/Commands/FetchMissingMediaMimeType.php
  31. 1 1
      app/Console/Commands/FixMediaDriver.php
  32. 57 0
      app/Console/Commands/HashtagCachedCountUpdate.php
  33. 94 0
      app/Console/Commands/HashtagRelatedGenerate.php
  34. 118 0
      app/Console/Commands/ImportEmojis.php
  35. 54 0
      app/Console/Commands/ImportUploadMediaToCloudStorage.php
  36. 298 0
      app/Console/Commands/InstanceManager.php
  37. 79 0
      app/Console/Commands/InstanceUpdateTotalLocalPosts.php
  38. 140 0
      app/Console/Commands/MediaCloudUrlRewrite.php
  39. 1 1
      app/Console/Commands/MediaS3GarbageCollector.php
  40. 31 0
      app/Console/Commands/NotificationEpochUpdate.php
  41. 74 0
      app/Console/Commands/PushGatewayRefresh.php
  42. 37 0
      app/Console/Commands/SoftwareUpdateRefresh.php
  43. 31 22
      app/Console/Commands/TransformImports.php
  44. 123 0
      app/Console/Commands/UserAccountDelete.php
  45. 9 2
      app/Console/Commands/UserVerifyEmail.php
  46. 47 0
      app/Console/Commands/WeeklyInstanceScan.php
  47. 24 15
      app/Console/Kernel.php
  48. 17 2
      app/Contact.php
  49. 2 2
      app/Http/Controllers/AccountController.php
  50. 104 101
      app/Http/Controllers/Admin/AdminDirectoryController.php
  51. 49 0
      app/Http/Controllers/Admin/AdminGroupsController.php
  52. 1578 1048
      app/Http/Controllers/Admin/AdminReportController.php
  53. 877 273
      app/Http/Controllers/Admin/AdminSettingsController.php
  54. 658 552
      app/Http/Controllers/AdminController.php
  55. 340 0
      app/Http/Controllers/AdminCuratedRegisterController.php
  56. 2 1
      app/Http/Controllers/AdminShadowFilterController.php
  57. 184 137
      app/Http/Controllers/Api/AdminApiController.php
  58. 38 0
      app/Http/Controllers/Api/ApiController.php
  59. 4290 3746
      app/Http/Controllers/Api/ApiV1Controller.php
  60. 894 856
      app/Http/Controllers/Api/ApiV1Dot1Controller.php
  61. 314 296
      app/Http/Controllers/Api/ApiV2Controller.php
  62. 5 2
      app/Http/Controllers/Api/BaseApiController.php
  63. 4 6
      app/Http/Controllers/Api/InstanceApiController.php
  64. 147 0
      app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php
  65. 119 0
      app/Http/Controllers/Api/V1/DomainBlockController.php
  66. 209 0
      app/Http/Controllers/Api/V1/TagsController.php
  67. 1 1
      app/Http/Controllers/Auth/ForgotPasswordController.php
  68. 6 5
      app/Http/Controllers/Auth/LoginController.php
  69. 227 218
      app/Http/Controllers/Auth/RegisterController.php
  70. 1 1
      app/Http/Controllers/Auth/ResetPasswordController.php
  71. 37 0
      app/Http/Controllers/AuthorizeInteractionController.php
  72. 46 49
      app/Http/Controllers/BookmarkController.php
  73. 138 120
      app/Http/Controllers/CollectionController.php
  74. 20 24
      app/Http/Controllers/CommentController.php
  75. 782 765
      app/Http/Controllers/ComposeController.php
  76. 11 0
      app/Http/Controllers/ContactController.php
  77. 399 0
      app/Http/Controllers/CuratedRegisterController.php
  78. 899 847
      app/Http/Controllers/DirectMessageController.php
  79. 414 350
      app/Http/Controllers/DiscoverController.php
  80. 293 254
      app/Http/Controllers/FederationController.php
  81. 671 0
      app/Http/Controllers/GroupController.php
  82. 107 0
      app/Http/Controllers/GroupFederationController.php
  83. 10 0
      app/Http/Controllers/GroupPostController.php
  84. 83 0
      app/Http/Controllers/Groups/CreateGroupsController.php
  85. 353 0
      app/Http/Controllers/Groups/GroupsAdminController.php
  86. 84 0
      app/Http/Controllers/Groups/GroupsApiController.php
  87. 361 0
      app/Http/Controllers/Groups/GroupsCommentController.php
  88. 57 0
      app/Http/Controllers/Groups/GroupsDiscoverController.php
  89. 188 0
      app/Http/Controllers/Groups/GroupsFeedController.php
  90. 214 0
      app/Http/Controllers/Groups/GroupsMemberController.php
  91. 31 0
      app/Http/Controllers/Groups/GroupsMetaController.php
  92. 55 0
      app/Http/Controllers/Groups/GroupsNotificationsController.php
  93. 420 0
      app/Http/Controllers/Groups/GroupsPostController.php
  94. 221 0
      app/Http/Controllers/Groups/GroupsSearchController.php
  95. 133 0
      app/Http/Controllers/Groups/GroupsTopicController.php
  96. 25 1
      app/Http/Controllers/Import/Instagram.php
  97. 14 3
      app/Http/Controllers/ImportPostController.php
  98. 412 430
      app/Http/Controllers/InternalApiController.php
  99. 20 21
      app/Http/Controllers/LandingController.php
  100. 19 18
      app/Http/Controllers/MediaController.php

+ 9 - 5
.circleci/config.yml

@@ -7,7 +7,7 @@ jobs:
   build:
     docker:
       # Specify the version you desire here
-      - image: cimg/php:8.2.5
+      - image: cimg/php:8.3.8
 
       # Specify service dependencies here if necessary
       # CircleCI maintains a library of pre-built images
@@ -21,7 +21,12 @@ jobs:
     steps:
       - checkout
 
-      - run: sudo apt update && sudo apt install zlib1g-dev libsqlite3-dev
+      - run:
+          name: "Create Environment file and generate app key"
+          command: |
+            mv .env.testing .env
+
+      - run: sudo apt install zlib1g-dev libsqlite3-dev
 
       # Download and cache dependencies
 
@@ -36,18 +41,17 @@ jobs:
       - run: composer install -n --prefer-dist
 
       - save_cache:
-          key: composer-v2-{{ checksum "composer.lock" }}
+          key: v2-dependencies-{{ checksum "composer.json" }}
           paths:
             - vendor
 
-      - run: cp .env.testing .env
       - run: php artisan config:cache
       - run: php artisan route:clear
       - run: php artisan storage:link
       - run: php artisan key:generate
 
       # run tests with phpunit or codecept
-      - run: ./vendor/bin/phpunit
+      - run: php artisan test
       - store_test_results:
           path: tests/_output
       - store_artifacts:

+ 1 - 1
.ddev/commands/redis/redis-cli

@@ -4,4 +4,4 @@
 ## Usage: redis-cli [flags] [args]
 ## Example: "redis-cli KEYS *" or "ddev redis-cli INFO" or "ddev redis-cli --version"
 
-redis-cli -p 6379 -h redis $@
+exec redis-cli -p 6379 -h redis "$@"

+ 30 - 8
.dockerignore

@@ -1,8 +1,30 @@
-data
-Dockerfile
-contrib/docker/Dockerfile.*
-docker-compose*.yml
-.dockerignore
-.git
-.gitignore
-.env
+.DS_Store
+/.bash_history
+/.bash_profile
+/.bashrc
+/.composer
+/.env
+/.env.dottie-backup
+/.git
+/.git-credentials
+/.gitconfig
+/.gitignore
+/.idea
+/.vagrant
+/bootstrap/cache
+/docker-compose-state/
+/Homestead.json
+/Homestead.yaml
+/node_modules
+/npm-debug.log
+/public/hot
+/public/storage
+/public/vendor/horizon
+/storage/*.key
+/storage/docker
+/vendor
+/yarn-error.log
+
+# Exceptions - these *MUST* be last
+!/bootstrap/cache/.gitignore
+!/public/vendor/horizon/.gitignore

+ 19 - 1
.editorconfig

@@ -1,9 +1,27 @@
 root = true
 
 [*]
+indent_style = space
 indent_size = 4
-indent_style = tab
 end_of_line = lf
 charset = utf-8
 trim_trailing_whitespace = true
 insert_final_newline = true
+
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+[*.{sh,envsh,env,env*}]
+indent_style = space
+indent_size = 4
+
+# ShellCheck config
+shell_variant      = bash  # like -ln=bash
+binary_next_line   = true  # like -bn
+switch_case_indent = true  # like -ci
+space_redirects    = false # like -sr
+keep_padding       = false # like -kp
+function_next_line = true  # like -fn
+never_split        = true  # like -ns
+simplify           = true

+ 1300 - 147
.env.docker

@@ -1,149 +1,1302 @@
-## Crypto
+#!/bin/bash
+# -*- mode: bash -*-
+# vi: ft=bash
+# shellcheck disable=SC2034,SC2148
+
+# Use Dottie (https://github.com/jippi/dottie) to manage this .env file easier!
+#
+# For example:
+#
+#   Run [dottie update] to update your [.env] file with upstream (as part of upgrade)
+#   Run [dottie validate] to validate youe [.env] file
+#
+# @dottie/source .env.docker
+
+################################################################################
+# app
+################################################################################
+
+# The name/title for your site
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_name-1
+# @dottie/example My Pixelfed Site
+# @dottie/validate required,ne=My Pixelfed Site
+APP_NAME=
+
+# Application domain used for routing. (e.g., pixelfed.org)
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_domain
+# @dottie/example example.com
+# @dottie/validate required,ne=example.com,fqdn
+APP_DOMAIN="example.com"
+
+# This URL is used by the console to properly generate URLs when using the Artisan command line tool.
+# You should set this to the root of your application so that it is used when running Artisan tasks.
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_url
+# @dottie/validate required,http_url
+APP_URL="https://${APP_DOMAIN}"
+
+# Application domains used for routing.
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#admin_domain
+# @dottie/validate required,fqdn
+ADMIN_DOMAIN="${APP_DOMAIN}"
+
+# This value determines the “environment” your application is currently running in.
+# This may determine how you prefer to configure various services your application utilizes.
+#
+# @default "production"
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_env
+# @dottie/validate required,oneof=production dev staging
+#APP_ENV="production"
+
+# When your application is in debug mode, detailed error messages with stack traces will
+# be shown on every error that occurs within your application.
+#
+# If disabled, a simple generic error page is shown.
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_debug
+# @dottie/validate required,boolean
+#APP_DEBUG="false"
+
+# Disable config cache
+#
+# If disabled, settings must be managed by .env variables.
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#config_cache
+# @dottie/validate required,boolean
+ENABLE_CONFIG_CACHE="true"
+
+# Enable/disable new local account registrations.
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#open_registration
+# @dottie/validate required,boolean
+#OPEN_REGISTRATION="true"
+
+# Require email verification before a new user can do anything.
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#enforce_email_verification
+# @dottie/validate required,boolean
+#ENFORCE_EMAIL_VERIFICATION="true"
+
+# Allow a maximum number of user accounts.
+#
+# @default "1000"
+# @see https://docs.pixelfed.org/technical-documentation/config/#pf_max_users
+# @dottie/validate required,number
+#PF_MAX_USERS="1000"
+
+# Enforce the maximum number of user accounts
+#
+# @default "true"
+# @dottie/validate boolean
+#PF_ENFORCE_MAX_USERS="true"
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#oauth_enabled
+# @dottie/validate required,boolean
+#OAUTH_ENABLED="false"
+
+# ! Do not edit your timezone once the service is running - or things will break!
+#
+# @default "UTC"
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_timezone
+# @see https://www.php.net/manual/en/timezones.php
+# @dottie/validate required,timezone
+APP_TIMEZONE="UTC"
+
+# The application locale determines the default locale that will be used by the translation service provider.
+# You are free to set this value to any of the locales which will be supported by the application.
+#
+# @default "en"
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_locale
+# @dottie/validate required
+#APP_LOCALE="en"
+
+# The fallback locale determines the locale to use when the current one is not available.
+#
+# You may change the value to correspond to any of the language folders that are provided through your application.
+#
+# @default "en"
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_fallback_locale
+# @dottie/validate required
+#APP_FALLBACK_LOCALE="en"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#limit_account_size
+# @dottie/validate required,boolean
+#LIMIT_ACCOUNT_SIZE="true"
+
+# Update the max account size, the per user limit of files in kB.
+#
+# @default "1000000" (1GB)
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_account_size-kb
+# @dottie/validate required,number
+#MAX_ACCOUNT_SIZE="1000000"
+
+# Update the max photo size, in kB.
+#
+# @default "15000" (15MB)
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_photo_size-kb
+# @dottie/validate required,number
+#MAX_PHOTO_SIZE="15000"
+
+# The max number of photos allowed per post.
+#
+# @default "4"
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_album_length
+# @dottie/validate required,number
+#MAX_ALBUM_LENGTH="4"
+
+# Update the max avatar size, in kB.
+#
+# @default "2000" (2MB).
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_avatar_size-kb
+# @dottie/validate required,number
+#MAX_AVATAR_SIZE="2000"
+
+# Change the caption length limit for new local posts.
+#
+# @default "500"
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_caption_length
+# @dottie/validate required,number
+#MAX_CAPTION_LENGTH="500"
+
+# Change the bio length limit for user profiles.
+#
+# @default "125"
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_bio_length
+# @dottie/validate required,number
+#MAX_BIO_LENGTH="125"
+
+# Change the length limit for user names.
+#
+# @default "30"
+# @see https://docs.pixelfed.org/technical-documentation/config/#max_name_length
+# @dottie/validate required,number
+#MAX_NAME_LENGTH="30"
+
+# Resize and optimize image uploads.
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#pf_optimize_images
+# @dottie/validate required,boolean
+#PF_OPTIMIZE_IMAGES="true"
+
+# Set the image optimization quality, must be a value between 1-100.
+#
+# @default "80"
+# @see https://docs.pixelfed.org/technical-documentation/config/#image_quality
+# @dottie/validate required,number
+#IMAGE_QUALITY="80"
+
+# Resize and optimize video uploads.
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#pf_optimize_videos
+# @dottie/validate required,boolean
+#PF_OPTIMIZE_VIDEOS="true"
+
+# Enable account deletion.
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#account_deletion
+# @dottie/validate required,boolean
+#ACCOUNT_DELETION="true"
+
+# Set account deletion queue after X days, set to false to delete accounts immediately.
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#account_delete_after
+# @dottie/validate required,boolean|number
+#ACCOUNT_DELETE_AFTER="false"
+
+# @default "Pixelfed - Photo sharing for everyone"
+# @see https://docs.pixelfed.org/technical-documentation/config/#instance_description
+# @dottie/validate required
+#INSTANCE_DESCRIPTION=""
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#instance_public_hashtags
+# @dottie/validate required,boolean
+#INSTANCE_PUBLIC_HASHTAGS="false"
+
+# The public e-mail address people can use to contact you by
+#
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#instance_contact_email
+# @dottie/validate required,ne=__CHANGE_ME__,email
+INSTANCE_CONTACT_EMAIL="__CHANGE_ME__"
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#instance_public_local_timeline
+# @dottie/validate required,boolean
+#INSTANCE_PUBLIC_LOCAL_TIMELINE="false"
+
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#banned_usernames
+#BANNED_USERNAMES=""
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#stories_enabled
+# @dottie/validate required,boolean
+#STORIES_ENABLED="false"
+
+# Level is hardcoded to 1.
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#restricted_instance
+# @dottie/validate required,boolean
+#RESTRICTED_INSTANCE="false"
+
+# @default false
+# @see https://docs.pixelfed.org/technical-documentation/config/#media_exif_database
+# @dottie/validate required,boolean
+#MEDIA_EXIF_DATABASE="false"
+
+# Pixelfed supports GD or ImageMagick to process images.
+#
+# Possible values:
+#  - "gd" (default)
+#  - "imagick"
+#
+# @default "gd"
+# @see https://docs.pixelfed.org/technical-documentation/config/#image_driver
+# @dottie/validate required,oneof=gd imagick
+#IMAGE_DRIVER="gd"
+
+# Set trusted proxy IP addresses.
+#
+# Both IPv4 and IPv6 addresses are supported, along with CIDR notation.
+#
+# The “*” character is syntactic sugar within TrustedProxy to trust any
+# proxy that connects directly to your server, a requirement when you cannot
+# know the address of your proxy (e.g. if using Rackspace balancers).
+#
+# The “**” character is syntactic sugar within TrustedProxy to trust not just any
+# proxy that connects directly to your server, but also proxies that connect to those proxies,
+# and all the way back until you reach the original source IP.  It will mean that
+# $request->getClientIp() always gets the originating client IP, no matter how many proxies
+# that client’s request has subsequently passed through.
+#
+# @default "*"
+# @see https://docs.pixelfed.org/technical-documentation/config/#trust_proxies
+# @dottie/validate required
+#TRUST_PROXIES="*"
+
+# This option controls the default cache connection that gets used while using this caching library.
+#
+# This connection is used when another is not explicitly specified when executing a given caching function.
+#
+# Possible values:
+# - "apc"
+# - "array"
+# - "database"
+# - "file" (default)
+# - "memcached"
+# - "redis"
+#
+# @default "file"
+# @see https://docs.pixelfed.org/technical-documentation/config/#cache_driver
+# @dottie/validate required,oneof=apc array database file memcached redis
+CACHE_DRIVER="redis"
+
+# @default ${APP_NAME}_cache, or laravel_cache if no APP_NAME is set.
+# @see https://docs.pixelfed.org/technical-documentation/config/#cache_prefix
+# @dottie/validate required
+#CACHE_PREFIX="{APP_NAME}_cache"
+
+# This option controls the default broadcaster that will be used by the framework when an event needs to be broadcast.
+#
+# Possible values:
+# - "pusher"
+# - "redis"
+# - "log"
+# - "null" (default)
+#
+# @default null
+# @see https://docs.pixelfed.org/technical-documentation/config/#broadcast_driver
+# @dottie/validate required,oneof=pusher redis log null
+BROADCAST_DRIVER="redis"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#restrict_html_types
+# @dottie/validate required,boolean
+#RESTRICT_HTML_TYPES="true"
+
+# Passport uses encryption keys while generating secure access tokens
+# for your application.
+#
+# By default, the keys are stored as local files but can be set via environment
+# variables when that is more convenient.
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#passport_private_key
+# @dottie/validate required
+#PASSPORT_PRIVATE_KEY=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#passport_public_key
+# @dottie/validate required
+#PASSPORT_PUBLIC_KEY=""
+
+################################################################################
+# database
+################################################################################
+
+# Database version to use (as Docker tag)
+#
+# @see https://hub.docker.com/_/mariadb
+# @dottie/validate required
+DB_VERSION="11.2"
+
+# Here you may specify which of the database connections below
+# you wish to use as your default connection for all database work.
+#
+# Of course you may use many connections at once using the database library.
+#
+# Possible values:
+#
+# - "sqlite"
+# - "mysql" (default)
+# - "pgsql"
+# - "sqlsrv"
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_connection
+# @dottie/validate required,oneof=sqlite mysql pgsql sqlsrv
+DB_CONNECTION="mysql"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_host
+# @dottie/validate required,hostname
+DB_HOST="db"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_username
+# @dottie/validate required
+DB_USERNAME="pixelfed"
+
+# The password to your database. Please make it secure.
+# Use a site like https://pwgen.io/ to generate it
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_password
+# @dottie/validate required
+DB_PASSWORD=
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_database
+# @dottie/validate required
+DB_DATABASE="pixelfed_prod"
+
+# Use "3306" for MySQL/MariaDB and "5432" for PostgreeSQL
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#db_port
+# @dottie/validate required,number
+DB_PORT="3306"
+
+# Automatically run [artisan migrate --force] if new migrations are detected.
+# @dottie/validate required,boolean
+DB_APPLY_NEW_MIGRATIONS_AUTOMATICALLY="false"
+
+################################################################################
+# mail
+################################################################################
+
+# Laravel supports both SMTP and PHP’s “mail” function as drivers for the sending of e-mail.
+# You may specify which one you’re using throughout your application here.
+#
+# Possible values:
+#
+# "smtp" (default)
+# "sendmail"
+# "mailgun"
+# "mandrill"
+# "ses"
+# "sparkpost"
+# "log"
+# "array"
+#
+# @default "smtp"
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_driver
+# @dottie/validate required,oneof=smtp sendmail mailgun mandrill ses sparkpost log array
+#MAIL_DRIVER="smtp"
+
+# The host address of the SMTP server used by your applications.
+#
+# A default option is provided that is compatible with the Mailgun mail service which will provide reliable deliveries.
+#
+# @default "smtp.mailgun.org"
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_host
+# @dottie/validate required_with=MAIL_DRIVER,fqdn
+#MAIL_HOST="smtp.mailgun.org"
+
+# This is the SMTP port used by your application to deliver e-mails to users of the application.
+#
+# Like the host we have set this value to stay compatible with the Mailgun e-mail application by default.
+#
+# @default 587.
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_port
+# @dottie/validate required_with=MAIL_DRIVER,number
+#MAIL_PORT="587"
+
+# Here, you may specify a name and address that is used globally for all e-mails that are sent by your application.
+#
+# You may wish for all e-mails sent by your application to be sent from the same address.
+#
+# @default "bot@example.com"
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_from_address
+# @dottie/validate required_with=MAIL_DRIVER,email,ne=__CHANGE_ME__
+#MAIL_FROM_ADDRESS="__CHANGE_ME__"
+
+# The 'name' you send e-mail from
+#
+# @default "Example"
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_from_name
+# @dottie/validate required_with=MAIL_DRIVER
+#MAIL_FROM_NAME="${APP_NAME}"
+
+# If your SMTP server requires a username for authentication, you should set it here.
+#
+# This will get used to authenticate with your server on connection.
+# You may also set the “password” value below this one.
+#
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_username
+# @dottie/validate required_with=MAIL_DRIVER
+#MAIL_USERNAME=""
+
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_password
+# @dottie/validate required_with=MAIL_DRIVER
+#MAIL_PASSWORD=""
+
+# Here you may specify the encryption protocol that should be used when the application send e-mail messages.
+#
+# A sensible default using the transport layer security protocol should provide great security.
+#
+# @default "tls"
+# @see https://docs.pixelfed.org/technical-documentation/config/#mail_encryption
+# @dottie/validate required_with=MAIL_DRIVER
+#MAIL_ENCRYPTION="tls"
+
+################################################################################
+# redis
+################################################################################
+
+# @default "phpredis"
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_client
+# @dottie/validate required
+#REDIS_CLIENT="phpredis"
+
+# @default "tcp"
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_scheme
+# @dottie/validate required
+#REDIS_SCHEME="tcp"
+
+# @default "localhost"
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_host
+# @dottie/validate required
+REDIS_HOST="redis"
+
+# @default "null" (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_password
+# @dottie/validate omitempty
+#REDIS_PASSWORD=
+
+# @default "6379"
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_port
+# @dottie/validate required,number
+REDIS_PORT="6379"
+
+# @default "0"
+# @see https://docs.pixelfed.org/technical-documentation/config/#redis_database
+# @dottie/validate required,number
+#REDIS_DATABASE="0"
+
+################################################################################
+# experiments
+################################################################################
+
+# Text only posts (alpha).
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#exp_top
+# @dottie/validate required,boolean
+#EXP_TOP="false"
+
+# Poll statuses (alpha).
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#exp_polls
+# @dottie/validate required,boolean
+#EXP_POLLS="false"
+
+# Cached public timeline for larger instances (beta).
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#exp_cpt
+# @dottie/validate required,boolean
+#EXP_CPT="false"
+
+# Enforce Mastodon API Compatibility (alpha).
+#
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#exp_emc
+# @dottie/validate required,boolean
+#EXP_EMC="true"
+
+################################################################################
+# ActivityPub
+################################################################################
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#activity_pub
+# @dottie/validate required,boolean
+#ACTIVITY_PUB="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#ap_remote_follow
+# @dottie/validate required,boolean
+#AP_REMOTE_FOLLOW="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#ap_sharedinbox
+# @dottie/validate required,boolean
+#AP_SHAREDINBOX="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#ap_inbox
+# @dottie/validate required,boolean
+#AP_INBOX="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#ap_outbox
+# @dottie/validate required,boolean
+#AP_OUTBOX="true"
+
+################################################################################
+# Federation
+################################################################################
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#atom_feeds
+# @dottie/validate required,boolean
+#ATOM_FEEDS="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#nodeinfo
+# @dottie/validate required,boolean
+#NODEINFO="true"
+
+# @default "true"
+# @see https://docs.pixelfed.org/technical-documentation/config/#webfinger
+# @dottie/validate required,boolean
+#WEBFINGER="true"
+
+################################################################################
+# Storage
+################################################################################
+
+# Store media on object storage like S3, Digital Ocean Spaces, Rackspace
+#
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#pf_enable_cloud
+# @dottie/validate required,boolean
+#PF_ENABLE_CLOUD="false"
+
+# Many applications store files both locally and in the cloud.
+#
+# For this reason, you may specify a default “cloud” driver here.
+# This driver will be bound as the Cloud disk implementation in the container.
+#
+# @default "s3"
+# @see https://docs.pixelfed.org/technical-documentation/config/#filesystem_cloud
+# @dottie/validate required_with=PF_ENABLE_CLOUD
+#FILESYSTEM_CLOUD="s3"
+
+# @default true.
+# @see https://docs.pixelfed.org/technical-documentation/config/#media_delete_local_after_cloud
+# @dottie/validate required_with=PF_ENABLE_CLOUD,boolean
+#MEDIA_DELETE_LOCAL_AFTER_CLOUD="true"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_access_key_id
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_ACCESS_KEY_ID=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_secret_access_key
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_SECRET_ACCESS_KEY=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_default_region
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_DEFAULT_REGION=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_bucket
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_BUCKET=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_url
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_URL=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_endpoint
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_ENDPOINT=""
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#aws_use_path_style_endpoint
+# @dottie/validate required_if=FILESYSTEM_CLOUD s3
+#AWS_USE_PATH_STYLE_ENDPOINT="false"
+
+################################################################################
+# COSTAR
+################################################################################
+
+# Comma-separated list of domains to block.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_blocked_domains
+# @dottie/validate
+#CS_BLOCKED_DOMAINS=""
+
+# Comma-separated list of domains to add warnings.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_cw_domains
+# @dottie/validate
+#CS_CW_DOMAINS=""
+
+# Comma-separated list of domains to remove from public timelines.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_unlisted_domains
+# @dottie/validate
+#CS_UNLISTED_DOMAINS=""
+
+# Comma-separated list of keywords to block.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_blocked_keywords
+# @dottie/validate
+#CS_BLOCKED_KEYWORDS=""
+
+# Comma-separated list of keywords to add warnings.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_cw_keywords
+# @dottie/validate
+#CS_CW_KEYWORDS=""
+
+# Comma-separated list of keywords to remove from public timelines.
+#
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_unlisted_keywords
+# @dottie/validate
+#CS_UNLISTED_KEYWORDS=""
+
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_blocked_actor
+# @dottie/validate
+#CS_BLOCKED_ACTOR=""
+
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_cw_actor
+# @dottie/validate
+#CS_CW_ACTOR=""
+
+# @default null (not set/commented out).
+# @see https://docs.pixelfed.org/technical-documentation/config/#cs_unlisted_actor
+# @dottie/validate
+#CS_UNLISTED_ACTOR=""
+
+################################################################################
+# logging
+################################################################################
+
+# Possible values:
+#
+# - "stack" (default)
+# - "single"
+# - "daily"
+# - "slack"
+# - "stderr"
+# - "syslog"
+# - "errorlog"
+# - "null"
+# - "emergency"
+# - "media"
+#
+# @default "stack"
+# @dottie/validate required,oneof=stack single daily slack stderr syslog errorlog null emergency media
+LOG_CHANNEL="stderr"
+
+# Used by single, stderr and syslog.
+#
+# @default "debug"
+# @see https://docs.pixelfed.org/technical-documentation/config/#log_level
+# @dottie/validate required,oneof=debug info notice warning error critical alert emergency
+#LOG_LEVEL="debug"
+
+# Used by stderr.
+#
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#log_stderr_formatter
+#LOG_STDERR_FORMATTER=""
+
+# Used by slack.
+#
+# @default ""
+# @see https://docs.pixelfed.org/technical-documentation/config/#log_slack_webhook_url
+# @dottie/validate required,http_url
+#LOG_SLACK_WEBHOOK_URL=""
+
+################################################################################
+# queue
+################################################################################
+
+# Possible values:
+#   - "sync" (default)
+#   - "database"
+#   - "beanstalkd"
+#   - "sqs"
+#   - "redis"
+#   - "null"
+#
+# @default "sync"
+# @see https://docs.pixelfed.org/technical-documentation/config/#queue_driver
+# @dottie/validate required,oneof=sync database beanstalkd sqs redis null
+QUEUE_DRIVER="redis"
+
+# @default "your-public-key"
+# @see https://docs.pixelfed.org/technical-documentation/config/#sqs_key
+# @dottie/validate required_if=QUEUE_DRIVER sqs
+#SQS_KEY="your-public-key"
+
+# @default "your-secret-key"
+# @see https://docs.pixelfed.org/technical-documentation/config/#sqs_secret
+# @dottie/validate required_if=QUEUE_DRIVER sqs
+#SQS_SECRET="your-secret-key"
+
+# @default "https://sqs.us-east-1.amazonaws.com/your-account-id"
+# @see https://docs.pixelfed.org/technical-documentation/config/#sqs_prefix
+# @dottie/validate required_if=QUEUE_DRIVER sqs
+#SQS_PREFIX=""
+
+# @default "your-queue-name"
+# @see https://docs.pixelfed.org/technical-documentation/config/#sqs_queue
+# @dottie/validate required_if=QUEUE_DRIVER sqs
+#SQS_QUEUE="your-queue-name"
+
+# @default "us-east-1"
+# @see https://docs.pixelfed.org/technical-documentation/config/#sqs_region
+# @dottie/validate required_if=QUEUE_DRIVER sqs
+#SQS_REGION="us-east-1"
+
+################################################################################
+# session
+################################################################################
+
+# This option controls the default session “driver” that will be used on requests.
+#
+# By default, we will use the lightweight native driver but you may specify any of the other wonderful drivers provided here.
+#
+# Possible values:
+#   - "file"
+#   - "cookie"
+#   - "database" (default)
+#   - "apc"
+#   - "memcached"
+#   - "redis"
+#   - "array"
+#
+# @default "database"
+# @dottie/validate required,oneof=file cookie database apc memcached redis array
+SESSION_DRIVER="redis"
+
+# Here you may specify the number of minutes that you wish the session to be allowed to remain idle before it expires.
+#
+# If you want them to immediately expire on the browser closing, set that option.
+#
+# @default 86400.
+# @see https://docs.pixelfed.org/technical-documentation/config/#session_lifetime
+# @dottie/validate required,number
+#SESSION_LIFETIME="86400"
+
+# Here you may change the domain of the cookie used to identify a session in your application.
+#
+# This will determine which domains the cookie is available to in your application.
+#
+# A sensible default has been set.
+#
+# @default the value of APP_DOMAIN, or null.
+# @see https://docs.pixelfed.org/technical-documentation/config/#session_domain
+# @dottie/validate required,hostname
+#SESSION_DOMAIN="${APP_DOMAIN}"
+
+################################################################################
+# horizon
+################################################################################
+
+# This prefix will be used when storing all Horizon data in Redis.
+#
+# You may modify the prefix when you are running multiple installations
+# of Horizon on the same server so that they don’t have problems.
+#
+# @default "horizon-"
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_prefix
+# @dottie/validate required
+#HORIZON_PREFIX="horizon-"
+
+# @default "false"
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_darkmode
+# @dottie/validate required,boolean
+#HORIZON_DARKMODE="false"
+
+# This value (in MB) describes the maximum amount of memory (in MB) the Horizon worker
+# may consume before it is terminated and restarted.
+#
+# You should set this value according to the resources available to your server.
+#
+# @default "64"
+# @dottie/validate required,number
+#HORIZON_MEMORY_LIMIT="64"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_balance_strategy
+# @dottie/validate required
+#HORIZON_BALANCE_STRATEGY="auto"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_min_processes
+# @dottie/validate required,number
+#HORIZON_MIN_PROCESSES="1"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_max_processes
+# @dottie/validate required,number
+#HORIZON_MAX_PROCESSES="20"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_supervisor_memory
+# @dottie/validate required,number
+#HORIZON_SUPERVISOR_MEMORY="64"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_supervisor_tries
+# @dottie/validate required,number
+#HORIZON_SUPERVISOR_TRIES="3"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_supervisor_nice
+# @dottie/validate required,number
+#HORIZON_SUPERVISOR_NICE="0"
+
+# @see https://docs.pixelfed.org/technical-documentation/config/#horizon_supervisor_timeout
+# @dottie/validate required,number
+#HORIZON_SUPERVISOR_TIMEOUT="300"
+
+################################################################################
+# docker shared
+################################################################################
+
+# A random 32-character string to be used as an encryption key.
+#
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+# ! NOTE: This will be auto-generated by Docker during bootstrap
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+#
+# This key is used by the Illuminate encrypter service and should be set to a random,
+# 32 character string, otherwise these encrypted strings will not be safe.
+#
+# @see https://docs.pixelfed.org/technical-documentation/config/#app_key
+# @dottie/validate required
 APP_KEY=
 
-## General Settings
-APP_NAME="Pixelfed Prod"
-APP_ENV=production
-APP_DEBUG=false
-APP_URL=https://real.domain
-APP_DOMAIN="real.domain"
-ADMIN_DOMAIN="real.domain"
-SESSION_DOMAIN="real.domain"
-
-OPEN_REGISTRATION=true
-ENFORCE_EMAIL_VERIFICATION=false
-PF_MAX_USERS=1000
-OAUTH_ENABLED=true
-
-APP_TIMEZONE=UTC
-APP_LOCALE=en
-
-## Pixelfed Tweaks
-LIMIT_ACCOUNT_SIZE=true
-MAX_ACCOUNT_SIZE=1000000
-MAX_PHOTO_SIZE=15000
-MAX_AVATAR_SIZE=2000
-MAX_CAPTION_LENGTH=500
-MAX_BIO_LENGTH=125
-MAX_NAME_LENGTH=30
-MAX_ALBUM_LENGTH=4
-IMAGE_QUALITY=80
-PF_OPTIMIZE_IMAGES=true
-PF_OPTIMIZE_VIDEOS=true
-ADMIN_ENV_EDITOR=false
-ACCOUNT_DELETION=true
-ACCOUNT_DELETE_AFTER=false
-MAX_LINKS_PER_POST=0
-
-## Instance
-#INSTANCE_DESCRIPTION=
-INSTANCE_PUBLIC_HASHTAGS=false
-#INSTANCE_CONTACT_EMAIL=
-INSTANCE_PUBLIC_LOCAL_TIMELINE=false
-#BANNED_USERNAMES=
-STORIES_ENABLED=false
-RESTRICTED_INSTANCE=false
-
-## Mail
-MAIL_DRIVER=log
-MAIL_HOST=smtp.mailtrap.io
-MAIL_PORT=2525
-MAIL_FROM_ADDRESS="pixelfed@example.com"
-MAIL_FROM_NAME="Pixelfed"
-MAIL_USERNAME=null
-MAIL_PASSWORD=null
-MAIL_ENCRYPTION=null
-
-## Databases (MySQL)
-DB_CONNECTION=mysql
-DB_DATABASE=pixelfed_prod
-DB_HOST=db
-DB_PASSWORD=pixelfed_db_pass
-DB_PORT=3306
-DB_USERNAME=pixelfed
-# pass the same values to the db itself
-MYSQL_DATABASE=pixelfed_prod
-MYSQL_PASSWORD=pixelfed_db_pass
-MYSQL_RANDOM_ROOT_PASSWORD=true
-MYSQL_USER=pixelfed
-
-## Databases (Postgres)
-#DB_CONNECTION=pgsql
-#DB_HOST=postgres
-#DB_PORT=5432
-#DB_DATABASE=pixelfed
-#DB_USERNAME=postgres
-#DB_PASSWORD=postgres
-
-## Cache (Redis)
-REDIS_CLIENT=phpredis
-REDIS_SCHEME=tcp
-REDIS_HOST=redis
-REDIS_PASSWORD=redis_password
-REDIS_PORT=6379
-REDIS_DATABASE=0
-
-HORIZON_PREFIX="horizon-"
-
-## EXPERIMENTS 
-EXP_LC=false
-EXP_REC=false
-EXP_LOOPS=false
-
-## ActivityPub Federation
-ACTIVITY_PUB=false
-AP_REMOTE_FOLLOW=false
-AP_SHAREDINBOX=false
-AP_INBOX=false
-AP_OUTBOX=false
-ATOM_FEEDS=true
-NODEINFO=true
-WEBFINGER=true
-
-## S3
-FILESYSTEM_CLOUD=s3
-PF_ENABLE_CLOUD=false
-#AWS_ACCESS_KEY_ID=
-#AWS_SECRET_ACCESS_KEY=
-#AWS_DEFAULT_REGION=
-#AWS_BUCKET=
-#AWS_URL=
-#AWS_ENDPOINT=
-#AWS_USE_PATH_STYLE_ENDPOINT=false
-
-## Horizon
-HORIZON_DARKMODE=false
-
-## COSTAR - Confirm Object Sentiment Transform and Reduce
-PF_COSTAR_ENABLED=false
-
-# Media
-MEDIA_EXIF_DATABASE=false
-
-## Logging
-LOG_CHANNEL=stderr
-
-## Image
-IMAGE_DRIVER=imagick
-
-## Broadcasting: log driver for local development
-BROADCAST_DRIVER=log
-
-## Cache
-CACHE_DRIVER=redis
-
-## Purify
-RESTRICT_HTML_TYPES=true
-
-## Queue
-QUEUE_DRIVER=redis
-
-## Session
-SESSION_DRIVER=redis
-
-## Trusted Proxy
-TRUST_PROXIES="*"
-
-## Passport
-#PASSPORT_PRIVATE_KEY=
-#PASSPORT_PUBLIC_KEY=
+# Prefix for container names (without any dash at the end)
+# @dottie/validate required
+DOCKER_ALL_CONTAINER_NAME_PREFIX="${APP_DOMAIN}"
+
+# How often Docker health check should run for all services
+#
+# Can be overridden by individual [DOCKER_*_HEALTHCHECK_INTERVAL] settings further down
+#
+# @default "10s"
+# @dottie/validate required
+DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL="10s"
+
+# Path (relative to the docker-compose.yml) or absolute (/some/other/path) where containers will *all* data
+# will be stored (data, config, overrides)
+#
+# @default "./docker-compose-state"
+# @dottie/validate required,dir
+DOCKER_ALL_HOST_ROOT_PATH="./docker-compose-state"
+
+# Path (relative to the docker-compose.yml) or absolute (/some/other/path) where containers will store their data
+#
+# @default "${DOCKER_ALL_HOST_ROOT_PATH}/data"
+# @dottie/validate required,dir
+DOCKER_ALL_HOST_DATA_ROOT_PATH="${DOCKER_ALL_HOST_ROOT_PATH:?error}/data"
+
+# Path (relative to the docker-compose.yml) or absolute (/some/other/path) where containers will store their confguration
+#
+# @default "${DOCKER_ALL_HOST_ROOT_PATH}/config"
+# @dottie/validate required,dir
+DOCKER_ALL_HOST_CONFIG_ROOT_PATH="${DOCKER_ALL_HOST_ROOT_PATH:?error}/config"
+
+# Path (relative to the docker-compose.yml) or absolute (/some/other/path) where containers will store overrides
+#
+# @default "${DOCKER_ALL_HOST_ROOT_PATH}/overrides"
+# @dottie/validate required,dir
+DOCKER_APP_HOST_OVERRIDES_PATH="${DOCKER_ALL_HOST_ROOT_PATH:?error}/overrides"
+
+# Set timezone used by *all* containers - these must be in sync.
+#
+# ! Do not edit your timezone once the service is running - or things will break!
+#
+# @see https://www.php.net/manual/en/timezones.php
+# @dottie/validate required,timezone
+TZ="${APP_TIMEZONE}"
+
+################################################################################
+# docker app
+################################################################################
+
+# The docker tag prefix to use for pulling images, can be one of
+#
+#  * latest
+#  * <some semver release>
+#  * staging
+#  * edge
+#  * branch-<some branch name>
+#  * pr-<some merge request id>
+#
+# Combined with [DOCKER_APP_RUNTIME] and [PHP_VERSION] configured
+# elsewhere in this file, the final Docker tag is computed.
+# @dottie/validate required
+DOCKER_APP_RELEASE="branch-jippi-fork"
+
+# The PHP version to use for [web] and [worker] container
+#
+# Any version published on https://hub.docker.com/_/php should work
+#
+# Example:
+#
+#   * 8.1
+#   * 8.2
+#   * 8.2.14
+#   * latest
+#
+# Do *NOT* use the full Docker tag (e.g. "8.3.2RC1-fpm-bullseye")
+# *only* the version part. The rest of the full tag is derived from
+# the [DOCKER_APP_RUNTIME] and [PHP_DEBIAN_RELEASE] settings
+# @dottie/validate required
+DOCKER_APP_PHP_VERSION="8.2"
+
+# The container runtime to use.
+#
+# @see https://docs.pixelfed.org/running-pixelfed/docker/runtimes.html
+# @dottie/validate required,oneof=apache nginx fpm
+DOCKER_APP_RUNTIME="apache"
+
+# The Debian release variant to use of the [php] Docker image
+#
+# Examlpe: [bookworm] or [bullseye]
+# @dottie/validate required,oneof=bookworm bullseye
+DOCKER_APP_DEBIAN_RELEASE="bullseye"
+
+# The [php] Docker image base type
+#
+# @see https://docs.pixelfed.org/running-pixelfed/docker/runtimes.html
+# @dottie/validate required,oneof=apache fpm cli
+DOCKER_APP_BASE_TYPE="apache"
+
+# Image to pull the Pixelfed Docker images from.
+#
+# Example values:
+#
+#   * "ghcr.io/pixelfed/pixelfed" to pull from GitHub
+#   * "pixelfed/pixelfed"         to pull from DockerHub
+#   * "your/fork"                 to pull from a custom fork
+#
+# @dottie/validate required
+DOCKER_APP_IMAGE="ghcr.io/jippi/pixelfed"
+
+# Pixelfed version (image tag) to pull from the registry.
+#
+# @see https://github.com/pixelfed/pixelfed/pkgs/container/pixelfed
+# @dottie/validate required
+DOCKER_APP_TAG="${DOCKER_APP_RELEASE:?error}-${DOCKER_APP_RUNTIME:?error}-${DOCKER_APP_PHP_VERSION:?error}"
+
+# Path (on host system) where the [app] + [worker] container will write
+# its [storage] data (e.g uploads/images/profile pictures etc.).
+#
+# Path is relative (./some/other/path) to the docker-compose.yml or absolute (/some/other/path)
+# @dottie/validate required,dir
+DOCKER_APP_HOST_STORAGE_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/pixelfed/storage"
+
+# Path (on host system) where the [app] + [worker] container will write
+# its [cache] data.
+#
+# Path is relative (./some/other/path) to the docker-compose.yml or absolute (/some/other/path)
+# @dottie/validate required,dir
+DOCKER_APP_HOST_CACHE_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/pixelfed/cache"
+
+# Automatically run "One-time setup tasks" commands.
+#
+# If you are migrating to this docker-compose setup or have manually run the "One time setup"
+# tasks (https://docs.pixelfed.org/running-pixelfed/installation/#setting-up-services)
+# you can set this to "0" to prevent them from running.
+#
+# Otherwise, leave it at "1" to have them run *once*.
+# @dottie/validate required,boolean
+#DOCKER_APP_RUN_ONE_TIME_SETUP_TASKS="1"
+
+# A space-seperated list of paths (inside the container) to *recursively* [chown]
+# to the container user/group id (UID/GID) in case of permission issues.
+#
+# ! You should *not* leave this on permanently, at it can significantly slow down startup
+# ! time for the container, and during normal operations there should never be permission
+# ! issues. Please report a bug if you see behavior requiring this to be permanently on
+#
+# Example: "/var/www/storage /var/www/bootstrap/cache"
+# @dottie/validate required
+#DOCKER_APP_ENSURE_OWNERSHIP_PATHS=""
+
+# Enable Docker Entrypoint debug mode (will call [set -x] in bash scripts)
+# by setting this to "1"
+# @dottie/validate required,boolean
+#DOCKER_APP_ENTRYPOINT_DEBUG="0"
+
+# Show the "diff" when applying templating to files
+#
+# @default "1"
+# @dottie/validate required,boolean
+#DOCKER_APP_ENTRYPOINT_SHOW_TEMPLATE_DIFF="1"
+
+# Docker entrypoints that should be skipped on startup
+# @default ""
+#ENTRYPOINT_SKIP_SCRIPTS=""
+
+# List of extra APT packages (separated by space) to install when building
+# locally using [docker compose build].
+#
+# @see https://github.com/pixelfed/pixelfed/blob/dev/docker/customizing.md
+# @dottie/validate required
+#DOCKER_APP_APT_PACKAGES_EXTRA=""
+
+# List of *extra* PECL extensions (separated by space) to install when
+# building locally using [docker compose build].
+#
+# @see https://github.com/pixelfed/pixelfed/blob/dev/docker/customizing.md
+# @dottie/validate required
+#DOCKER_APP_PHP_PECL_EXTENSIONS_EXTRA=""
+
+# List of *extra* PHP extensions (separated by space) to install when
+# building locally using [docker compose build].
+#
+# @see https://github.com/pixelfed/pixelfed/blob/dev/docker/customizing.md
+# @dottie/validate required
+#DOCKER_APP_PHP_EXTENSIONS_EXTRA=""
+
+# @default "128M"
+# @see https://www.php.net/manual/en/ini.core.php#ini.memory-limit
+# @dottie/validate required
+#DOCKER_APP_PHP_MEMORY_LIMIT="128M"
+
+# @default "E_ALL & ~E_DEPRECATED & ~E_STRICT"
+# @see http://php.net/error-reporting
+# @dottie/validate required
+#DOCKER_APP_PHP_ERROR_REPORTING="E_ALL & ~E_DEPRECATED & ~E_STRICT"
+
+# @default "off"
+# @see http://php.net/display-errors
+# @dottie/validate required,oneof=on off
+#DOCKER_APP_PHP_DISPLAY_ERRORS="off"
+
+# Enables the opcode cache.
+#
+# When disabled, code is not optimised or cached.
+#
+# @default "1"
+# @see https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.enable
+# @dottie/validate required,oneof=0 1
+#DOCKER_APP_PHP_OPCACHE_ENABLE="1"
+
+# If enabled, OPcache will check for updated scripts every [opcache.revalidate_freq] seconds.
+#
+# When this directive is disabled, you must reset OPcache manually via opcache_reset(),
+# opcache_invalidate() or by restarting the Web server for changes to the filesystem to take effect.
+#
+# @default "0"
+# @see https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.validate-timestamps
+# @dottie/validate required,oneof=0 1
+#DOCKER_APP_PHP_OPCACHE_VALIDATE_TIMESTAMPS="0"
+
+# How often to check script timestamps for updates, in seconds.
+# 0 will result in OPcache checking for updates on every request.
+#
+# @default "2"
+# @see https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq
+# @dottie/validate required,oneof=0 1 2
+#DOCKER_APP_PHP_OPCACHE_REVALIDATE_FREQ="2"
+
+# When doing [docker compose build], should the frontend be built in the Dockerfile?
+# If set to "0" the included pre-compiled frontend will be used.
+#
+# @default "0"
+# @dottie/validate required,oneof=0 1
+#DOCKER_APP_BUILD_FRONTEND="0"
+
+################################################################################
+# docker redis
+################################################################################
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [redis] service
+#DOCKER_REDIS_PROFILE=
+
+# Redis version to use as Docker tag
+#
+# @see https://hub.docker.com/_/redis
+# @dottie/validate required
+DOCKER_REDIS_VERSION="7.2"
+
+# Path (on host system) where the [redis] container will store its data
+#
+# Path is relative (./some/other/path) to the docker-compose.yml or absolute (/some/other/path)
+# @dottie/validate required,dir
+DOCKER_REDIS_HOST_DATA_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/redis"
+
+# Port that Redis will listen on *outside* the container (e.g. the host machine)
+# @dottie/validate required,number
+DOCKER_REDIS_HOST_PORT="${REDIS_PORT:?error}"
+
+# The filename that Redis should store its config file within
+#
+# NOTE: The file *MUST* exists (even empty) before enabling this setting!
+#
+# Use a command like [touch "${DOCKER_ALL_HOST_CONFIG_ROOT_PATH}/redis/redis.conf"] to create it.
+#
+# @default ""
+# @dottie/validate required
+#DOCKER_REDIS_CONFIG_FILE="/etc/redis/redis.conf"
+# How often Docker health check should run for [redis] service
+#
+# @default "10s"
+# @dottie/validate required
+DOCKER_REDIS_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?error}"
+
+################################################################################
+# docker db
+################################################################################
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [db] service
+#DOCKER_DB_PROFILE=
+
+# Docker image for the DB service
+# @dottie/validate required
+DOCKER_DB_IMAGE="mariadb:${DB_VERSION}"
+
+# Command to pass to the [db] server container
+# @dottie/validate required
+DOCKER_DB_COMMAND="--default-authentication-plugin=mysql_native_password"
+
+# Path (on host system) where the [db] container will store its data
+#
+# Path is relative (./some/other/path) to the docker-compose.yml or absolute (/some/other/path)
+# @dottie/validate required,dir
+DOCKER_DB_HOST_DATA_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/db"
+
+# Path (inside the container) where the [db] will store its data.
+#
+# Path MUST be absolute.
+#
+# For MySQL this should be [/var/lib/mysql]
+# For PostgreSQL this should be [/var/lib/postgresql/data]
+# @dottie/validate required
+DOCKER_DB_CONTAINER_DATA_PATH="/var/lib/mysql"
+
+# Port that the database will listen on *OUTSIDE* the container (e.g. the host machine)
+#
+# Use "3306" for MySQL/MariaDB and "5432" for PostgreeSQL
+# @dottie/validate required,number
+DOCKER_DB_HOST_PORT="${DB_PORT:?error}"
+
+# Port that the database will listen on *INSIDE* the container
+#
+# Use "3306" for MySQL/MariaDB and "5432" for PostgreeSQL
+# @dottie/validate required,number
+DOCKER_DB_CONTAINER_PORT="${DB_PORT:?error}"
+
+# root password for the database. By default uses DB_PASSWORD
+# but can be changed in situations where you are migrating
+# to the included docker-compose and have a different password
+# set already
+#
+# @dottie/validate required
+DOCKER_DB_ROOT_PASSWORD="${DB_PASSWORD:?error}"
+
+# How often Docker health check should run for [db] service
+# @dottie/validate required
+DOCKER_DB_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?error}"
+
+################################################################################
+# docker web
+################################################################################
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [web] service
+#DOCKER_WEB_PROFILE=""
+
+# Port to expose [web] container will listen on *outside* the container (e.g. the host machine) for *HTTP* traffic only
+# @dottie/validate required,number
+DOCKER_WEB_PORT_EXTERNAL_HTTP="8080"
+
+# How often Docker health check should run for [web] service
+# @dottie/validate required
+DOCKER_WEB_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?error}"
+
+################################################################################
+# docker worker
+################################################################################
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [worker] service
+#DOCKER_WORKER_PROFILE=""
+
+# How often Docker health check should run for [worker] service
+# @dottie/validate required
+DOCKER_WORKER_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?error}"
+
+################################################################################
+# docker proxy
+################################################################################
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [proxy] and [proxy-acme] service
+#DOCKER_PROXY_PROFILE=
+
+# Set this to a non-empty value (e.g. "disabled") to disable the [proxy-acme] service
+#DOCKER_PROXY_ACME_PROFILE="${DOCKER_PROXY_PROFILE:-}"
+
+# The version of nginx-proxy to use
+#
+# @see https://hub.docker.com/r/nginxproxy/nginx-proxy
+# @dottie/validate required
+DOCKER_PROXY_VERSION="1.4"
+
+# How often Docker health check should run for [proxy] service
+# @dottie/validate required
+DOCKER_PROXY_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?error}"
+
+# Port that the [proxy] will listen on *outside* the container (e.g. the host machine) for HTTP traffic
+# @dottie/validate required,number
+DOCKER_PROXY_HOST_PORT_HTTP="80"
+
+# Port that the [proxy] will listen on *outside* the container (e.g. the host machine) for HTTPS traffic
+# @dottie/validate required,number
+DOCKER_PROXY_HOST_PORT_HTTPS="443"
+
+# Path to the Docker socket on the *host*
+# @dottie/validate required,file
+DOCKER_PROXY_HOST_DOCKER_SOCKET_PATH="/var/run/docker.sock"
+
+# The host to request LetsEncrypt certificate for
+# @dottie/validate required,fqdn
+DOCKER_PROXY_LETSENCRYPT_HOST="${APP_DOMAIN}"
+
+# The e-mail to use for Lets Encrypt certificate requests.
+# @dottie/validate required,email
+DOCKER_PROXY_LETSENCRYPT_EMAIL="${INSTANCE_CONTACT_EMAIL:?error}"
+
+# Lets Encrypt staging/test servers for certificate requests.
+#
+# Setting this to any value will change to letsencrypt test servers.
+#DOCKER_PROXY_LETSENCRYPT_TEST="1"

+ 1 - 0
.env.example

@@ -8,6 +8,7 @@ OPEN_REGISTRATION="false"
 ENFORCE_EMAIL_VERIFICATION="false"
 PF_MAX_USERS="1000"
 OAUTH_ENABLED="true"
+ENABLE_CONFIG_CACHE=true
 
 # Media Configuration
 PF_OPTIMIZE_IMAGES="true"

+ 4 - 2
.env.testing

@@ -1,3 +1,5 @@
+# shellcheck disable=SC2034,SC2148
+
 APP_NAME="Pixelfed Test"
 APP_ENV=local
 APP_KEY=base64:lwX95GbNWX3XsucdMe0XwtOKECta3h/B+p9NbH2jd0E=
@@ -62,8 +64,8 @@ CS_BLOCKED_DOMAINS='example.org,example.net,example.com'
 CS_CW_DOMAINS='example.org,example.net,example.com'
 CS_UNLISTED_DOMAINS='example.org,example.net,example.com'
 
-## Optional 
+## Optional
 #HORIZON_DARKMODE=false  # Horizon theme darkmode
-#HORIZON_EMBED=false  # Single Docker Container mode 
+#HORIZON_EMBED=false  # Single Docker Container mode
 
 ENABLE_CONFIG_CACHE=false

+ 7 - 0
.gitattributes

@@ -3,3 +3,10 @@
 *.scss linguist-vendored
 *.js linguist-vendored
 CHANGELOG.md export-ignore
+
+# Collapse diffs for generated files:
+public/**/*.js text -diff
+public/**/*.json text -diff
+public/**/*.css text -diff
+public/img/* binary -diff
+public/fonts/* binary -diff

+ 0 - 125
.github/workflows/build-docker.yml

@@ -1,125 +0,0 @@
----
-name: Build Docker image
-
-on:
-  workflow_dispatch:
-  push:
-    branches:
-      - dev
-    tags:
-      - '*'
-  pull_request:
-    paths:
-      - .github/workflows/build-docker.yml
-      - contrib/docker/Dockerfile.apache
-      - contrib/docker/Dockerfile.fpm
-permissions:
-  contents: read
-
-jobs:
-  build-docker-apache:
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout Code
-        uses: actions/checkout@v3
-
-      - name: Docker Lint
-        uses: hadolint/hadolint-action@v3.0.0
-        with:
-          dockerfile: contrib/docker/Dockerfile.apache
-          failure-threshold: error
-
-      - name: Set up QEMU
-        uses: docker/setup-qemu-action@v2
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2
-
-      - name: Login to DockerHub
-        uses: docker/login-action@v2
-        secrets: inherit
-        with:
-          username: ${{ secrets.DOCKER_HUB_USERNAME }}
-          password: ${{ secrets.DOCKER_HUB_TOKEN }}
-        if: github.event_name != 'pull_request'
-
-      - name: Fetch tags
-        uses: docker/metadata-action@v4
-        secrets: inherit
-        id: meta
-        with:
-          images: ${{ secrets.DOCKER_HUB_ORGANISATION }}/pixelfed
-          flavor: |
-            latest=auto
-            suffix=-apache
-          tags: |
-            type=edge,branch=dev
-            type=pep440,pattern={{raw}}
-            type=pep440,pattern=v{{major}}.{{minor}}
-            type=ref,event=pr
-
-      - name: Build and push Docker image
-        uses: docker/build-push-action@v3
-        with:
-          context: .
-          file: contrib/docker/Dockerfile.apache
-          platforms: linux/amd64,linux/arm64
-          builder: ${{ steps.buildx.outputs.name }}
-          push: ${{ github.event_name != 'pull_request' }}
-          tags: ${{ steps.meta.outputs.tags }}
-          cache-from: type=gha
-          cache-to: type=gha,mode=max
-
-  build-docker-fpm:
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout Code
-        uses: actions/checkout@v3
-
-      - name: Docker Lint
-        uses: hadolint/hadolint-action@v3.0.0
-        with:
-          dockerfile: contrib/docker/Dockerfile.fpm
-          failure-threshold: error
-
-      - name: Set up QEMU
-        uses: docker/setup-qemu-action@v2
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2
-
-      - name: Login to DockerHub
-        uses: docker/login-action@v2
-        secrets: inherit
-        with:
-          username: ${{ secrets.DOCKER_HUB_USERNAME }}
-          password: ${{ secrets.DOCKER_HUB_TOKEN }}
-        if: github.event_name != 'pull_request'
-
-      - name: Fetch tags
-        uses: docker/metadata-action@v4
-        secrets: inherit
-        id: meta
-        with:
-          images: ${{ secrets.DOCKER_HUB_ORGANISATION }}/pixelfed
-          flavor: |
-            suffix=-fpm
-          tags: |
-            type=edge,branch=dev
-            type=pep440,pattern={{raw}}
-            type=pep440,pattern=v{{major}}.{{minor}}
-            type=ref,event=pr
-
-      - name: Build and push Docker image
-        uses: docker/build-push-action@v3
-        with:
-          context: .
-          file: contrib/docker/Dockerfile.fpm
-          platforms: linux/amd64,linux/arm64
-          builder: ${{ steps.buildx.outputs.name }}
-          push: ${{ github.event_name != 'pull_request' }}
-          tags: ${{ steps.meta.outputs.tags }}
-          cache-from: type=gha
-          cache-to: type=gha,mode=max

+ 230 - 0
.github/workflows/docker.yml

@@ -0,0 +1,230 @@
+---
+name: Docker
+
+on:
+  # See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
+  workflow_dispatch:
+
+  # See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
+  push:
+    branches:
+      - dev
+      - staging
+    tags:
+      - "*"
+
+  # See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
+  pull_request:
+    types:
+      - opened
+      - reopened
+      - synchronize
+
+jobs:
+  lint:
+    name: hadolint
+    runs-on: ubuntu-latest
+
+    permissions:
+      contents: read
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Docker Lint
+        uses: hadolint/hadolint-action@v3.1.0
+        with:
+          dockerfile: Dockerfile
+          failure-threshold: error
+
+  shellcheck:
+    name: ShellCheck
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Run ShellCheck
+        uses: ludeeus/action-shellcheck@master
+        env:
+          SHELLCHECK_OPTS: --shell=bash --external-sources
+        with:
+          version: v0.9.0
+          additional_files: "*.envsh .env .env.docker .env.example .env.testing"
+
+  bats:
+    name: Bats Testing
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Run bats
+        run: docker run -v "$PWD:/var/www" bats/bats:latest /var/www/tests/bats
+
+  build:
+    name: Build, Test, and Push
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+
+      # See: https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
+      matrix:
+        php_version:
+          - 8.2
+          - 8.3
+        target_runtime:
+          - apache
+          - fpm
+          - nginx
+        php_base:
+          - apache
+          - fpm
+
+        # See: https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#excluding-matrix-configurations
+        # See: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixexclude
+        exclude:
+          # targeting [apache] runtime with [fpm] base type doesn't make sense
+          - target_runtime: apache
+            php_base: fpm
+
+          # targeting [fpm] runtime with [apache] base type doesn't make sense
+          - target_runtime: fpm
+            php_base: apache
+
+          # targeting [nginx] runtime with [apache] base type doesn't make sense
+          - target_runtime: nginx
+            php_base: apache
+
+    # See: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-using-concurrency-and-the-default-behavior
+    concurrency:
+      group: docker-build-${{ github.ref }}-${{ matrix.php_base }}-${{ matrix.php_version }}-${{ matrix.target_runtime }}
+      cancel-in-progress: true
+
+    permissions:
+      contents: read
+      packages: write
+
+    env:
+      # Set the repo variable [DOCKER_HUB_USERNAME] to override the default
+      # at https://github.com/<user>/<project>/settings/variables/actions
+      DOCKER_HUB_USERNAME: ${{ vars.DOCKER_HUB_USERNAME || 'pixelfed' }}
+
+      # Set the repo variable [DOCKER_HUB_ORGANISATION] to override the default
+      # at https://github.com/<user>/<project>/settings/variables/actions
+      DOCKER_HUB_ORGANISATION: ${{ vars.DOCKER_HUB_ORGANISATION || 'pixelfed' }}
+
+      # Set the repo variable [DOCKER_HUB_REPO] to override the default
+      # at https://github.com/<user>/<project>/settings/variables/actions
+      DOCKER_HUB_REPO: ${{ vars.DOCKER_HUB_REPO || 'pixelfed' }}
+
+      # For Docker Hub pushing to work, you need the secret [DOCKER_HUB_TOKEN]
+      # set to your Personal Access Token at https://github.com/<user>/<project>/settings/secrets/actions
+      #
+      # ! NOTE: no [login] or [push] will happen to Docker Hub until this secret is set!
+      HAS_DOCKER_HUB_CONFIGURED: ${{ secrets.DOCKER_HUB_TOKEN != '' }}
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+        id: buildx
+        with:
+          version: v0.12.0 # *or* newer, needed for annotations to work
+
+        # See: https://github.com/docker/login-action?tab=readme-ov-file#github-container-registry
+      - name: Log in to the GitHub Container registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+        # See: https://github.com/docker/login-action?tab=readme-ov-file#docker-hub
+      - name: Login to Docker Hub registry (conditionally)
+        if: ${{ env.HAS_DOCKER_HUB_CONFIGURED == true }}
+        uses: docker/login-action@v3
+        with:
+          username: ${{ env.DOCKER_HUB_USERNAME }}
+          password: ${{ secrets.DOCKER_HUB_TOKEN }}
+
+      - name: Docker meta
+        uses: docker/metadata-action@v5
+        id: meta
+        with:
+          images: |
+            name=ghcr.io/${{ github.repository }},enable=true
+            name=${{ env.DOCKER_HUB_ORGANISATION }}/${{ env.DOCKER_HUB_REPO }},enable=${{ env.HAS_DOCKER_HUB_CONFIGURED }}
+          flavor: |
+            latest=auto
+            suffix=-${{ matrix.target_runtime }}-${{ matrix.php_version }}
+          tags: |
+            type=raw,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'dev') }}
+            type=raw,value=staging,enable=${{ github.ref == format('refs/heads/{0}', 'staging') }}
+            type=pep440,pattern={{raw}}
+            type=pep440,pattern=v{{major}}.{{minor}}
+            type=ref,event=branch,prefix=branch-
+            type=ref,event=pr,prefix=pr-
+            type=ref,event=tag
+        env:
+          DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
+
+      - name: Docker meta (Cache)
+        uses: docker/metadata-action@v5
+        id: cache
+        with:
+          images: |
+            name=ghcr.io/${{ github.repository }}-cache,enable=true
+            name=${{ env.DOCKER_HUB_ORGANISATION }}/${{ env.DOCKER_HUB_REPO }}-cache,enable=${{ env.HAS_DOCKER_HUB_CONFIGURED }}
+          flavor: |
+            latest=auto
+            suffix=-${{ matrix.target_runtime }}-${{ matrix.php_version }}
+          tags: |
+            type=raw,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'dev') }}
+            type=raw,value=staging,enable=${{ github.ref == format('refs/heads/{0}', 'staging') }}
+            type=pep440,pattern={{raw}}
+            type=pep440,pattern=v{{major}}.{{minor}}
+            type=ref,event=branch,prefix=branch-
+            type=ref,event=pr,prefix=pr-
+            type=ref,event=tag
+        env:
+          DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
+
+      - name: Build and push Docker image
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          file: Dockerfile
+          target: ${{ matrix.target_runtime }}-runtime
+          platforms: linux/amd64,linux/arm64
+          builder: ${{ steps.buildx.outputs.name }}
+          tags: ${{ steps.meta.outputs.tags }}
+          annotations: ${{ steps.meta.outputs.annotations }}
+          push: true
+          sbom: true
+          provenance: true
+          build-args: |
+            PHP_VERSION=${{ matrix.php_version }}
+            PHP_BASE_TYPE=${{ matrix.php_base }}
+          cache-from: |
+            type=gha,scope=${{ matrix.target_runtime }}-${{ matrix.php_base }}-${{ matrix.php_version }}
+          cache-to: |
+            type=gha,mode=max,scope=${{ matrix.target_runtime }}-${{ matrix.php_base }}-${{ matrix.php_version }}
+            ${{ steps.cache.outputs.tags }}
+
+      # goss validate the image
+      #
+      # See: https://github.com/goss-org/goss
+      - uses: e1himself/goss-installation-action@v1
+        with:
+          version: "v0.4.4"
+      - name: Execute Goss tests
+        run: |
+          dgoss run \
+            -v "./.env.testing:/var/www/.env" \
+            -e "EXPECTED_PHP_VERSION=${{ matrix.php_version }}" \
+            -e "PHP_BASE_TYPE=${{ matrix.php_base }}" \
+            ${{ steps.meta.outputs.tags }}

+ 26 - 17
.gitignore

@@ -1,22 +1,31 @@
+.DS_Store
+/.bash_history
+/.bash_profile
+/.bashrc
+/.composer
+/.env
+/.env.dottie-backup
+#/.git
+/.git-credentials
+/.gitconfig
+#/.gitignore
+/.idea
+/.vagrant
+/bootstrap/cache
+/docker-compose-state/
+/Homestead.json
+/Homestead.yaml
 /node_modules
+/npm-debug.log
 /public/hot
 /public/storage
+/public/vendor/horizon
 /storage/*.key
+/storage/docker
 /vendor
-/.idea
-/.vscode
-/.vagrant
-/docker-volumes
-Homestead.json
-Homestead.yaml
-npm-debug.log
-yarn-error.log
-.env
-.DS_Store
-.bash_profile
-.bash_history
-.bashrc
-.gitconfig
-.git-credentials
-/.composer/
-/nginx.conf
+/yarn-error.log
+/public/build
+
+# Exceptions - these *MUST* be last
+!/bootstrap/cache/.gitignore
+!/public/vendor/horizon/.gitignore

+ 6 - 0
.hadolint.yaml

@@ -0,0 +1,6 @@
+ignored:
+  - DL3002 # warning: Last USER should not be root
+  - DL3008 # warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
+  - DL3029 # warning: Do not use --platform flag with FROM
+  - SC2046 # warning: Quote this to prevent word splitting.
+  - SC2086 # info: Double quote to prevent globbing and word splitting.

+ 4 - 0
.markdownlint.json

@@ -0,0 +1,4 @@
+{
+    "MD013": false,
+    "MD014": false
+}

+ 12 - 0
.shellcheckrc

@@ -0,0 +1,12 @@
+# See: https://github.com/koalaman/shellcheck/blob/master/shellcheck.1.md#rc-files
+
+source-path=SCRIPTDIR
+
+# Allow opening any 'source'd file, even if not specified as input
+external-sources=true
+
+# Turn on warnings for unquoted variables with safe values
+enable=quote-safe-variables
+
+# Turn on warnings for unassigned uppercase variables
+enable=check-unassigned-uppercase

+ 14 - 0
.vscode/extensions.json

@@ -0,0 +1,14 @@
+{
+    "recommendations": [
+        "foxundermoon.shell-format",
+        "timonwong.shellcheck",
+        "jetmartin.bats",
+        "aaron-bond.better-comments",
+        "streetsidesoftware.code-spell-checker",
+        "editorconfig.editorconfig",
+        "github.vscode-github-actions",
+        "bmewburn.vscode-intelephense-client",
+        "redhat.vscode-yaml",
+        "ms-azuretools.vscode-docker"
+    ]
+}

+ 21 - 0
.vscode/settings.json

@@ -0,0 +1,21 @@
+{
+    "shellformat.useEditorConfig": true,
+    "[shellscript]": {
+        "files.eol": "\n",
+        "editor.defaultFormatter": "foxundermoon.shell-format"
+    },
+    "[yaml]": {
+        "editor.defaultFormatter": "redhat.vscode-yaml"
+    },
+    "[dockercompose]": {
+        "editor.defaultFormatter": "redhat.vscode-yaml",
+        "editor.autoIndent": "advanced",
+    },
+    "yaml.schemas": {
+        "https://json.schemastore.org/composer": "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json"
+    },
+    "files.associations": {
+        ".env": "shellscript",
+        ".env.*": "shellscript"
+    }
+}

+ 302 - 3
CHANGELOG.md

@@ -1,17 +1,261 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.9...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+
+### OAuth
+- Fix oauth oob (urn:ietf:wg:oauth:2.0:oob) support. ([8afbdb03](https://github.com/pixelfed/pixelfed/commit/8afbdb03))
+
+### Updates
+- Update AP helpers, reject statuses with invalid dates ([960f3849](https://github.com/pixelfed/pixelfed/commit/960f3849))
+- Update DirectMessage API, fix broken threading ([044d410c](https://github.com/pixelfed/pixelfed/commit/044d410c))
+- Update Status caption render logic ([fb8dbb95](https://github.com/pixelfed/pixelfed/commit/fb8dbb95))
+- Update ApiV1Controller, fix bookmark bug. Closes #5216 ([9f7cc52c](https://github.com/pixelfed/pixelfed/commit/9f7cc52c))
+- Update Status caption logic, stop storing duplicate html caption in db and defer to cached StatusService rendering ([9eeb7b67](https://github.com/pixelfed/pixelfed/commit/9eeb7b67))
+- Update AutolinkService, optimize lookups ([eac2c196](https://github.com/pixelfed/pixelfed/commit/eac2c196))
+- Update DirectMessageController, remove 72h limit for admins ([639df410](https://github.com/pixelfed/pixelfed/commit/639df410))
+- Update StatusService, fix newlines ([56c07b7a](https://github.com/pixelfed/pixelfed/commit/56c07b7a))
+- Update confirm email template, add plaintext link. Fixes #5375 ([45986707](https://github.com/pixelfed/pixelfed/commit/45986707))
+- Update UserVerifyEmail command ([77da9ad8](https://github.com/pixelfed/pixelfed/commit/77da9ad8))
+- Update StatusStatelessTransformer, refactor the caption field to be compliant with the MastoAPI. Fixes #5364 ([79039ba5](https://github.com/pixelfed/pixelfed/commit/79039ba5))
+- Update mailgun config, add endpoint and scheme ([271d5114](https://github.com/pixelfed/pixelfed/commit/271d5114))
+- Update search and status logic to fix postgres bugs ([8c39ef4](https://github.com/pixelfed/pixelfed/commit/8c39ef4))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+
+## [v0.12.4 (2024-11-08)](https://github.com/pixelfed/pixelfed/compare/v0.12.4...dev)
+
+### Added
+- Implement Admin Domain Blocks API (Mastodon API Compatible) [ThisIsMissEm](https://github.com/ThisIsMissEm) ([#5021](https://github.com/pixelfed/pixelfed/pull/5021))
+- Authorize Interaction support (for handling remote interactions) ([4ca7c6c3](https://github.com/pixelfed/pixelfed/commit/4ca7c6c3))
+- Contact Form Admin Responses ([52cc6090](https://github.com/pixelfed/pixelfed/commit/52cc6090))
+- Profile Carousels ([8af77a3f](https://github.com/pixelfed/pixelfed/commit/8af77a3f))
+- Moderated Profiles ([39f16321](https://github.com/pixelfed/pixelfed/commit/39f16321))
+
+### Federation
+- Add ActiveSharedInboxService, for efficient sharedInbox caching ([1a6a3397](https://github.com/pixelfed/pixelfed/commit/1a6a3397))
+- Add MovePipeline queue jobs ([9904d05f](https://github.com/pixelfed/pixelfed/commit/9904d05f))
+- Add ActivityPub Move validator ([909a6c72](https://github.com/pixelfed/pixelfed/commit/909a6c72))
+- Add delay to move handler to allow for remote cache invalidation ([8a362c12](https://github.com/pixelfed/pixelfed/commit/8a362c12))
+
+### Updates
+- Update ApiV1Controller, add support for notification filter types ([f61159a1](https://github.com/pixelfed/pixelfed/commit/f61159a1))
+- Update ApiV1Dot1Controller, fix mutual api ([a8bb97b2](https://github.com/pixelfed/pixelfed/commit/a8bb97b2))
+- Update ApiV1Controller, fix /api/v1/favourits pagination ([72f68160](https://github.com/pixelfed/pixelfed/commit/72f68160))
+- Update RegisterController, update username constraints, require atleast one alpha char ([dd6e3cc2](https://github.com/pixelfed/pixelfed/commit/dd6e3cc2))
+- Update AdminUser, fix entity casting ([cb5620d4](https://github.com/pixelfed/pixelfed/commit/cb5620d4))
+- Update instance config, update network cache feed max_hours_old falloff to 90 days instead of 6 hours to allow for less active instances to have more results ([c042d135](https://github.com/pixelfed/pixelfed/commit/c042d135))
+- Update ApiV1Dot1Controller, add new single media status create endpoint ([b03f5cec](https://github.com/pixelfed/pixelfed/commit/b03f5cec))
+- Update AdminSettings component, add link to Custom CSS settings ([958daac4](https://github.com/pixelfed/pixelfed/commit/958daac4))
+- Update ApiV1Controller, fix v1/instance stats, force cast to int ([dcd95d68](https://github.com/pixelfed/pixelfed/commit/dcd95d68))
+- Update BeagleService, disable discovery if AP is disabled ([6cd1cbb4](https://github.com/pixelfed/pixelfed/commit/6cd1cbb4))
+- Update NodeinfoService, fix typo ([edad436d](https://github.com/pixelfed/pixelfed/commit/edad436d))
+- Update ActivityPubFetchService, reduce cache ttl from 1 hour to 7.5 mins and add uncached fetchRequest method ([21da2b64](https://github.com/pixelfed/pixelfed/commit/21da2b64))
+- Update UserAccountDelete command, increase sharedInbox ttl from 12h to 14d ([be02f48a](https://github.com/pixelfed/pixelfed/commit/be02f48a))
+- Update HttpSignature, add signRaw method and improve error checking ([d4cf9181](https://github.com/pixelfed/pixelfed/commit/d4cf9181))
+- Update AP helpers, add forceBanCheck param to validateUrl method ([42424028](https://github.com/pixelfed/pixelfed/commit/42424028))
+- Update layout, add og:logo ([4cc576e1](https://github.com/pixelfed/pixelfed/commit/4cc576e1))
+- Update ReblogService, fix cache sync issues ([3de8ceca](https://github.com/pixelfed/pixelfed/commit/3de8ceca))
+- Update config, allow Beagle discover service to be disabled ([de4ce3c8](https://github.com/pixelfed/pixelfed/commit/de4ce3c8))
+- Update ApiV1Dot1Controller, allow upto 5 similar push tokens ([7820b506](https://github.com/pixelfed/pixelfed/commit/7820b506))
+- Update AdminReports, add missing click handler. Fixes #5332 ([fe48b8ad](https://github.com/pixelfed/pixelfed/commit/fe48b8ad))
+- Improve media filtering by using OffscreenCanvas, if supported ([aea5392](https://github.com/pixelfed/pixelfed/commit/aea5392))
+
+## [v0.12.3 (2024-07-01)](https://github.com/pixelfed/pixelfed/compare/v0.12.2...v0.12.3)
+
+### Updates
+- Fix migrations bug ([4d1180b1](https://github.com/pixelfed/pixelfed/commit/4d1180b1))
+
+## [v0.12.2 (2024-07-01)](https://github.com/pixelfed/pixelfed/compare/v0.12.1...v0.12.2)
+
+### Framework
+- Updated to Laravel 11 (requires php 8.2+)
+
+### Added
+- New api/v1/instance/peers API endpoint, disabled by default ([4aad1c22](https://github.com/pixelfed/pixelfed/commit/4aad1c22))
+- Added disable_embeds setting, and fix cache invalidation in other settings ([c5e7e917](https://github.com/pixelfed/pixelfed/commit/c5e7e917))
+
+### Updates
+- Update DirectMessageController, add 72 hour delay for new accounts before they can send a DM ([61d105fd](https://github.com/pixelfed/pixelfed/commit/61d105fd))
+- Update AdminCuratedRegisterController, increase message length from 1000 to 3000 ([9a5e3471](https://github.com/pixelfed/pixelfed/commit/9a5e3471))
+- Update ApiV1Controller, add pe (pixelfed entity) support to /api/v1/statuses/{id}/context endpoint ([d645d6ca](https://github.com/pixelfed/pixelfed/commit/d645d6ca))
+- Update Admin Curated Onboarding, add select-all/mass action operations ([b22cac94](https://github.com/pixelfed/pixelfed/commit/b22cac94))
+- Update AdminCuratedRegisterController, fix existing account approval ([cbb96cfd](https://github.com/pixelfed/pixelfed/commit/cbb96cfd))
+- Update ActivityPubFetchService, fix Friendica bug ([e4edc6f1](https://github.com/pixelfed/pixelfed/commit/e4edc6f1))
+- Update ProfileController, fix atom feed cache ttl. Fixes #5093 ([921e2965](https://github.com/pixelfed/pixelfed/commit/921e2965))
+- Update CollectionsController, add new self route ([bc2495c6](https://github.com/pixelfed/pixelfed/commit/bc2495c6))
+- Update FederationController, add webfinger support for actor uri. Fixes #5068 ([24194f7d](https://github.com/pixelfed/pixelfed/commit/24194f7d))
+- Update FetchNodeinfoPipeline, set last_fetched_at timestamp ([a7fce91e](https://github.com/pixelfed/pixelfed/commit/a7fce91e))
+- Update task scheduler, add weekly instance scan to check nodeinfo for known instances ([dc6b9f46](https://github.com/pixelfed/pixelfed/commit/dc6b9f46))
+- Update AP fetch service and domain service ([42915ff9](https://github.com/pixelfed/pixelfed/commit/42915ff9))
+- Update ApiV1Controller, add settings to verify_credentials endpoint ([3f4e0b94](https://github.com/pixelfed/pixelfed/commit/3f4e0b94))
+- Update ApiV1Controller, fix update_credentials boolean handling ([19c62aaa](https://github.com/pixelfed/pixelfed/commit/19c62aaa))
+- Update ApiV1Controller, fix cache invalidation bug in update_credentials ([d56a4108](https://github.com/pixelfed/pixelfed/commit/d56a4108))
+- Update ApiV1Controller, fix self relationship response ([28bc7aa4](https://github.com/pixelfed/pixelfed/commit/28bc7aa4))
+- Update ApiController, add pe support to like/unlike endpoints ([679ef677](https://github.com/pixelfed/pixelfed/commit/679ef677))
+- Update ApiV1Dot1Controller, fix username to id endpoint ([4d6cea9a](https://github.com/pixelfed/pixelfed/commit/4d6cea9a))
+- Update StatusController, cache AP object ([a75b89b2](https://github.com/pixelfed/pixelfed/commit/a75b89b2))
+- Update status embed, add support for album carousels ([f4898db9](https://github.com/pixelfed/pixelfed/commit/f4898db9))
+- Update profile embeds, add support for albums ([4fd156c4](https://github.com/pixelfed/pixelfed/commit/4fd156c4))
+- Update DirectMessageController, add timestamps to threads ([b24d2554](https://github.com/pixelfed/pixelfed/commit/b24d2554))
+- Update DirectMessageController, add carousel entity to threads ([96f24f33](https://github.com/pixelfed/pixelfed/commit/96f24f33))
+- Update and refactor total local post count logic, cache value and schedule updates twice daily to eliminate the perf issue on larger instances ([4f2b8ed2](https://github.com/pixelfed/pixelfed/commit/4f2b8ed2))
+- Update Media model, fix broken thumbnail/gray thumbnail bug ([e33643c2](https://github.com/pixelfed/pixelfed/commit/e33643c2))
+- Update StatusController, fix unlisted post guest/ap access bug ([83098428](https://github.com/pixelfed/pixelfed/commit/83098428))
+- Update discover, add network trending using Beagle API ([2cae8b48](https://github.com/pixelfed/pixelfed/commit/2cae8b48))
+
+## [v0.12.1 (2024-05-07)](https://github.com/pixelfed/pixelfed/compare/v0.12.0...v0.12.1)
+
+### Updates
+- Update ApiV1Dot1Controller, fix in app registration bug that prevents proper auth flow due to missing oauth scopes ([cbf996c9](https://github.com/pixelfed/pixelfed/commit/cbf996c9))
+- Update ConfigCacheService, fix database race condition and fallback to file config and enable by default ([60a62b59](https://github.com/pixelfed/pixelfed/commit/60a62b59))
+
+## [v0.12.0 (2024-04-29)](https://github.com/pixelfed/pixelfed/compare/v0.11.13...v0.12.0)
+
+### Updates
+
+- Update SoftwareUpdateService, add command to refresh latest versions ([632f2cb6](https://github.com/pixelfed/pixelfed/commit/632f2cb6))
+- Update Post.vue, fix cache bug ([3a27e637](https://github.com/pixelfed/pixelfed/commit/3a27e637))
+- Update StatusHashtagService, use more efficient cached count ([592c8412](https://github.com/pixelfed/pixelfed/commit/592c8412))
+- Update DiscoverController, handle discover hashtag redirects ([18382e8a](https://github.com/pixelfed/pixelfed/commit/18382e8a))
+- Update ApiV1Controller, use admin filter service ([94503a1c](https://github.com/pixelfed/pixelfed/commit/94503a1c))
+- Update SearchApiV2Service, use more efficient query ([cee618e8](https://github.com/pixelfed/pixelfed/commit/cee618e8))
+- Update Curated Onboarding view, fix concierge form ([15ad69f7](https://github.com/pixelfed/pixelfed/commit/15ad69f7))
+- Update AP Profile Transformer, add `suspended` attribute ([25f3fa06](https://github.com/pixelfed/pixelfed/commit/25f3fa06))
+- Update AP Profile Transformer, fix movedTo attribute ([63100fe9](https://github.com/pixelfed/pixelfed/commit/63100fe9))
+- Update AP Profile Transformer, fix suspended attributes ([2e5e68e4](https://github.com/pixelfed/pixelfed/commit/2e5e68e4))
+- Update PrivacySettings controller, add cache invalidation ([e742d595](https://github.com/pixelfed/pixelfed/commit/e742d595))
+- Update ProfileController, preserve deleted actor objects for federated account deletion and use more efficient account cache lookup ([853a729f](https://github.com/pixelfed/pixelfed/commit/853a729f))
+- Update SiteController, add curatedOnboarding method that gracefully falls back to open registration when applicable ([95199843](https://github.com/pixelfed/pixelfed/commit/95199843))
+- Update AP transformers, add DeleteActor activity ([bcce1df6](https://github.com/pixelfed/pixelfed/commit/bcce1df6))
+- Update commands, add user account delete cli command to federate account deletion ([4aa0e25f](https://github.com/pixelfed/pixelfed/commit/4aa0e25f))
+- Update web-api popular accounts route to its own method to remove the breaking oauth scope bug ([a4bc5ce3](https://github.com/pixelfed/pixelfed/commit/a4bc5ce3))
+- Update config cache ([5e4d4eff](https://github.com/pixelfed/pixelfed/commit/5e4d4eff))
+- Update Config, use config_cache ([7785a2da](https://github.com/pixelfed/pixelfed/commit/7785a2da))
+- Update ApiV1Dot1Controller, use config_cache for in-app registration ([b0cb4456](https://github.com/pixelfed/pixelfed/commit/b0cb4456))
+- Update captcha, use config_cache helper ([8a89e3c9](https://github.com/pixelfed/pixelfed/commit/8a89e3c9))
+- Update custom emoji, add config_cache support ([481314cd](https://github.com/pixelfed/pixelfed/commit/481314cd))
+- Update ProfileController, fix permalink redirect bug ([75081e60](https://github.com/pixelfed/pixelfed/commit/75081e60))
+- Update admin css, use font-display:swap for nucleo icons ([8a0c456e](https://github.com/pixelfed/pixelfed/commit/8a0c456e))
+- Update PixelfedDirectoryController, fix boolean cast bug ([f08aab22](https://github.com/pixelfed/pixelfed/commit/f08aab22))
+- Update PixelfedDirectoryController, use cached stats ([f2f2a809](https://github.com/pixelfed/pixelfed/commit/f2f2a809))
+- Update AdminDirectoryController, fix type casting ([ad506e90](https://github.com/pixelfed/pixelfed/commit/ad506e90))
+- Update image pipeline, use config_cache ([a72188a7](https://github.com/pixelfed/pixelfed/commit/a72188a7))
+- Update cloud storage, use config_cache ([665581d8](https://github.com/pixelfed/pixelfed/commit/665581d8))
+- Update pixelfed.max_album_length, use config_cache ([fecbe189](https://github.com/pixelfed/pixelfed/commit/fecbe189))
+- Update media_types, use config_cache ([d670de17](https://github.com/pixelfed/pixelfed/commit/d670de17))
+- Update landing settings, use config_cache ([40478f25](https://github.com/pixelfed/pixelfed/commit/40478f25))
+- Update activitypub setting, use config_cache ([5071aaf4](https://github.com/pixelfed/pixelfed/commit/5071aaf4))
+- Update oauth setting, use config_cache ([ce228f7f](https://github.com/pixelfed/pixelfed/commit/ce228f7f))
+- Update stories config, use config_cache ([d1adb109](https://github.com/pixelfed/pixelfed/commit/d1adb109))
+- Update ig import, use config_cache ([da0e0ffa](https://github.com/pixelfed/pixelfed/commit/da0e0ffa))
+- Update autospam config, use config_cache ([a76cb5f4](https://github.com/pixelfed/pixelfed/commit/a76cb5f4))
+- Update app.name config, use config_cache ([911446c0](https://github.com/pixelfed/pixelfed/commit/911446c0))
+- Update UserObserver, fix type casting ([949e9979](https://github.com/pixelfed/pixelfed/commit/949e9979))
+- Update user_filters, use config_cache ([6ce513f8](https://github.com/pixelfed/pixelfed/commit/6ce513f8))
+- Update filesystems config, add to config_cache ([087b2791](https://github.com/pixelfed/pixelfed/commit/087b2791))
+- Update web-admin routes, add setting api routes ([828a456f](https://github.com/pixelfed/pixelfed/commit/828a456f))
+- Update hashtag component ([cee979ed](https://github.com/pixelfed/pixelfed/commit/cee979ed))
+- Update AdminReadMore component, add .prevent to click action ([704e7b12](https://github.com/pixelfed/pixelfed/commit/704e7b12))
+- Update admin dashboard, add admin settings partials ([eb487123](https://github.com/pixelfed/pixelfed/commit/eb487123))
+- Update admin settings, refactor to vue component ([674e560f](https://github.com/pixelfed/pixelfed/commit/674e560f))
+- Update ConfigCacheService, encrypt keys at rest ([3628b462](https://github.com/pixelfed/pixelfed/commit/3628b462))
+- Update RemoteFollowImportRecent, use MediaPathService ([5162c070](https://github.com/pixelfed/pixelfed/commit/5162c070))
+- Update AdminSettingsController, add user filter max limit settings ([ac1f0748](https://github.com/pixelfed/pixelfed/commit/ac1f0748))
+- Update AdminSettingsController, add AdminSettingsService ([dcc5f416](https://github.com/pixelfed/pixelfed/commit/dcc5f416))
+- Update AdminSettings component, fix user settings ([aba1e13d](https://github.com/pixelfed/pixelfed/commit/aba1e13d))
+- Update AdminInstances component ([ec2fdd61](https://github.com/pixelfed/pixelfed/commit/ec2fdd61))
+- Update AdminSettings, add max_account_size support ([2dcbc1d5](https://github.com/pixelfed/pixelfed/commit/2dcbc1d5))
+- Update AdminSettings, use better validation for user integer settings ([d946afcc](https://github.com/pixelfed/pixelfed/commit/d946afcc))
+- Update spa sass, fix timestamp dark mode bug ([4147f7c5](https://github.com/pixelfed/pixelfed/commit/4147f7c5))
+- Update relationships view, fix unfollow hashtag bug. Fixes #5008 ([8c693640](https://github.com/pixelfed/pixelfed/commit/8c693640))
+- Update PrivacySettings controller, refresh RelationshipService when unmute/unblocking ([b7322b68](https://github.com/pixelfed/pixelfed/commit/b7322b68))
+- Update ApiV1Controller, improve refresh relations logic when (un)muting or (un)blocking ([b8e96a5f](https://github.com/pixelfed/pixelfed/commit/b8e96a5f))
+- Update context menu, add mute/block/unfollow actions and update relationship store accordingly ([81d1e0fd](https://github.com/pixelfed/pixelfed/commit/81d1e0fd))
+- Update docker env, fix config_cache. Fixes #5033 ([858fcbf6](https://github.com/pixelfed/pixelfed/commit/858fcbf6))
+- Update UnfollowPipeline, fix follower count cache bug ([6bdf73de](https://github.com/pixelfed/pixelfed/commit/6bdf73de))
+- Update VideoPresenter component, add webkit-playsinline attribute to video element to prevent the full screen video player ([ad032916](https://github.com/pixelfed/pixelfed/commit/ad032916))
+- Update VideoPlayer component, add playsinline attribute to video element ([8af23607](https://github.com/pixelfed/pixelfed/commit/8af23607))
+- Update StatusController, refactor status embeds ([9a7acc12](https://github.com/pixelfed/pixelfed/commit/9a7acc12))
+- Update ProfileController, refactor profile embeds ([8b8b1ffc](https://github.com/pixelfed/pixelfed/commit/8b8b1ffc))
+- Update profile embed view, fix height bug ([65166570](https://github.com/pixelfed/pixelfed/commit/65166570))
+- Update CustomEmojiService, only return local emoji ([7f8bba44](https://github.com/pixelfed/pixelfed/commit/7f8bba44))
+- Update Like model, increase max likes per day from 500 to 1500 ([4223119f](https://github.com/pixelfed/pixelfed/commit/4223119f))
+
+## [v0.11.13 (2024-03-05)](https://github.com/pixelfed/pixelfed/compare/v0.11.12...v0.11.13)
+
+### Features
+
+- Account Migrations ([#4968](https://github.com/pixelfed/pixelfed/pull/4968)) ([4a6be6212](https://github.com/pixelfed/pixelfed/pull/4968/commits/4a6be6212))
+- Curated Onboarding ([#4946](https://github.com/pixelfed/pixelfed/pull/4946)) ([8dac2caf](https://github.com/pixelfed/pixelfed/commit/8dac2caf))
+- Add Curated Onboarding Templates ([071163b4](https://github.com/pixelfed/pixelfed/commit/071163b4))
+- Add Remote Reports to Admin Dashboard Reports page ([ef0ff78e](https://github.com/pixelfed/pixelfed/commit/ef0ff78e))
+- Improved Docker Support ([#4844](https://github.com/pixelfed/pixelfed/pull/4844)) ([d92cf7f](https://github.com/pixelfed/pixelfed/commit/d92cf7f))
+
+### Updates
+
+- Update Inbox, cast live filters to lowercase ([d835e0ad](https://github.com/pixelfed/pixelfed/commit/d835e0ad))
+- Update federation config, increase default timeline days falloff to 90 days from 2 days. Fixes #4905 ([011834f4](https://github.com/pixelfed/pixelfed/commit/011834f4))
+- Update cache config, use predis as default redis driver client ([ea6b1623](https://github.com/pixelfed/pixelfed/commit/ea6b1623))
+- Update .gitattributes to collapse diffs on generated files ([ThisIsMissEm](https://github.com/pixelfed/pixelfed/commit/9978b2b9))
+- Update api v1/v2 instance endpoints, bump mastoapi version from 2.7.2 to 3.5.3 ([545f7d5e](https://github.com/pixelfed/pixelfed/commit/545f7d5e))
+- Update ApiV1Controller, implement better limit logic to gracefully handle requests with limits that exceed the max ([1f74a95d](https://github.com/pixelfed/pixelfed/commit/1f74a95d))
+- Update AdminCuratedRegisterController, show oldest applications first ([c4dde641](https://github.com/pixelfed/pixelfed/commit/c4dde641))
+- Update Directory logic, add curated onboarding support ([59c70239](https://github.com/pixelfed/pixelfed/commit/59c70239))
+- Update Inbox and StatusObserver, fix silently rejected direct messages due to saveQuietly which failed to generate a snowflake id ([089ba3c4](https://github.com/pixelfed/pixelfed/commit/089ba3c4))
+- Update Curated Onboarding dashboard, improve application filtering and make it easier to distinguish response state ([2b5d7235](https://github.com/pixelfed/pixelfed/commit/2b5d7235))
+- Update AdminReports, add story reports and fix cs ([767522a8](https://github.com/pixelfed/pixelfed/commit/767522a8))
+- Update AdminReportController, add story report support ([a16309ac](https://github.com/pixelfed/pixelfed/commit/a16309ac))
+- Update kb, add email confirmation issues page ([2f48df8c](https://github.com/pixelfed/pixelfed/commit/2f48df8c))
+- Update AdminCuratedRegisterController, filter confirmation activities from activitylog ([ab9ecb6e](https://github.com/pixelfed/pixelfed/commit/ab9ecb6e))
+- Update Inbox, fix flag validation condition, allow profile reports ([402a4607](https://github.com/pixelfed/pixelfed/commit/402a4607))
+- Update AccountTransformer, fix follower/following count visibility bug ([542d1106](https://github.com/pixelfed/pixelfed/commit/542d1106))
+- Update ProfileMigration model, add target relation ([3f053997](https://github.com/pixelfed/pixelfed/commit/3f053997))
+- Update ApiV1Controller, update Notifications endpoint to filter notifications with missing activities ([a933615b](https://github.com/pixelfed/pixelfed/commit/a933615b))
+- Update ApiV1Controller, fix public timeline scope, properly support both local + remote parameters ([d6eac655](https://github.com/pixelfed/pixelfed/commit/d6eac655))
+- Update ApiV1Controller, handle public feed parameter bug to gracefully fallback to min_id=1 when max_id=0 ([e3826c58](https://github.com/pixelfed/pixelfed/commit/e3826c58))
+- Update ApiV1Controller, fix hashtag feed to include private posts from accounts you follow or your own, and your own unlisted posts ([3b5500b3](https://github.com/pixelfed/pixelfed/commit/3b5500b3))
+- Update checkpoint view, improve input autocomplete. Fixes ([#4959](https://github.com/pixelfed/pixelfed/pull/4959)) ([d18824e7](https://github.com/pixelfed/pixelfed/commit/d18824e7))
+- Update navbar.vue, removes the 50px limit ([#4969](https://github.com/pixelfed/pixelfed/pull/4969)) ([7fd5599](https://github.com/pixelfed/pixelfed/commit/7fd5599))
+- Update ComposeModal.vue, add an informative UI error message when trying to create a mixed media album ([#4886](https://github.com/pixelfed/pixelfed/pull/4886)) ([fd4f41a](https://github.com/pixelfed/pixelfed/commit/fd4f41a))
+
+## [v0.11.12 (2024-02-16)](https://github.com/pixelfed/pixelfed/compare/v0.11.11...v0.11.12)
+
+### Features
+- Autospam Live Filters - block remote activities based on comma separated keywords ([40b45b2a](https://github.com/pixelfed/pixelfed/commit/40b45b2a))
+- Added Software Update banner to admin home feeds ([b0fb1988](https://github.com/pixelfed/pixelfed/commit/b0fb1988))
+
+### Updates
+
+- Update ApiV1Controller, fix network timeline ([0faf59e3](https://github.com/pixelfed/pixelfed/commit/0faf59e3))
+- Update public/network timelines, fix non-redis response and fix reblogs in home feed ([8b4ac5cc](https://github.com/pixelfed/pixelfed/commit/8b4ac5cc))
+- Update Federation, use proper Content-Type headers for following/follower collections ([fb0bb9a3](https://github.com/pixelfed/pixelfed/commit/fb0bb9a3))
+- Update ActivityPubFetchService, enforce stricter Content-Type validation ([1232cfc8](https://github.com/pixelfed/pixelfed/commit/1232cfc8))
+- Update status view, fix unlisted/private scope bug ([0f3ca194](https://github.com/pixelfed/pixelfed/commit/0f3ca194))
+
+## [v0.11.11 (2024-02-09)](https://github.com/pixelfed/pixelfed/compare/v0.11.10...v0.11.11)
+
+### Fixes
+- Fix api endpoints ([fd7f5dbb](https://github.com/pixelfed/pixelfed/commit/fd7f5dbb))
+
+## [v0.11.10 (2024-02-09)](https://github.com/pixelfed/pixelfed/compare/v0.11.9...v0.11.10)
 
 ### Added
 - Resilient Media Storage ([#4665](https://github.com/pixelfed/pixelfed/pull/4665)) ([fb1deb6](https://github.com/pixelfed/pixelfed/commit/fb1deb6))
 - Video WebP2P ([#4713](https://github.com/pixelfed/pixelfed/pull/4713)) ([0405ef12](https://github.com/pixelfed/pixelfed/commit/0405ef12))
 - Added user:2fa command to easily disable 2FA for given account ([c6408fd7](https://github.com/pixelfed/pixelfed/commit/c6408fd7))
 - Added `avatar:storage-deep-clean` command to dispatch remote avatar storage cleanup jobs ([c37b7cde](https://github.com/pixelfed/pixelfed/commit/c37b7cde))
+- Added S3 command to rewrite media urls ([5b3a5610](https://github.com/pixelfed/pixelfed/commit/5b3a5610))
+- Experimental home feed ([#4752](https://github.com/pixelfed/pixelfed/pull/4752)) ([c39b9afb](https://github.com/pixelfed/pixelfed/commit/c39b9afb))
+- Added `app:hashtag-cached-count-update` command to update cached_count of hashtags and add to scheduler to run every 25 minutes past the hour ([1e31fee6](https://github.com/pixelfed/pixelfed/commit/1e31fee6))
+- Added `app:hashtag-related-generate` command to generate related hashtags ([176b4ed7](https://github.com/pixelfed/pixelfed/commit/176b4ed7))
+- Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46))
+- Added User Domain Blocks ([#4834](https://github.com/pixelfed/pixelfed/pull/4834)) ([fa0380ac](https://github.com/pixelfed/pixelfed/commit/fa0380ac))
+- Added Parental Controls ([#4862](https://github.com/pixelfed/pixelfed/pull/4862)) ([c91f1c59](https://github.com/pixelfed/pixelfed/commit/c91f1c59))
+- Added Forgot Email Feature ([67c650b1](https://github.com/pixelfed/pixelfed/commit/67c650b1))
+- Added S3 IG Import Media Storage support ([#4891](https://github.com/pixelfed/pixelfed/pull/4891)) ([081360b9](https://github.com/pixelfed/pixelfed/commit/081360b9))
 
 ### Federation
 - Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e))
 - Update AP Helpers, consume actor `indexable` attribute ([fbdcdd9d](https://github.com/pixelfed/pixelfed/commit/fbdcdd9d))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ### Updates
 - Update FollowerService, add forget method to RelationshipService call to reduce load when mass purging ([347e4f59](https://github.com/pixelfed/pixelfed/commit/347e4f59))
@@ -40,7 +284,62 @@
 - Update ApiV1Dot1Controller, allow iar rate limits to be configurable ([28a80803](https://github.com/pixelfed/pixelfed/commit/28a80803))
 - Update ApiV1Dot1Controller, add domain to iar redirect ([1f82d47c](https://github.com/pixelfed/pixelfed/commit/1f82d47c))
 - Update ApiV1Dot1Controller, add configurable app confirm rate limit ttl ([4c6a0719](https://github.com/pixelfed/pixelfed/commit/4c6a0719))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
+- Update LikePipeline, dispatch to feed queue. Fixes ([#4723](https://github.com/pixelfed/pixelfed/issues/4723)) ([da510089](https://github.com/pixelfed/pixelfed/commit/da510089))
+- Update AccountImport ([5a2d7e3e](https://github.com/pixelfed/pixelfed/commit/5a2d7e3e))
+- Update ImportPostController, fix IG bug with missing spaces between hashtags ([9c24157a](https://github.com/pixelfed/pixelfed/commit/9c24157a))
+- Update ApiV1Controller, fix mutes in home feed ([ddc21714](https://github.com/pixelfed/pixelfed/commit/ddc21714))
+- Update AP helpers, improve preferredUsername validation ([21218c79](https://github.com/pixelfed/pixelfed/commit/21218c79))
+- Update delete pipelines, properly invoke StatusHashtag delete events ([ce54d29c](https://github.com/pixelfed/pixelfed/commit/ce54d29c))
+- Update mail config ([0e431271](https://github.com/pixelfed/pixelfed/commit/0e431271))
+- Update hashtag following ([015b1b80](https://github.com/pixelfed/pixelfed/commit/015b1b80))
+- Update IncrementPostCount job, prevent overlap ([b2c9cc23](https://github.com/pixelfed/pixelfed/commit/b2c9cc23))
+- Update HashtagFollowService, fix cache invalidation bug ([84f4e885](https://github.com/pixelfed/pixelfed/commit/84f4e885))
+- Update Experimental Home Feed, fix remote posts, shares and reblogs ([c6a6b3ae](https://github.com/pixelfed/pixelfed/commit/c6a6b3ae))
+- Update HashtagService, improve count perf ([3327a008](https://github.com/pixelfed/pixelfed/commit/3327a008))
+- Update StatusHashtagService, remove problematic cache layer ([e5401f85](https://github.com/pixelfed/pixelfed/commit/e5401f85))
+- Update HomeFeedPipeline, fix tag filtering ([f105f4e8](https://github.com/pixelfed/pixelfed/commit/f105f4e8))
+- Update HashtagService, reduce cached_count cache ttl ([15f29f7d](https://github.com/pixelfed/pixelfed/commit/15f29f7d))
+- Update ApiV1Controller, fix include_reblogs param on timelines/home endpoint, and improve limit pagination logic ([287f903b](https://github.com/pixelfed/pixelfed/commit/287f903b))
+- Update StoryApiV1Controller, add self-carousel endpoint. Fixes ([#4352](https://github.com/pixelfed/pixelfed/issues/4352)) ([bcb88d5b](https://github.com/pixelfed/pixelfed/commit/bcb88d5b))
+- Update FollowServiceWarmCache, use more efficient query ([fe9b4c5a](https://github.com/pixelfed/pixelfed/commit/fe9b4c5a))
+- Update HomeFeedPipeline, observe mutes/blocks during fanout ([8548294c](https://github.com/pixelfed/pixelfed/commit/8548294c))
+- Update FederationController, add proper following/follower counts ([3204fb96](https://github.com/pixelfed/pixelfed/commit/3204fb96))
+- Update FederationController, add proper statuses counts ([3204fb96](https://github.com/pixelfed/pixelfed/commit/3204fb96))
+- Update Inbox handler, fix missing object_url and uri fields for direct statuses ([a0157fce](https://github.com/pixelfed/pixelfed/commit/a0157fce))
+- Update DirectMessageController, deliver direct delete activities to user inbox instead of sharedInbox ([d848792a](https://github.com/pixelfed/pixelfed/commit/d848792a))
+- Update DirectMessageController, dispatch deliver and delete actions to the job queue ([7f462a80](https://github.com/pixelfed/pixelfed/commit/7f462a80))
+- Update Inbox, improve story attribute collection ([06bee36c](https://github.com/pixelfed/pixelfed/commit/06bee36c))
+- Update DirectMessageController, dispatch local deletes to pipeline ([98186564](https://github.com/pixelfed/pixelfed/commit/98186564))
+- Update StatusPipeline, fix Direct and Story notification deletion ([4c95306f](https://github.com/pixelfed/pixelfed/commit/4c95306f))
+- Update Notifications.vue, fix deprecated DM action links for story activities ([4c3823b0](https://github.com/pixelfed/pixelfed/commit/4c3823b0))
+- Update ComposeModal, fix missing alttext post state ([0a068119](https://github.com/pixelfed/pixelfed/commit/0a068119))
+- Update PhotoAlbumPresenter.vue, fix fullscreen mode ([822e9888](https://github.com/pixelfed/pixelfed/commit/822e9888))
+- Update Timeline.vue, improve CHT pagination ([9c43e7e2](https://github.com/pixelfed/pixelfed/commit/9c43e7e2))
+- Update HomeFeedPipeline, fix StatusService validation ([041c0135](https://github.com/pixelfed/pixelfed/commit/041c0135))
+- Update Inbox, improve tombstone query efficiency ([759a4393](https://github.com/pixelfed/pixelfed/commit/759a4393))
+- Update AccountService, add setLastActive method ([ebbd98e7](https://github.com/pixelfed/pixelfed/commit/ebbd98e7))
+- Update ApiV1Controller, set last_active_at ([b6419545](https://github.com/pixelfed/pixelfed/commit/b6419545))
+- Update AdminShadowFilter, fix deleted profile bug ([a492a95a](https://github.com/pixelfed/pixelfed/commit/a492a95a))
+- Update FollowerService, add $silent param to remove method to more efficently purge relationships ([1664a5bc](https://github.com/pixelfed/pixelfed/commit/1664a5bc))
+- Update AP ProfileTransformer, add published attribute ([adfaa2b1](https://github.com/pixelfed/pixelfed/commit/adfaa2b1))
+- Update meta tags, improve descriptions and seo/og tags ([fd44c80c](https://github.com/pixelfed/pixelfed/commit/fd44c80c))
+- Update login view, add email prefill logic ([d76f0168](https://github.com/pixelfed/pixelfed/commit/d76f0168))
+- Update LoginController, fix captcha validation error message ([0325e171](https://github.com/pixelfed/pixelfed/commit/0325e171))
+- Update ApiV1Controller, properly cast boolean sensitive parameter. Fixes #4888 ([0aff126a](https://github.com/pixelfed/pixelfed/commit/0aff126a))
+- Update AccountImport.vue, fix new IG export format ([59aa6a4b](https://github.com/pixelfed/pixelfed/commit/59aa6a4b))
+- Update TransformImports command, fix import service condition ([32c59f04](https://github.com/pixelfed/pixelfed/commit/32c59f04))
+- Update AP helpers, more efficently update post count ([7caed381](https://github.com/pixelfed/pixelfed/commit/7caed381))
+- Update AP helpers, refactor post count decrement logic ([b81ae577](https://github.com/pixelfed/pixelfed/commit/b81ae577))
+- Update AP helpers, fix sensitive bug ([00ed330c](https://github.com/pixelfed/pixelfed/commit/00ed330c))
+- Update NotificationEpochUpdatePipeline, use more efficient query ([4d401389](https://github.com/pixelfed/pixelfed/commit/4d401389))
+- Update notification pipelines, fix non-local saving ([fa97a1f3](https://github.com/pixelfed/pixelfed/commit/fa97a1f3))
+- Update NodeinfoService, disable redirects ([240e6bbe](https://github.com/pixelfed/pixelfed/commit/240e6bbe))
+- Update Instance model, add entity casts ([289cad47](https://github.com/pixelfed/pixelfed/commit/289cad47))
+- Update FetchNodeinfoPipeline, use more efficient dispatch ([ac01f51a](https://github.com/pixelfed/pixelfed/commit/ac01f51a))
+- Update horizon.php config ([1e3acade](https://github.com/pixelfed/pixelfed/commit/1e3acade))
+- Update PublicApiController, consume InstanceService blocked domains for account and statuses endpoints ([01b33fb3](https://github.com/pixelfed/pixelfed/commit/01b33fb3))
+- Update ApiV1Controller, enforce blocked instance domain logic ([5b284cac](https://github.com/pixelfed/pixelfed/commit/5b284cac))
+- Update ApiV2Controller, add vapid key to instance object. Thanks thisismissem! ([4d02d6f1](https://github.com/pixelfed/pixelfed/commit/4d02d6f1))
 
 ## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9)
 

+ 18 - 0
CODEOWNERS

@@ -0,0 +1,18 @@
+# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
+
+# These owners will be the default owners for everything in
+# the repo. Unless a later match takes precedence,
+*           @dansup
+
+# Docker related files
+.editorconfig               @jippi @dansup
+.env                        @jippi @dansup
+.env.*                      @jippi @dansup
+.hadolint.yaml              @jippi @dansup
+.shellcheckrc               @jippi @dansup
+/.github/                   @jippi @dansup
+/docker/                    @jippi @dansup
+/tests/                     @jippi @dansup
+docker-compose.migrate.yml  @jippi @dansup
+docker-compose.yml          @jippi @dansup
+goss.yaml                   @jippi @dansup

+ 364 - 0
Dockerfile

@@ -0,0 +1,364 @@
+# syntax=docker/dockerfile:1
+# See https://hub.docker.com/r/docker/dockerfile
+
+#######################################################
+# Configuration
+#######################################################
+
+# See: https://github.com/mlocati/docker-php-extension-installer
+ARG DOCKER_PHP_EXTENSION_INSTALLER_VERSION="2.1.80"
+
+# See: https://github.com/composer/composer
+ARG COMPOSER_VERSION="2.6"
+
+# See: https://nginx.org/
+ARG NGINX_VERSION="1.25.3"
+
+# See: https://github.com/ddollar/forego
+ARG FOREGO_VERSION="0.17.2"
+
+# See: https://github.com/hairyhenderson/gomplate
+ARG GOMPLATE_VERSION="v3.11.6"
+
+# See: https://github.com/jippi/dottie
+ARG DOTTIE_VERSION="v0.9.5"
+
+###
+# PHP base configuration
+###
+
+# See: https://hub.docker.com/_/php/tags
+ARG PHP_VERSION="8.1"
+
+# See: https://github.com/docker-library/docs/blob/master/php/README.md#image-variants
+ARG PHP_BASE_TYPE="apache"
+ARG PHP_DEBIAN_RELEASE="bullseye"
+
+ARG RUNTIME_UID=33 # often called 'www-data'
+ARG RUNTIME_GID=33 # often called 'www-data'
+
+# APT extra packages
+ARG APT_PACKAGES_EXTRA=
+
+# Extensions installed via [pecl install]
+# ! NOTE: imagick is installed from [master] branch on GitHub due to 8.3 bug on ARM that haven't
+# ! been released yet (after +10 months)!
+# ! See: https://github.com/Imagick/imagick/pull/641
+ARG PHP_PECL_EXTENSIONS="redis https://codeload.github.com/Imagick/imagick/tar.gz/28f27044e435a2b203e32675e942eb8de620ee58"
+ARG PHP_PECL_EXTENSIONS_EXTRA=
+
+# Extensions installed via [docker-php-ext-install]
+ARG PHP_EXTENSIONS="intl bcmath zip pcntl exif curl gd"
+ARG PHP_EXTENSIONS_EXTRA=""
+ARG PHP_EXTENSIONS_DATABASE="pdo_pgsql pdo_mysql pdo_sqlite"
+
+# GPG key for nginx apt repository
+ARG NGINX_GPGKEY="573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62"
+
+# GPP key path for nginx apt repository
+ARG NGINX_GPGKEY_PATH="/usr/share/keyrings/nginx-archive-keyring.gpg"
+
+#######################################################
+# Docker "copy from" images
+#######################################################
+
+# Composer docker image from Docker Hub
+#
+# NOTE: Docker will *not* pull this image unless it's referenced (via build target)
+FROM composer:${COMPOSER_VERSION} AS composer-image
+
+# php-extension-installer image from Docker Hub
+#
+# NOTE: Docker will *not* pull this image unless it's referenced (via build target)
+FROM mlocati/php-extension-installer:${DOCKER_PHP_EXTENSION_INSTALLER_VERSION} AS php-extension-installer
+
+# nginx webserver from Docker Hub.
+# Used to copy some docker-entrypoint files for [nginx-runtime]
+#
+# NOTE: Docker will *not* pull this image unless it's referenced (via build target)
+FROM nginx:${NGINX_VERSION} AS nginx-image
+
+# Forego is a Procfile "runner" that makes it trival to run multiple
+# processes under a simple init / PID 1 process.
+#
+# NOTE: Docker will *not* pull this image unless it's referenced (via build target)
+#
+# See: https://github.com/nginx-proxy/forego
+FROM nginxproxy/forego:${FOREGO_VERSION}-debian AS forego-image
+
+# Dottie makes working with .env files easier and safer
+#
+# NOTE: Docker will *not* pull this image unless it's referenced (via build target)
+#
+# See: https://github.com/jippi/dottie
+FROM ghcr.io/jippi/dottie:${DOTTIE_VERSION} AS dottie-image
+
+# gomplate-image grabs the gomplate binary from GitHub releases
+#
+# It's in its own layer so it can be fetched in parallel with other build steps
+FROM php:${PHP_VERSION}-${PHP_BASE_TYPE}-${PHP_DEBIAN_RELEASE} AS gomplate-image
+
+ARG TARGETARCH
+ARG TARGETOS
+ARG GOMPLATE_VERSION
+
+RUN set -ex \
+    && curl \
+        --silent \
+        --show-error \
+        --location \
+        --output /usr/local/bin/gomplate \
+        https://github.com/hairyhenderson/gomplate/releases/download/${GOMPLATE_VERSION}/gomplate_${TARGETOS}-${TARGETARCH} \
+    && chmod +x /usr/local/bin/gomplate \
+    && /usr/local/bin/gomplate --version
+
+#######################################################
+# Base image
+#######################################################
+
+FROM php:${PHP_VERSION}-${PHP_BASE_TYPE}-${PHP_DEBIAN_RELEASE} AS base
+
+ARG BUILDKIT_SBOM_SCAN_STAGE="true"
+
+ARG APT_PACKAGES_EXTRA
+ARG PHP_DEBIAN_RELEASE
+ARG PHP_VERSION
+ARG RUNTIME_GID
+ARG RUNTIME_UID
+ARG TARGETPLATFORM
+
+ENV DEBIAN_FRONTEND="noninteractive"
+
+# Ensure we run all scripts through 'bash' rather than 'sh'
+SHELL ["/bin/bash", "-c"]
+
+# Set www-data to be RUNTIME_UID/RUNTIME_GID
+RUN groupmod --gid ${RUNTIME_GID} www-data \
+    && usermod --uid ${RUNTIME_UID} --gid ${RUNTIME_GID} www-data
+
+RUN set -ex \
+    && mkdir -pv /var/www/ \
+    && chown -R ${RUNTIME_UID}:${RUNTIME_GID} /var/www
+
+WORKDIR /var/www/
+
+ENV APT_PACKAGES_EXTRA=${APT_PACKAGES_EXTRA}
+
+# Install and configure base layer
+COPY docker/shared/root/docker/install/base.sh /docker/install/base.sh
+
+RUN --mount=type=cache,id=pixelfed-apt-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/lib/apt \
+    --mount=type=cache,id=pixelfed-apt-cache-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/cache/apt \
+    /docker/install/base.sh
+
+#######################################################
+# PHP: extensions
+#######################################################
+
+FROM base AS php-extensions
+
+ARG PHP_DEBIAN_RELEASE
+ARG PHP_EXTENSIONS
+ARG PHP_EXTENSIONS_DATABASE
+ARG PHP_EXTENSIONS_EXTRA
+ARG PHP_PECL_EXTENSIONS
+ARG PHP_PECL_EXTENSIONS_EXTRA
+ARG PHP_VERSION
+ARG TARGETPLATFORM
+
+COPY --from=php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
+
+COPY docker/shared/root/docker/install/php-extensions.sh /docker/install/php-extensions.sh
+
+RUN --mount=type=cache,id=pixelfed-pear-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/tmp/pear  \
+    --mount=type=cache,id=pixelfed-apt-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/lib/apt \
+    --mount=type=cache,id=pixelfed-apt-cache-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/cache/apt \
+    PHP_EXTENSIONS=${PHP_EXTENSIONS} \
+    PHP_EXTENSIONS_DATABASE=${PHP_EXTENSIONS_DATABASE} \
+    PHP_EXTENSIONS_EXTRA=${PHP_EXTENSIONS_EXTRA} \
+    PHP_PECL_EXTENSIONS=${PHP_PECL_EXTENSIONS} \
+    PHP_PECL_EXTENSIONS_EXTRA=${PHP_PECL_EXTENSIONS_EXTRA} \
+    /docker/install/php-extensions.sh
+
+#######################################################
+# Node: Build frontend
+#######################################################
+
+# NOTE: Since the nodejs build is CPU architecture agnostic,
+# we only want to build once and cache it for other architectures.
+# We force the (CPU) [--platform] here to be architecture
+# of the "builder"/"server" and not the *target* CPU architecture
+# (e.g.) building the ARM version of Pixelfed on AMD64.
+FROM --platform=${BUILDARCH} node:lts AS frontend-build
+
+ARG BUILDARCH
+ARG BUILD_FRONTEND=0
+ARG RUNTIME_UID
+ARG RUNTIME_GID
+
+ARG NODE_ENV=production
+ENV NODE_ENV=$NODE_ENV
+
+WORKDIR /var/www/
+
+SHELL [ "/usr/bin/bash", "-c" ]
+
+# Install NPM dependencies
+RUN --mount=type=cache,id=pixelfed-node-${BUILDARCH},sharing=locked,target=/tmp/cache \
+    --mount=type=bind,source=package.json,target=/var/www/package.json \
+    --mount=type=bind,source=package-lock.json,target=/var/www/package-lock.json \
+<<EOF
+    if [[ $BUILD_FRONTEND -eq 1 ]];
+    then
+        npm install --cache /tmp/cache --no-save --dev
+    else
+        echo "Skipping [npm install] as --build-arg [BUILD_FRONTEND] is not set to '1'"
+    fi
+EOF
+
+# Copy the frontend source into the image before building
+COPY --chown=${RUNTIME_UID}:${RUNTIME_GID} . /var/www
+
+# Build the frontend with "mix" (See package.json)
+RUN \
+<<EOF
+    if [[ $BUILD_FRONTEND -eq 1 ]];
+    then
+        npm run production
+    else
+        echo "Skipping [npm run production] as --build-arg [BUILD_FRONTEND] is not set to '1'"
+    fi
+EOF
+
+#######################################################
+# PHP: composer and source code
+#######################################################
+
+FROM php-extensions AS composer-and-src
+
+ARG PHP_VERSION
+ARG PHP_DEBIAN_RELEASE
+ARG RUNTIME_UID
+ARG RUNTIME_GID
+ARG TARGETPLATFORM
+
+# Make sure composer cache is targeting our cache mount later
+ENV COMPOSER_CACHE_DIR="/cache/composer"
+
+# Don't enforce any memory limits for composer
+ENV COMPOSER_MEMORY_LIMIT=-1
+
+# Disable interactvitity from composer
+ENV COMPOSER_NO_INTERACTION=1
+
+# Copy composer from https://hub.docker.com/_/composer
+COPY --link --from=composer-image /usr/bin/composer /usr/bin/composer
+
+#! Changing user to runtime user
+USER ${RUNTIME_UID}:${RUNTIME_GID}
+
+
+# Install composer dependencies
+# NOTE: we skip the autoloader generation here since we don't have all files avaliable (yet)
+RUN --mount=type=cache,id=pixelfed-composer-${PHP_VERSION},sharing=locked,uid=${RUNTIME_UID},gid=${RUNTIME_GID},target=/cache/composer \
+    --mount=type=bind,source=composer.json,target=/var/www/composer.json \
+    --mount=type=bind,source=composer.lock,target=/var/www/composer.lock \
+    set -ex \
+    && composer install --prefer-dist --no-autoloader --ignore-platform-reqs --no-scripts
+
+# Copy all other files over
+COPY --chown=${RUNTIME_UID}:${RUNTIME_GID} . /var/www/
+
+# Generate optimized autoloader now that we have all files around
+RUN set -ex \
+    && ENABLE_CONFIG_CACHE=false composer dump-autoload --optimize
+
+# Now we can run the post-install scripts
+RUN set -ex \
+    && composer run-script post-update-cmd
+
+#######################################################
+# Runtime: base
+#######################################################
+
+FROM php-extensions AS shared-runtime
+
+ARG RUNTIME_GID
+ARG RUNTIME_UID
+
+ENV RUNTIME_UID=${RUNTIME_UID}
+ENV RUNTIME_GID=${RUNTIME_GID}
+
+COPY --link --from=forego-image /usr/local/bin/forego /usr/local/bin/forego
+COPY --link --from=dottie-image /dottie /usr/local/bin/dottie
+COPY --link --from=gomplate-image /usr/local/bin/gomplate /usr/local/bin/gomplate
+COPY --link --from=composer-image /usr/bin/composer /usr/bin/composer
+COPY --link --from=composer-and-src --chown=${RUNTIME_UID}:${RUNTIME_GID} /var/www /var/www
+COPY --link --from=frontend-build --chown=${RUNTIME_UID}:${RUNTIME_GID} /var/www/public /var/www/public
+
+USER root
+
+# for detail why storage is copied this way, pls refer to https://github.com/pixelfed/pixelfed/pull/2137#discussion_r434468862
+RUN set -ex \
+    && cp --recursive --link --preserve=all storage storage.skel \
+    && rm -rf html && ln -s public html
+
+COPY docker/shared/root /
+
+ENTRYPOINT ["/docker/entrypoint.sh"]
+
+#######################################################
+# Runtime: apache
+#######################################################
+
+FROM shared-runtime AS apache-runtime
+
+COPY docker/apache/root /
+
+RUN set -ex \
+    && a2enmod rewrite remoteip proxy proxy_http \
+    && a2enconf remoteip
+
+CMD ["apache2-foreground"]
+
+#######################################################
+# Runtime: fpm
+#######################################################
+
+FROM shared-runtime AS fpm-runtime
+
+COPY docker/fpm/root /
+
+CMD ["php-fpm"]
+
+#######################################################
+# Runtime: nginx
+#######################################################
+
+FROM shared-runtime AS nginx-runtime
+
+ARG NGINX_GPGKEY
+ARG NGINX_GPGKEY_PATH
+ARG NGINX_VERSION
+ARG PHP_DEBIAN_RELEASE
+ARG PHP_VERSION
+ARG TARGETPLATFORM
+
+# Install nginx dependencies
+RUN --mount=type=cache,id=pixelfed-apt-lists-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/lib/apt/lists \
+    --mount=type=cache,id=pixelfed-apt-cache-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${TARGETPLATFORM},sharing=locked,target=/var/cache/apt \
+    set -ex \
+    && gpg1 --keyserver "hkp://keyserver.ubuntu.com:80" --keyserver-options timeout=10 --recv-keys "${NGINX_GPGKEY}" \
+    && gpg1 --export "$NGINX_GPGKEY" > "$NGINX_GPGKEY_PATH" \
+    && echo "deb [signed-by=${NGINX_GPGKEY_PATH}] https://nginx.org/packages/mainline/debian/ ${PHP_DEBIAN_RELEASE} nginx" >> /etc/apt/sources.list.d/nginx.list \
+    && apt-get update \
+    && apt-get install -y --no-install-recommends nginx=${NGINX_VERSION}*
+
+# copy docker entrypoints from the *real* nginx image directly
+COPY --link --from=nginx-image /docker-entrypoint.d /docker/entrypoint.d/
+COPY docker/nginx/root /
+COPY docker/nginx/Procfile .
+
+STOPSIGNAL SIGQUIT
+
+CMD ["forego", "start", "-r"]

+ 7 - 0
README.md

@@ -43,3 +43,10 @@ We would like to extend our thanks to the following sponsors for funding Pixelfe
 - [NLnet Foundation](https://nlnet.nl) and [NGI0
 Discovery](https://nlnet.nl/discovery/), part of the [Next Generation
 Internet](https://ngi.eu) initiative.
+
+<p>This project is supported by:</p>
+<p>
+  <a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=pixelfed">
+    <img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
+  </a>
+</p>

+ 1 - 2
app/Auth/BearerTokenResponse.php

@@ -18,8 +18,7 @@ class BearerTokenResponse extends \League\OAuth2\Server\ResponseTypes\BearerToke
     protected function getExtraParams(AccessTokenEntityInterface $accessToken)
     {
         return [
-        	'created_at' => time(),
-        	'scope' => 'read write follow push'
+            'created_at' => time(),
         ];
     }
 }

+ 57 - 0
app/Console/Commands/AccountPostCountStatUpdate.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\AccountService;
+use App\Services\Account\AccountStatService;
+use App\Status;
+use App\Profile;
+
+class AccountPostCountStatUpdate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:account-post-count-stat-update';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Update post counts from recent activities';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $ids = AccountStatService::getAllPostCountIncr();
+        if(!$ids || !count($ids)) {
+            return;
+        }
+        foreach($ids as $id) {
+            $acct = AccountService::get($id, true);
+            if(!$acct) {
+                AccountStatService::removeFromPostCount($id);
+                continue;
+            }
+            $statusCount = Status::whereProfileId($id)->count();
+            if($statusCount != $acct['statuses_count']) {
+                $profile = Profile::find($id);
+                if(!$profile) {
+                    AccountStatService::removeFromPostCount($id);
+                    continue;
+                }
+                $profile->status_count = $statusCount;
+                $profile->save();
+                AccountService::del($id);
+            }
+            AccountStatService::removeFromPostCount($id);
+        }
+        return;
+    }
+}

+ 106 - 0
app/Console/Commands/AddUserDomainBlock.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\User;
+use App\Models\DefaultDomainBlock;
+use App\Models\UserDomainBlock;
+use function Laravel\Prompts\text;
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\progress;
+
+class AddUserDomainBlock extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:add-user-domain-block';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Apply a domain block to all users';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $domain = text('Enter domain you want to block');
+        $domain = strtolower($domain);
+        $domain = $this->validateDomain($domain);
+        if(!$domain || empty($domain)) {
+            $this->error('Invalid domain');
+            return;
+        }
+        $this->processBlocks($domain);
+        return;
+    }
+
+    protected function validateDomain($domain)
+    {
+        if(!strpos($domain, '.')) {
+            return;
+        }
+
+        if(str_starts_with($domain, 'https://')) {
+            $domain = str_replace('https://', '', $domain);
+        }
+
+        if(str_starts_with($domain, 'http://')) {
+            $domain = str_replace('http://', '', $domain);
+        }
+
+        $domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST));
+
+        $valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE);
+        if(!$valid) {
+            return;
+        }
+
+        if($domain === config('pixelfed.domain.app')) {
+            $this->error('Invalid domain');
+            return;
+        }
+
+        $confirmed = confirm('Are you sure you want to block ' . $domain . '?');
+        if(!$confirmed) {
+            return;
+        }
+
+        return $domain;
+    }
+
+    protected function processBlocks($domain)
+    {
+        DefaultDomainBlock::updateOrCreate([
+            'domain' => $domain
+        ]);
+        progress(
+            label: 'Updating user domain blocks...',
+            steps: User::lazyById(500),
+            callback: fn ($user) => $this->performTask($user, $domain),
+        );
+    }
+
+    protected function performTask($user, $domain)
+    {
+        if(!$user->profile_id || $user->delete_after) {
+            return;
+        }
+
+        if($user->status != null && $user->status != 'disabled') {
+            return;
+        }
+
+        UserDomainBlock::updateOrCreate([
+            'profile_id' => $user->profile_id,
+            'domain' => $domain
+        ]);
+    }
+}

+ 5 - 5
app/Console/Commands/AvatarStorage.php

@@ -82,7 +82,7 @@ class AvatarStorage extends Command
 
         $this->line(' ');
 
-        if(config_cache('pixelfed.cloud_storage')) {
+        if((bool) config_cache('pixelfed.cloud_storage')) {
             $this->info('✅ - Cloud storage configured');
             $this->line(' ');
         }
@@ -92,7 +92,7 @@ class AvatarStorage extends Command
             $this->line(' ');
         }
 
-        if(config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
+        if((bool) config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
             $disk = Storage::disk(config_cache('filesystems.cloud'));
             $exists = $disk->exists('cache/avatars/default.jpg');
             $state = $exists ? '✅' : '❌';
@@ -100,7 +100,7 @@ class AvatarStorage extends Command
             $this->info($msg);
         }
 
-        $options = config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud') ?
+        $options = (bool) config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud') ?
             [
                 'Cancel',
                 'Upload default avatar to cloud',
@@ -164,7 +164,7 @@ class AvatarStorage extends Command
 
     protected function uploadAvatarsToCloud()
     {
-        if(!config_cache('pixelfed.cloud_storage') || !config('instance.avatar.local_to_cloud')) {
+        if(!(bool) config_cache('pixelfed.cloud_storage') || !config('instance.avatar.local_to_cloud')) {
             $this->error('Enable cloud storage and avatar cloud storage to perform this action');
             return;
         }
@@ -213,7 +213,7 @@ class AvatarStorage extends Command
             return;
         }
 
-        if(config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) {
+        if((bool) config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) {
             $this->error('You have cloud storage disabled and local avatar storage disabled, we cannot refetch avatars.');
             return;
         }

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

@@ -44,7 +44,7 @@ class AvatarStorageDeepClean extends Command
         $this->line(' ');
 
         $storage = [
-            'cloud' => boolval(config_cache('pixelfed.cloud_storage')),
+            'cloud' => (bool) config_cache('pixelfed.cloud_storage'),
             'local' => boolval(config_cache('federation.avatars.store_local'))
         ];
 

+ 52 - 0
app/Console/Commands/CaptchaToggleCommand.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use function Laravel\Prompts\info;
+use function Laravel\Prompts\confirm;
+use App\Services\ConfigCacheService;
+
+class CaptchaToggleCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:captcha-toggle-command';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $captchaEnabled = (bool) config_cache('captcha.enabled');
+
+        info($captchaEnabled ? 'Captcha is enabled' : 'Captcha is not enabled');
+
+        if(!$captchaEnabled) {
+            info('Enable the Captcha from the admin settings dashboard.');
+            return;
+        }
+
+        $confirmed = confirm(
+            label: 'Do you want to disable the captcha?',
+            default: false,
+            yes: 'Yes',
+            no: 'No',
+            hint: 'Select an option to proceed.'
+        );
+
+        if($confirmed) {
+            ConfigCacheService::put('captcha.enabled', false);
+        }
+    }
+}

+ 5 - 1
app/Console/Commands/CloudMediaMigrate.php

@@ -35,12 +35,16 @@ class CloudMediaMigrate extends Command
      */
     public function handle()
     {
-        $enabled = config('pixelfed.cloud_storage');
+        $enabled = (bool) config_cache('pixelfed.cloud_storage');
         if(!$enabled) {
             $this->error('Cloud storage not enabled. Exiting...');
             return;
         }
 
+        if(!$this->confirm('Are you sure you want to proceed?')) {
+            return;
+        }
+
         $limit = $this->option('limit');
         $hugeMode = $this->option('huge');
 

+ 51 - 0
app/Console/Commands/DeleteRemoteProfile.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
+use App\Profile;
+use Illuminate\Console\Command;
+
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\search;
+
+class DeleteRemoteProfile extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:delete-remote-profile';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Delete remote profile';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $id = search(
+            'Search for the account',
+            fn (string $value) => strlen($value) > 2
+                ? Profile::whereNotNull('domain')->where('username', 'like', $value.'%')->pluck('username', 'id')->all()
+                : []
+        );
+        $profile = Profile::whereNotNull('domain')->find($id);
+
+        if (! $profile) {
+            $this->error('Could not find profile.');
+            exit;
+        }
+
+        $confirmed = confirm('Are you sure you want to delete '.$profile->username.'\'s account? This action cannot be reversed.');
+        DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('adelete');
+        $this->info('Dispatched delete job, it may take a few minutes...');
+        exit;
+    }
+}

+ 96 - 0
app/Console/Commands/DeleteUserDomainBlock.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\User;
+use App\Models\DefaultDomainBlock;
+use App\Models\UserDomainBlock;
+use function Laravel\Prompts\text;
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\progress;
+
+class DeleteUserDomainBlock extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:delete-user-domain-block';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Remove a domain block for all users';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $domain = text('Enter domain you want to unblock');
+        $domain = strtolower($domain);
+        $domain = $this->validateDomain($domain);
+        if(!$domain || empty($domain)) {
+            $this->error('Invalid domain');
+            return;
+        }
+        $this->processUnblocks($domain);
+        return;
+    }
+
+    protected function validateDomain($domain)
+    {
+        if(!strpos($domain, '.')) {
+            return;
+        }
+
+        if(str_starts_with($domain, 'https://')) {
+            $domain = str_replace('https://', '', $domain);
+        }
+
+        if(str_starts_with($domain, 'http://')) {
+            $domain = str_replace('http://', '', $domain);
+        }
+
+        $domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST));
+
+        $valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE);
+        if(!$valid) {
+            return;
+        }
+
+        if($domain === config('pixelfed.domain.app')) {
+            return;
+        }
+
+        $confirmed = confirm('Are you sure you want to unblock ' . $domain . '?');
+        if(!$confirmed) {
+            return;
+        }
+
+        return $domain;
+    }
+
+    protected function processUnblocks($domain)
+    {
+        DefaultDomainBlock::whereDomain($domain)->delete();
+        if(!UserDomainBlock::whereDomain($domain)->count()) {
+            $this->info('No results found!');
+            return;
+        }
+        progress(
+            label: 'Updating user domain blocks...',
+            steps: UserDomainBlock::whereDomain($domain)->lazyById(500),
+            callback: fn ($domainBlock) => $this->performTask($domainBlock),
+        );
+    }
+
+    protected function performTask($domainBlock)
+    {
+        $domainBlock->deleteQuietly();
+    }
+}

+ 7 - 7
app/Console/Commands/FetchMissingMediaMimeType.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
 use App\Media;
-use Illuminate\Support\Facades\Http;
 use App\Services\MediaService;
 use App\Services\StatusService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
 
 class FetchMissingMediaMimeType extends Command
 {
@@ -29,20 +29,20 @@ class FetchMissingMediaMimeType extends Command
      */
     public function handle()
     {
-        foreach(Media::whereNotNull(['remote_url', 'status_id'])->whereNull('mime')->lazyByIdDesc(50, 'id') as $media) {
+        foreach (Media::whereNotNull(['remote_url', 'status_id'])->whereNull('mime')->lazyByIdDesc(50, 'id') as $media) {
             $res = Http::retry(2, 100, throw: false)->head($media->remote_url);
 
-            if(!$res->successful()) {
+            if (! $res->successful()) {
                 continue;
             }
 
-            if(!in_array($res->header('content-type'), explode(',',config('pixelfed.media_types')))) {
+            if (! in_array($res->header('content-type'), explode(',', config_cache('pixelfed.media_types')))) {
                 continue;
             }
 
             $media->mime = $res->header('content-type');
 
-            if($res->hasHeader('content-length')) {
+            if ($res->hasHeader('content-length')) {
                 $media->size = $res->header('content-length');
             }
 
@@ -50,7 +50,7 @@ class FetchMissingMediaMimeType extends Command
 
             MediaService::del($media->status_id);
             StatusService::del($media->status_id);
-            $this->info('mid:'.$media->id . ' (' . $res->header('content-type') . ':' . $res->header('content-length') . ' bytes)');
+            $this->info('mid:'.$media->id.' ('.$res->header('content-type').':'.$res->header('content-length').' bytes)');
         }
     }
 }

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

@@ -37,7 +37,7 @@ class FixMediaDriver extends Command
 			return Command::SUCCESS;
 		}
 
-		if(config_cache('pixelfed.cloud_storage') == false) {
+		if((bool) config_cache('pixelfed.cloud_storage') == false) {
 			$this->error('Cloud storage not enabled, exiting...');
 			return Command::SUCCESS;
 		}

+ 57 - 0
app/Console/Commands/HashtagCachedCountUpdate.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Hashtag;
+use App\StatusHashtag;
+use DB;
+
+class HashtagCachedCountUpdate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:hashtag-cached-count-update {--limit=100}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Update cached counter of hashtags';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $limit = $this->option('limit');
+        $tags = Hashtag::whereNull('cached_count')->limit($limit)->get();
+        $count = count($tags);
+        if(!$count) {
+            return;
+        }
+
+        $bar = $this->output->createProgressBar($count);
+        $bar->start();
+
+        foreach($tags as $tag) {
+            $count = DB::table('status_hashtags')->whereHashtagId($tag->id)->count();
+            if(!$count) {
+                $tag->cached_count = 0;
+                $tag->saveQuietly();
+                $bar->advance();
+                continue;
+            }
+            $tag->cached_count = $count;
+            $tag->saveQuietly();
+            $bar->advance();
+        }
+        $bar->finish();
+        $this->line(' ');
+        return;
+    }
+}

+ 94 - 0
app/Console/Commands/HashtagRelatedGenerate.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Hashtag;
+use App\StatusHashtag;
+use App\Models\HashtagRelated;
+use App\Services\HashtagRelatedService;
+use Illuminate\Contracts\Console\PromptsForMissingInput;
+use function Laravel\Prompts\multiselect;
+use function Laravel\Prompts\confirm;
+
+class HashtagRelatedGenerate extends Command implements PromptsForMissingInput
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:hashtag-related-generate {tag}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Prompt for missing input arguments using the returned questions.
+     *
+     * @return array
+     */
+    protected function promptForMissingArgumentsUsing()
+    {
+        return [
+            'tag' => 'Which hashtag should we generate related tags for?',
+        ];
+    }
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $tag = $this->argument('tag');
+        $hashtag = Hashtag::whereName($tag)->orWhere('slug', $tag)->first();
+        if(!$hashtag) {
+            $this->error('Hashtag not found, aborting...');
+            exit;
+        }
+
+        $exists = HashtagRelated::whereHashtagId($hashtag->id)->exists();
+
+        if($exists) {
+            $confirmed = confirm('Found existing related tags, do you want to regenerate them?');
+            if(!$confirmed) {
+                $this->error('Aborting...');
+                exit;
+            }
+        }
+
+        $this->info('Looking up #' . $tag . '...');
+
+        $tags = StatusHashtag::whereHashtagId($hashtag->id)->count();
+        if(!$tags || $tags < 100) {
+            $this->error('Not enough posts found to generate related hashtags!');
+            exit;
+        }
+
+        $this->info('Found ' . $tags . ' posts that use that hashtag');
+        $related = collect(HashtagRelatedService::fetchRelatedTags($tag));
+
+        $selected = multiselect(
+            label: 'Which tags do you want to generate?',
+            options: $related->pluck('name'),
+            required: true,
+        );
+
+        $filtered = $related->filter(fn($i) => in_array($i['name'], $selected))->all();
+        $agg_score = $related->filter(fn($i) => in_array($i['name'], $selected))->sum('related_count');
+
+        HashtagRelated::updateOrCreate([
+            'hashtag_id' => $hashtag->id,
+        ], [
+            'related_tags' => array_values($filtered),
+            'agg_score' => $agg_score,
+            'last_calculated_at' => now()
+        ]);
+
+        $this->info('Finished!');
+    }
+}

+ 118 - 0
app/Console/Commands/ImportEmojis.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\CustomEmoji;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+
+class ImportEmojis extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'import:emojis
+                            {path : Path to a tar.gz archive with the emojis}
+                            {--prefix : Define a prefix for the emjoi shortcode}
+                            {--suffix : Define a suffix for the emjoi shortcode}
+                            {--overwrite : Overwrite existing emojis}
+                            {--disabled : Import all emojis as disabled}';
+
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Import emojis to the database';
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $path = $this->argument('path');
+
+        if (!file_exists($path) || !mime_content_type($path) == 'application/x-tar') {
+            $this->error('Path does not exist or is not a tarfile');
+            return Command::FAILURE;
+        }
+
+        $imported = 0;
+        $skipped = 0;
+        $failed = 0;
+
+        $tar = new \PharData($path);
+        $tar->decompress();
+
+        foreach (new \RecursiveIteratorIterator($tar) as $entry) {
+            $this->line("Processing {$entry->getFilename()}");
+            if (!$entry->isFile() || !$this->isImage($entry) || !$this->isEmoji($entry->getPathname())) {
+                $failed++;
+                continue;
+            }
+
+            $filename = pathinfo($entry->getFilename(), PATHINFO_FILENAME);
+            $extension = pathinfo($entry->getFilename(), PATHINFO_EXTENSION);
+
+            // Skip macOS shadow files
+            if (str_starts_with($filename, '._')) {
+                continue;
+            }
+
+            $shortcode = implode('', [
+                $this->option('prefix'),
+                $filename,
+                $this->option('suffix'),
+            ]);
+
+            $customEmoji = CustomEmoji::whereShortcode($shortcode)->first();
+
+            if ($customEmoji && !$this->option('overwrite')) {
+                $skipped++;
+                continue;
+            }
+
+            $emoji = $customEmoji ?? new CustomEmoji();
+            $emoji->shortcode = $shortcode;
+            $emoji->domain = config('pixelfed.domain.app');
+            $emoji->disabled = $this->option('disabled');
+            $emoji->save();
+
+            $fileName = $emoji->id . '.' . $extension;
+            Storage::putFileAs('public/emoji', $entry->getPathname(), $fileName);
+            $emoji->media_path = 'emoji/' . $fileName;
+            $emoji->save();
+            $imported++;
+            Cache::forget('pf:custom_emoji');
+        }
+
+        $this->line("Imported: {$imported}");
+        $this->line("Skipped: {$skipped}");
+        $this->line("Failed: {$failed}");
+
+        //delete file
+        unlink(str_replace('.tar.gz', '.tar', $path));
+
+        return Command::SUCCESS;
+    }
+
+    private function isImage($file)
+    {
+        $image = getimagesize($file->getPathname());
+        return $image !== false;
+    }
+
+    private function isEmoji($filename)
+    {
+        $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp'];
+        $mimeType = mime_content_type($filename);
+
+        return in_array($mimeType, $allowedMimeTypes);
+    }
+}

+ 54 - 0
app/Console/Commands/ImportUploadMediaToCloudStorage.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\ImportPost;
+use App\Jobs\ImportPipeline\ImportMediaToCloudPipeline;
+use function Laravel\Prompts\progress;
+
+class ImportUploadMediaToCloudStorage extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:import-upload-media-to-cloud-storage {--limit=500}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Migrate media imported from Instagram to S3 cloud storage.';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        if(
+            (bool) config('import.instagram.storage.cloud.enabled') === false ||
+            (bool) config_cache('pixelfed.cloud_storage') === false
+        ) {
+            $this->error('Aborted. Cloud storage is not enabled for IG imports.');
+            return;
+        }
+
+        $limit = $this->option('limit');
+
+        $progress = progress(label: 'Migrating import media', steps: $limit);
+
+        $progress->start();
+
+        $posts = ImportPost::whereUploadedToS3(false)->take($limit)->get();
+
+        foreach($posts as $post) {
+            ImportMediaToCloudPipeline::dispatch($post)->onQueue('low');
+            $progress->advance();
+        }
+
+        $progress->finish();
+    }
+}

+ 298 - 0
app/Console/Commands/InstanceManager.php

@@ -0,0 +1,298 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Instance;
+use App\Profile;
+use App\Services\InstanceService;
+use App\Jobs\InstancePipeline\FetchNodeinfoPipeline;
+use function Laravel\Prompts\select;
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\progress;
+use function Laravel\Prompts\search;
+use function Laravel\Prompts\table;
+
+class InstanceManager extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:instance-manager';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Manage Instances';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $action = select(
+            'What action do you want to perform?',
+            [
+                'Recalculate Stats',
+                'Ban Instance',
+                'Unlist Instance',
+                'Unlisted Instances',
+                'Banned Instances',
+                'Unban Instance',
+                'Relist Instance',
+            ],
+        );
+
+        switch($action) {
+            case 'Recalculate Stats':
+                return $this->recalculateStats();
+            break;
+
+            case 'Unlisted Instances':
+                return $this->viewUnlistedInstances();
+            break;
+
+            case 'Banned Instances':
+                return $this->viewBannedInstances();
+            break;
+
+            case 'Unlist Instance':
+                return $this->unlistInstance();
+            break;
+
+            case 'Ban Instance':
+                return $this->banInstance();
+            break;
+
+            case 'Unban Instance':
+                return $this->unbanInstance();
+            break;
+
+            case 'Relist Instance':
+                return $this->relistInstance();
+            break;
+        }
+    }
+
+    protected function recalculateStats()
+    {
+        $instanceCount = Instance::count();
+        $confirmed = confirm('Do you want to recalculate stats for all ' . $instanceCount . ' instances?');
+        if(!$confirmed) {
+            $this->error('Aborting...');
+            exit;
+        }
+
+        $users = progress(
+            label: 'Updating instance stats...',
+            steps: Instance::all(),
+            callback: fn ($instance) => $this->updateInstanceStats($instance),
+        );
+    }
+
+    protected function updateInstanceStats($instance)
+    {
+        FetchNodeinfoPipeline::dispatch($instance)->onQueue('intbg');
+    }
+
+    protected function unlistInstance()
+    {
+        $id = search(
+            'Search by domain',
+            fn (string $value) => strlen($value) > 0
+                ? Instance::whereUnlisted(false)->where('domain', 'like', "%{$value}%")->pluck('domain', 'id')->all()
+                : []
+        );
+
+        $instance = Instance::find($id);
+        if(!$instance) {
+            $this->error('Oops, an error occured');
+            exit;
+        }
+
+        $tbl = [
+            [
+                $instance->domain,
+                number_format($instance->status_count),
+                number_format($instance->user_count),
+            ]
+        ];
+        table(
+            ['Domain', 'Status Count', 'User Count'],
+            $tbl
+        );
+
+        $confirmed = confirm('Are you sure you want to unlist this instance?');
+        if(!$confirmed) {
+            $this->error('Aborting instance unlisting');
+            exit;
+        }
+
+        $instance->unlisted = true;
+        $instance->save();
+        InstanceService::refresh();
+        $this->info('Successfully unlisted ' . $instance->domain . '!');
+        exit;
+    }
+
+    protected function relistInstance()
+    {
+        $id = search(
+            'Search by domain',
+            fn (string $value) => strlen($value) > 0
+                ? Instance::whereUnlisted(true)->where('domain', 'like', "%{$value}%")->pluck('domain', 'id')->all()
+                : []
+        );
+
+        $instance = Instance::find($id);
+        if(!$instance) {
+            $this->error('Oops, an error occured');
+            exit;
+        }
+
+        $tbl = [
+            [
+                $instance->domain,
+                number_format($instance->status_count),
+                number_format($instance->user_count),
+            ]
+        ];
+        table(
+            ['Domain', 'Status Count', 'User Count'],
+            $tbl
+        );
+
+        $confirmed = confirm('Are you sure you want to re-list this instance?');
+        if(!$confirmed) {
+            $this->error('Aborting instance re-listing');
+            exit;
+        }
+
+        $instance->unlisted = false;
+        $instance->save();
+        InstanceService::refresh();
+        $this->info('Successfully re-listed ' . $instance->domain . '!');
+        exit;
+    }
+
+    protected function banInstance()
+    {
+        $id = search(
+            'Search by domain',
+            fn (string $value) => strlen($value) > 0
+                ? Instance::whereBanned(false)->where('domain', 'like', "%{$value}%")->pluck('domain', 'id')->all()
+                : []
+        );
+
+        $instance = Instance::find($id);
+        if(!$instance) {
+            $this->error('Oops, an error occured');
+            exit;
+        }
+
+        $tbl = [
+            [
+                $instance->domain,
+                number_format($instance->status_count),
+                number_format($instance->user_count),
+            ]
+        ];
+        table(
+            ['Domain', 'Status Count', 'User Count'],
+            $tbl
+        );
+
+        $confirmed = confirm('Are you sure you want to ban this instance?');
+        if(!$confirmed) {
+            $this->error('Aborting instance ban');
+            exit;
+        }
+
+        $instance->banned = true;
+        $instance->save();
+        InstanceService::refresh();
+        $this->info('Successfully banned ' . $instance->domain . '!');
+        exit;
+    }
+
+    protected function unbanInstance()
+    {
+        $id = search(
+            'Search by domain',
+            fn (string $value) => strlen($value) > 0
+                ? Instance::whereBanned(true)->where('domain', 'like', "%{$value}%")->pluck('domain', 'id')->all()
+                : []
+        );
+
+        $instance = Instance::find($id);
+        if(!$instance) {
+            $this->error('Oops, an error occured');
+            exit;
+        }
+
+        $tbl = [
+            [
+                $instance->domain,
+                number_format($instance->status_count),
+                number_format($instance->user_count),
+            ]
+        ];
+        table(
+            ['Domain', 'Status Count', 'User Count'],
+            $tbl
+        );
+
+        $confirmed = confirm('Are you sure you want to unban this instance?');
+        if(!$confirmed) {
+            $this->error('Aborting instance unban');
+            exit;
+        }
+
+        $instance->banned = false;
+        $instance->save();
+        InstanceService::refresh();
+        $this->info('Successfully un-banned ' . $instance->domain . '!');
+        exit;
+    }
+
+    protected function viewBannedInstances()
+    {
+        $data = Instance::whereBanned(true)
+            ->get(['domain', 'user_count', 'status_count'])
+            ->map(function($d) {
+                return [
+                    'domain' => $d->domain,
+                    'user_count' => number_format($d->user_count),
+                    'status_count' => number_format($d->status_count),
+                ];
+            })
+            ->toArray();
+        table(
+            ['Domain', 'User Count', 'Status Count'],
+            $data
+        );
+    }
+
+    protected function viewUnlistedInstances()
+    {
+        $data = Instance::whereUnlisted(true)
+            ->get(['domain', 'user_count', 'status_count', 'banned'])
+            ->map(function($d) {
+                return [
+                    'domain' => $d->domain,
+                    'user_count' => number_format($d->user_count),
+                    'status_count' => number_format($d->status_count),
+                    'banned' => $d->banned ? '✅' : null
+                ];
+            })
+            ->toArray();
+        table(
+            ['Domain', 'User Count', 'Status Count', 'Banned'],
+            $data
+        );
+    }
+}

+ 79 - 0
app/Console/Commands/InstanceUpdateTotalLocalPosts.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ConfigCacheService;
+use Cache;
+use DB;
+use Illuminate\Console\Command;
+use Storage;
+
+class InstanceUpdateTotalLocalPosts extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:instance-update-total-local-posts';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Update the total number of local statuses/post count';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $cached = $this->checkForCache();
+        if (! $cached) {
+            $this->initCache();
+
+            return;
+        }
+        $cache = $this->getCached();
+        if (! $cache || ! isset($cache['count'])) {
+            $this->error('Problem fetching cache');
+
+            return;
+        }
+        $this->updateAndCache();
+        Cache::forget('api:nodeinfo');
+
+    }
+
+    protected function checkForCache()
+    {
+        return Storage::exists('total_local_posts.json');
+    }
+
+    protected function initCache()
+    {
+        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
+        $res = [
+            'count' => $count,
+        ];
+        Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
+        ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
+    }
+
+    protected function getCached()
+    {
+        return Storage::json('total_local_posts.json');
+    }
+
+    protected function updateAndCache()
+    {
+        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
+        $res = [
+            'count' => $count,
+        ];
+        Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
+        ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
+
+    }
+}

+ 140 - 0
app/Console/Commands/MediaCloudUrlRewrite.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Media;
+use Cache, Storage;
+use Illuminate\Contracts\Console\PromptsForMissingInput;
+
+class MediaCloudUrlRewrite extends Command implements PromptsForMissingInput
+{
+    /**
+    * The name and signature of the console command.
+    *
+    * @var string
+    */
+    protected $signature = 'media:cloud-url-rewrite {oldDomain} {newDomain}';
+
+    /**
+    * Prompt for missing input arguments using the returned questions.
+    *
+    * @return array
+    */
+    protected function promptForMissingArgumentsUsing()
+    {
+        return [
+            'oldDomain' => 'The old S3 domain',
+            'newDomain' => 'The new S3 domain'
+        ];
+    }
+    /**
+    * The console command description.
+    *
+    * @var string
+    */
+    protected $description = 'Rewrite S3 media urls from local users';
+
+    /**
+    * Execute the console command.
+    */
+    public function handle()
+    {
+        $this->preflightCheck();
+        $this->bootMessage();
+        $this->confirmCloudUrl();
+    }
+
+    protected function preflightCheck()
+    {
+        if(!(bool) config_cache('pixelfed.cloud_storage')) {
+            $this->info('Error: Cloud storage is not enabled!');
+            $this->error('Aborting...');
+            exit;
+        }
+    }
+
+    protected function bootMessage()
+    {
+        $this->info('       ____  _           ______         __  ');
+        $this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
+        $this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
+        $this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
+        $this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
+        $this->info(' ');
+        $this->info('    Media Cloud Url Rewrite Tool');
+        $this->info('    ===');
+        $this->info('    Old S3: ' . trim($this->argument('oldDomain')));
+        $this->info('    New S3: ' . trim($this->argument('newDomain')));
+        $this->info(' ');
+    }
+
+    protected function confirmCloudUrl()
+    {
+        $disk = Storage::disk(config('filesystems.cloud'))->url('test');
+        $domain = parse_url($disk, PHP_URL_HOST);
+        if(trim($this->argument('newDomain')) !== $domain) {
+            $this->error('Error: The new S3 domain you entered is not currently configured');
+            exit;
+        }
+
+        if(!$this->confirm('Confirm this is correct')) {
+            $this->error('Aborting...');
+            exit;
+        }
+
+        $this->updateUrls();
+    }
+
+    protected function updateUrls()
+    {
+        $this->info('Updating urls...');
+        $oldDomain = trim($this->argument('oldDomain'));
+        $newDomain = trim($this->argument('newDomain'));
+        $disk = Storage::disk(config('filesystems.cloud'));
+        $count = Media::whereNotNull('cdn_url')->count();
+        $bar = $this->output->createProgressBar($count);
+        $counter = 0;
+        $bar->start();
+        foreach(Media::whereNotNull('cdn_url')->lazyById(1000, 'id') as $media) {
+            if(strncmp($media->media_path, 'http', 4) === 0) {
+                $bar->advance();
+                continue;
+            }
+            $cdnHost = parse_url($media->cdn_url, PHP_URL_HOST);
+            if($oldDomain != $cdnHost || $newDomain == $cdnHost) {
+                $bar->advance();
+                continue;
+            }
+
+            $media->cdn_url = str_replace($oldDomain, $newDomain, $media->cdn_url);
+
+            if($media->thumbnail_url != null) {
+                $thumbHost = parse_url($media->thumbnail_url, PHP_URL_HOST);
+                if($thumbHost == $oldDomain) {
+                    $thumbUrl = $disk->url($media->thumbnail_path);
+                    $media->thumbnail_url = $thumbUrl;
+                }
+            }
+
+            if($media->optimized_url != null) {
+                $optiHost = parse_url($media->optimized_url, PHP_URL_HOST);
+                if($optiHost == $oldDomain) {
+                    $optiUrl = str_replace($oldDomain, $newDomain, $media->optimized_url);
+                    $media->optimized_url = $optiUrl;
+                }
+            }
+
+            $media->save();
+            $counter++;
+            $bar->advance();
+        }
+
+        $bar->finish();
+
+        $this->line(' ');
+        $this->info('Finished! Updated ' . $counter . ' total records!');
+        $this->line(' ');
+        $this->info('Tip: Run `php artisan cache:clear` to purge cached urls');
+    }
+}

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

@@ -45,7 +45,7 @@ class MediaS3GarbageCollector extends Command
     */
     public function handle()
     {
-        $enabled = in_array(config_cache('pixelfed.cloud_storage'), ['1', true, 'true']);
+        $enabled = (bool) config_cache('pixelfed.cloud_storage');
         if(!$enabled) {
             $this->error('Cloud storage not enabled. Exiting...');
             return;

+ 31 - 0
app/Console/Commands/NotificationEpochUpdate.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Jobs\InternalPipeline\NotificationEpochUpdatePipeline;
+
+class NotificationEpochUpdate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:notification-epoch-update';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Update notification epoch';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        NotificationEpochUpdatePipeline::dispatch();
+    }
+}

+ 74 - 0
app/Console/Commands/PushGatewayRefresh.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\NotificationAppGatewayService;
+use App\Services\PushNotificationService;
+use Illuminate\Console\Command;
+
+use function Laravel\Prompts\select;
+
+class PushGatewayRefresh extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:push-gateway-refresh';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Refresh push notification gateway support';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $this->info('Checking Push Notification support...');
+        $this->line(' ');
+
+        $currentState = NotificationAppGatewayService::enabled();
+
+        if ($currentState) {
+            $this->info('Push Notification support is active!');
+
+            return;
+        } else {
+            $this->error('Push notification support is NOT active');
+
+            $action = select(
+                label: 'Do you want to force re-check?',
+                options: ['Yes', 'No'],
+                required: true
+            );
+
+            if ($action === 'Yes') {
+                $recheck = NotificationAppGatewayService::forceSupportRecheck();
+                if ($recheck) {
+                    $this->info('Success! Push Notifications are now active!');
+                    PushNotificationService::warmList('like');
+
+                    return;
+                } else {
+                    $this->error('Error, please ensure you have a valid API key.');
+                    $this->line(' ');
+                    $this->line('For more info, visit https://docs.pixelfed.org/running-pixelfed/push-notifications.html');
+                    $this->line(' ');
+
+                    return;
+                }
+
+                return;
+            } else {
+                exit;
+            }
+
+            return;
+        }
+    }
+}

+ 37 - 0
app/Console/Commands/SoftwareUpdateRefresh.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\Internal\SoftwareUpdateService;
+use Cache;
+
+class SoftwareUpdateRefresh extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:software-update-refresh';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Refresh latest software version data';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $key = SoftwareUpdateService::cacheKey();
+        Cache::forget($key);
+        Cache::remember($key, 1209600, function() {
+            return SoftwareUpdateService::fetchLatest();
+        });
+        $this->info('Succesfully updated software versions!');
+    }
+}

+ 31 - 22
app/Console/Commands/TransformImports.php

@@ -2,17 +2,16 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
-use App\Models\ImportPost;
-use App\Services\ImportService;
 use App\Media;
+use App\Models\ImportPost;
 use App\Profile;
-use App\Status;
-use Storage;
 use App\Services\AccountService;
+use App\Services\ImportService;
 use App\Services\MediaPathService;
+use App\Status;
+use Illuminate\Console\Command;
 use Illuminate\Support\Str;
-use App\Util\Lexer\Autolink;
+use Storage;
 
 class TransformImports extends Command
 {
@@ -35,23 +34,24 @@ class TransformImports extends Command
      */
     public function handle()
     {
-        if(!config('import.instagram.enabled')) {
+        if (! config('import.instagram.enabled')) {
             return;
         }
 
         $ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(500)->get();
 
-        if(!$ips->count()) {
+        if (! $ips->count()) {
             return;
         }
 
-        foreach($ips as $ip) {
+        foreach ($ips as $ip) {
             $id = $ip->user_id;
             $pid = $ip->profile_id;
             $profile = Profile::find($pid);
-            if(!$profile) {
+            if (! $profile) {
                 $ip->skip_missing_media = true;
                 $ip->save();
+
                 continue;
             }
 
@@ -63,34 +63,43 @@ class TransformImports extends Command
                 ->where('creation_day', $ip->creation_day)
                 ->exists();
 
-            if($exists == true) {
+            if ($exists == true) {
                 $ip->skip_missing_media = true;
                 $ip->save();
+
                 continue;
             }
 
             $idk = ImportService::getId($ip->user_id, $ip->creation_year, $ip->creation_month, $ip->creation_day);
+            if (! $idk) {
+                $ip->skip_missing_media = true;
+                $ip->save();
+
+                continue;
+            }
 
-            if(Storage::exists('imports/' . $id . '/' . $ip->filename) === false) {
+            if (Storage::exists('imports/'.$id.'/'.$ip->filename) === false) {
                 ImportService::clearAttempts($profile->id);
                 ImportService::getPostCount($profile->id, true);
                 $ip->skip_missing_media = true;
                 $ip->save();
+
                 continue;
             }
 
             $missingMedia = false;
-            foreach($ip->media as $ipm) {
+            foreach ($ip->media as $ipm) {
                 $fileName = last(explode('/', $ipm['uri']));
-                $og = 'imports/' . $id . '/' . $fileName;
-                if(!Storage::exists($og)) {
+                $og = 'imports/'.$id.'/'.$fileName;
+                if (! Storage::exists($og)) {
                     $missingMedia = true;
                 }
             }
 
-            if($missingMedia === true) {
+            if ($missingMedia === true) {
                 $ip->skip_missing_media = true;
                 $ip->save();
+
                 continue;
             }
 
@@ -98,7 +107,6 @@ class TransformImports extends Command
             $status = new Status;
             $status->profile_id = $pid;
             $status->caption = $caption;
-            $status->rendered = strlen(trim($caption)) ? Autolink::create()->autolink($ip->caption) : null;
             $status->type = $ip->post_type;
 
             $status->scope = 'unlisted';
@@ -107,20 +115,21 @@ class TransformImports extends Command
             $status->created_at = now()->parse($ip->creation_date);
             $status->save();
 
-            foreach($ip->media as $ipm) {
+            foreach ($ip->media as $ipm) {
                 $fileName = last(explode('/', $ipm['uri']));
                 $ext = last(explode('.', $fileName));
                 $basePath = MediaPathService::get($profile);
-                $og = 'imports/' . $id . '/' . $fileName;
-                if(!Storage::exists($og)) {
+                $og = 'imports/'.$id.'/'.$fileName;
+                if (! Storage::exists($og)) {
                     $ip->skip_missing_media = true;
                     $ip->save();
+
                     continue;
                 }
                 $size = Storage::size($og);
                 $mime = Storage::mimeType($og);
-                $newFile = Str::random(40) . '.' . $ext;
-                $np = $basePath . '/' . $newFile;
+                $newFile = Str::random(40).'.'.$ext;
+                $np = $basePath.'/'.$newFile;
                 Storage::move($og, $np);
                 $media = new Media;
                 $media->profile_id = $pid;

+ 123 - 0
app/Console/Commands/UserAccountDelete.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Instance;
+use App\Profile;
+use App\Transformer\ActivityPub\Verb\DeleteActor;
+use App\User;
+use App\Util\ActivityPub\HttpSignature;
+use GuzzleHttp\Client;
+use GuzzleHttp\Pool;
+use Illuminate\Console\Command;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\search;
+use function Laravel\Prompts\table;
+
+class UserAccountDelete extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:user-account-delete';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Federate Account Deletion';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $id = search(
+            label: 'Search for the account to delete by username',
+            placeholder: 'john.appleseed',
+            options: fn (string $value) => strlen($value) > 0
+                ? User::withTrashed()->whereStatus('deleted')->where('username', 'like', "%{$value}%")->pluck('username', 'id')->all()
+                : [],
+        );
+
+        $user = User::withTrashed()->find($id);
+
+        table(
+            ['Username', 'Name', 'Email', 'Created'],
+            [[$user->username, $user->name, $user->email, $user->created_at]]
+        );
+
+        $confirmed = confirm(
+            label: 'Do you want to federate this account deletion?',
+            default: false,
+            yes: 'Proceed',
+            no: 'Cancel',
+            hint: 'This action is irreversible'
+        );
+
+        if (! $confirmed) {
+            $this->error('Aborting...');
+            exit;
+        }
+
+        $profile = Profile::withTrashed()->find($user->profile_id);
+
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Item($profile, new DeleteActor());
+        $activity = $fractal->createData($resource)->toArray();
+
+        $audience = Instance::whereNotNull(['shared_inbox', 'nodeinfo_last_fetched'])
+            ->where('nodeinfo_last_fetched', '>', now()->subDays(14))
+            ->distinct()
+            ->pluck('shared_inbox');
+
+        $payload = json_encode($activity);
+
+        $client = new Client([
+            'timeout' => 5,
+        ]);
+
+        $version = config('pixelfed.version');
+        $appUrl = config('app.url');
+        $userAgent = "(Pixelfed/{$version}; +{$appUrl})";
+
+        $requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
+            foreach ($audience as $url) {
+                $headers = HttpSignature::sign($profile, $url, $activity, [
+                    'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+                    'User-Agent' => $userAgent,
+                ]);
+                yield function () use ($client, $url, $headers, $payload) {
+                    return $client->postAsync($url, [
+                        'curl' => [
+                            CURLOPT_HTTPHEADER => $headers,
+                            CURLOPT_POSTFIELDS => $payload,
+                            CURLOPT_HEADER => true,
+                            CURLOPT_SSL_VERIFYPEER => false,
+                            CURLOPT_SSL_VERIFYHOST => false,
+                        ],
+                    ]);
+                };
+            }
+        };
+
+        $pool = new Pool($client, $requests($audience), [
+            'concurrency' => 50,
+            'fulfilled' => function ($response, $index) {
+            },
+            'rejected' => function ($reason, $index) {
+            },
+        ]);
+
+        $promise = $pool->promise();
+
+        $promise->wait();
+    }
+}

+ 9 - 2
app/Console/Commands/UserVerifyEmail.php

@@ -5,8 +5,9 @@ namespace App\Console\Commands;
 use Illuminate\Console\Command;
 use Illuminate\Support\Str;
 use App\User;
+use Illuminate\Contracts\Console\PromptsForMissingInput;
 
-class UserVerifyEmail extends Command
+class UserVerifyEmail extends Command implements PromptsForMissingInput
 {
     /**
      * The name and signature of the console command.
@@ -39,13 +40,19 @@ class UserVerifyEmail extends Command
      */
     public function handle()
     {
-        $user = User::whereUsername($this->argument('username'))->first();
+        $username = $this->argument('username');
+        $user = User::whereUsername($username)->first();
 
         if(!$user) {
             $this->error('Username not found');
             return;
         }
 
+        if($user->email_verified_at) {
+            $this->error('Email already verified ' . $user->email_verified_at->diffForHumans());
+            return;
+        }
+
         $user->email_verified_at = now();
         $user->save();
         $this->info('Successfully verified email address for ' . $user->username);

+ 47 - 0
app/Console/Commands/WeeklyInstanceScan.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Instance;
+use App\Jobs\InstancePipeline\FetchNodeinfoPipeline;
+use Illuminate\Console\Command;
+
+use function Laravel\Prompts\progress;
+
+class WeeklyInstanceScan extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:weekly-instance-scan';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Scan instance nodeinfo';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        if ((bool) config_cache('federation.activitypub.enabled') == false) {
+            return;
+        }
+
+        $users = progress(
+            label: 'Updating instance stats...',
+            steps: Instance::all(),
+            callback: fn ($instance) => $this->updateInstanceStats($instance),
+        );
+    }
+
+    protected function updateInstanceStats($instance)
+    {
+        FetchNodeinfoPipeline::dispatch($instance)->onQueue('intbg');
+    }
+}

+ 24 - 15
app/Console/Kernel.php

@@ -19,30 +19,39 @@ class Kernel extends ConsoleKernel
     /**
      * Define the application's command schedule.
      *
-     * @param \Illuminate\Console\Scheduling\Schedule $schedule
      *
      * @return void
      */
     protected function schedule(Schedule $schedule)
     {
-        $schedule->command('media:optimize')->hourlyAt(40);
-        $schedule->command('media:gc')->hourlyAt(5);
-        $schedule->command('horizon:snapshot')->everyFiveMinutes();
-        $schedule->command('story:gc')->everyFiveMinutes();
-        $schedule->command('gc:failedjobs')->dailyAt(3);
-        $schedule->command('gc:passwordreset')->dailyAt('09:41');
-        $schedule->command('gc:sessions')->twiceDaily(13, 23);
-
-        if(in_array(config_cache('pixelfed.cloud_storage'), ['1', true, 'true']) && config('media.delete_local_after_cloud')) {
+        $schedule->command('media:optimize')->hourlyAt(40)->onOneServer();
+        $schedule->command('media:gc')->hourlyAt(5)->onOneServer();
+        $schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer();
+        $schedule->command('story:gc')->everyFiveMinutes()->onOneServer();
+        $schedule->command('gc:failedjobs')->dailyAt(3)->onOneServer();
+        $schedule->command('gc:passwordreset')->dailyAt('09:41')->onOneServer();
+        $schedule->command('gc:sessions')->twiceDaily(13, 23)->onOneServer();
+        $schedule->command('app:weekly-instance-scan')->weeklyOn(2, '4:20')->onOneServer();
+
+        if ((bool) config_cache('pixelfed.cloud_storage') && (bool) config_cache('media.delete_local_after_cloud')) {
             $schedule->command('media:s3gc')->hourlyAt(15);
         }
 
-        if(config('import.instagram.enabled')) {
-            $schedule->command('app:transform-imports')->everyFourMinutes();
-            $schedule->command('app:import-upload-garbage-collection')->hourlyAt(51);
-            $schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37);
-            $schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32);
+        if (config('import.instagram.enabled')) {
+            $schedule->command('app:transform-imports')->everyTenMinutes()->onOneServer();
+            $schedule->command('app:import-upload-garbage-collection')->hourlyAt(51)->onOneServer();
+            $schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37)->onOneServer();
+            $schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32)->onOneServer();
+
+            if (config('import.instagram.storage.cloud.enabled') && (bool) config_cache('pixelfed.cloud_storage')) {
+                $schedule->command('app:import-upload-media-to-cloud-storage')->hourlyAt(39)->onOneServer();
+            }
         }
+
+        $schedule->command('app:notification-epoch-update')->weeklyOn(1, '2:21')->onOneServer();
+        $schedule->command('app:hashtag-cached-count-update')->hourlyAt(25)->onOneServer();
+        $schedule->command('app:account-post-count-stat-update')->everySixHours(25)->onOneServer();
+        $schedule->command('app:instance-update-total-local-posts')->twiceDailyAt(1, 13, 45)->onOneServer();
     }
 
     /**

+ 17 - 2
app/Contact.php

@@ -3,16 +3,31 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Str;
 
 class Contact extends Model
 {
+    protected $casts = [
+        'responded_at' => 'datetime',
+    ];
+
     public function user()
     {
-    	return $this->belongsTo(User::class);
+        return $this->belongsTo(User::class);
     }
 
     public function adminUrl()
     {
-    	return url('/i/admin/messages/show/' . $this->id);
+        return url('/i/admin/messages/show/'.$this->id);
+    }
+
+    public function userResponseUrl()
+    {
+        return url('/i/contact-admin-response/'.$this->id);
+    }
+
+    public function getMessageId()
+    {
+        return $this->id.'-'.(string) Str::uuid().'@'.strtolower(config('pixelfed.domain.app', 'example.org'));
     }
 }

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

@@ -157,7 +157,7 @@ class AccountController extends Controller
 
 		$pid = $request->user()->profile_id;
 		$count = UserFilterService::muteCount($pid);
-		$maxLimit = intval(config('instance.user_filters.max_user_mutes'));
+		$maxLimit = (int) config_cache('instance.user_filters.max_user_mutes');
 		abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
 		if($count == 0) {
 			$filterCount = UserFilter::whereUserId($pid)->count();
@@ -260,7 +260,7 @@ class AccountController extends Controller
 		]);
 		$pid = $request->user()->profile_id;
 		$count = UserFilterService::blockCount($pid);
-		$maxLimit = intval(config('instance.user_filters.max_user_blocks'));
+		$maxLimit = (int) config_cache('instance.user_filters.max_user_blocks');
 		abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
 		if($count == 0) {
 			$filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count();

+ 104 - 101
app/Http/Controllers/Admin/AdminDirectoryController.php

@@ -2,30 +2,20 @@
 
 namespace App\Http\Controllers\Admin;
 
-use DB, Cache;
-use App\{
-    DiscoverCategory,
-    DiscoverCategoryHashtag,
-    Hashtag,
-    Media,
-    Profile,
-    Status,
-    StatusHashtag,
-    User
-};
+use App\Http\Controllers\PixelfedDirectoryController;
 use App\Models\ConfigCache;
 use App\Services\AccountService;
 use App\Services\ConfigCacheService;
 use App\Services\StatusService;
-use Carbon\Carbon;
+use App\Status;
+use App\User;
+use Cache;
 use Illuminate\Http\Request;
-use Illuminate\Validation\Rule;
-use League\ISO3166\ISO3166;
-use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Validator;
-use Illuminate\Support\Facades\Http;
-use App\Http\Controllers\PixelfedDirectoryController;
+use Illuminate\Support\Str;
+use League\ISO3166\ISO3166;
 
 trait AdminDirectoryController
 {
@@ -41,64 +31,67 @@ trait AdminDirectoryController
         $res['countries'] = collect((new ISO3166)->all())->pluck('name');
         $res['admins'] = User::whereIsAdmin(true)
             ->where('2fa_enabled', true)
-            ->get()->map(function($user) {
-            return [
-                'uid' => (string) $user->id,
-                'pid' => (string) $user->profile_id,
-                'username' => $user->username,
-                'created_at' => $user->created_at
-            ];
-        });
+            ->get()->map(function ($user) {
+                return [
+                    'uid' => (string) $user->id,
+                    'pid' => (string) $user->profile_id,
+                    'username' => $user->username,
+                    'created_at' => $user->created_at,
+                ];
+            });
         $config = ConfigCache::whereK('pixelfed.directory')->first();
-        if($config) {
+        if ($config) {
             $data = $config->v ? json_decode($config->v, true) : [];
             $res = array_merge($res, $data);
         }
 
-        if(empty($res['summary'])) {
+        if (empty($res['summary'])) {
             $summary = ConfigCache::whereK('app.short_description')->pluck('v');
             $res['summary'] = $summary ? $summary[0] : null;
         }
 
-        if(isset($res['banner_image']) && !empty($res['banner_image'])) {
+        if (isset($res['banner_image']) && ! empty($res['banner_image'])) {
             $res['banner_image'] = url(Storage::url($res['banner_image']));
         }
 
-        if(isset($res['favourite_posts'])) {
-            $res['favourite_posts'] = collect($res['favourite_posts'])->map(function($id) {
+        if (isset($res['favourite_posts'])) {
+            $res['favourite_posts'] = collect($res['favourite_posts'])->map(function ($id) {
                 return StatusService::get($id);
             })
-            ->filter(function($post) {
-                return $post && isset($post['account']);
-            })
-            ->values();
+                ->filter(function ($post) {
+                    return $post && isset($post['account']);
+                })
+                ->values();
         }
 
         $res['community_guidelines'] = config_cache('app.rules') ? json_decode(config_cache('app.rules'), true) : [];
+        $res['curated_onboarding'] = (bool) config_cache('instance.curated_registration.enabled');
         $res['open_registration'] = (bool) config_cache('pixelfed.open_registration');
-        $res['oauth_enabled'] = (bool) config_cache('pixelfed.oauth_enabled') && file_exists(storage_path('oauth-public.key')) && file_exists(storage_path('oauth-private.key'));
+        $res['oauth_enabled'] = (bool) config_cache('pixelfed.oauth_enabled') &&
+            (file_exists(storage_path('oauth-public.key')) || config_cache('passport.public_key')) &&
+            (file_exists(storage_path('oauth-private.key')) || config_cache('passport.private_key'));
 
         $res['activitypub_enabled'] = (bool) config_cache('federation.activitypub.enabled');
 
         $res['feature_config'] = [
             'media_types' => Str::of(config_cache('pixelfed.media_types'))->explode(','),
             'image_quality' => config_cache('pixelfed.image_quality'),
-            'optimize_image' => config_cache('pixelfed.optimize_image'),
+            'optimize_image' => (bool) config_cache('pixelfed.optimize_image'),
             'max_photo_size' => config_cache('pixelfed.max_photo_size'),
             'max_caption_length' => config_cache('pixelfed.max_caption_length'),
             'max_altext_length' => config_cache('pixelfed.max_altext_length'),
-            'enforce_account_limit' => config_cache('pixelfed.enforce_account_limit'),
+            'enforce_account_limit' => (bool) config_cache('pixelfed.enforce_account_limit'),
             'max_account_size' => config_cache('pixelfed.max_account_size'),
             'max_album_length' => config_cache('pixelfed.max_album_length'),
-            'account_deletion' => config_cache('pixelfed.account_deletion'),
+            'account_deletion' => (bool) config_cache('pixelfed.account_deletion'),
         ];
 
-        if(config_cache('pixelfed.directory.testimonials')) {
-            $testimonials = collect(json_decode(config_cache('pixelfed.directory.testimonials'),true))
-                ->map(function($t) {
+        if (config_cache('pixelfed.directory.testimonials')) {
+            $testimonials = collect(json_decode(config_cache('pixelfed.directory.testimonials'), true))
+                ->map(function ($t) {
                     return [
                         'profile' => AccountService::get($t['profile_id']),
-                        'body' => $t['body']
+                        'body' => $t['body'],
                     ];
                 });
             $res['testimonials'] = $testimonials;
@@ -107,8 +100,8 @@ trait AdminDirectoryController
         $validator = Validator::make($res['feature_config'], [
             'media_types' => [
                 'required',
-                 function ($attribute, $value, $fail) {
-                    if (!in_array('image/jpeg', $value->toArray()) || !in_array('image/png', $value->toArray())) {
+                function ($attribute, $value, $fail) {
+                    if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
                         $fail('You must enable image/jpeg and image/png support.');
                     }
                 },
@@ -119,12 +112,12 @@ trait AdminDirectoryController
             'max_account_size' => 'required_if:enforce_account_limit,true|integer|min:1000000',
             'max_album_length' => 'required|integer|min:4|max:20',
             'account_deletion' => 'required|accepted',
-            'max_caption_length' => 'required|integer|min:500|max:10000'
+            'max_caption_length' => 'required|integer|min:500|max:10000',
         ]);
 
         $res['requirements_validator'] = $validator->errors();
 
-        $res['is_eligible'] = $res['open_registration'] &&
+        $res['is_eligible'] = ($res['open_registration'] || $res['curated_onboarding']) &&
             $res['oauth_enabled'] &&
             $res['activitypub_enabled'] &&
             count($res['requirements_validator']) === 0 &&
@@ -145,11 +138,11 @@ trait AdminDirectoryController
         foreach (new \DirectoryIterator($path) as $io) {
             $name = $io->getFilename();
             $skip = ['vendor'];
-            if($io->isDot() || in_array($name, $skip)) {
+            if ($io->isDot() || in_array($name, $skip)) {
                 continue;
             }
 
-            if($io->isDir()) {
+            if ($io->isDir()) {
                 $langs->push(['code' => $name, 'name' => locale_get_display_name($name)]);
             }
         }
@@ -158,25 +151,26 @@ trait AdminDirectoryController
         $res['primary_locale'] = config('app.locale');
 
         $submissionState = Http::withoutVerifying()
-        ->post('https://pixelfed.org/api/v1/directory/check-submission', [
-            'domain' => config('pixelfed.domain.app')
-        ]);
+            ->post('https://pixelfed.org/api/v1/directory/check-submission', [
+                'domain' => config('pixelfed.domain.app'),
+            ]);
 
         $res['submission_state'] = $submissionState->json();
+
         return $res;
     }
 
     protected function validVal($res, $val, $count = false, $minLen = false)
     {
-        if(!isset($res[$val])) {
+        if (! isset($res[$val])) {
             return false;
         }
 
-        if($count) {
+        if ($count) {
             return count($res[$val]) >= $count;
         }
 
-        if($minLen) {
+        if ($minLen) {
             return strlen($res[$val]) >= $minLen;
         }
 
@@ -193,11 +187,11 @@ trait AdminDirectoryController
             'favourite_posts' => 'array|max:12',
             'favourite_posts.*' => 'distinct',
             'privacy_pledge' => 'sometimes',
-            'banner_image' => 'sometimes|mimes:jpg,png|dimensions:width=1920,height:1080|max:5000'
+            'banner_image' => 'sometimes|mimes:jpg,png|dimensions:width=1920,height:1080|max:5000',
         ]);
 
         $config = ConfigCache::firstOrNew([
-            'k' => 'pixelfed.directory'
+            'k' => 'pixelfed.directory',
         ]);
 
         $res = $config->v ? json_decode($config->v, true) : [];
@@ -207,27 +201,28 @@ trait AdminDirectoryController
         $res['contact_email'] = $request->input('contact_email');
         $res['privacy_pledge'] = (bool) $request->input('privacy_pledge');
 
-        if($request->filled('location')) {
+        if ($request->filled('location')) {
             $exists = (new ISO3166)->name($request->location);
-            if($exists) {
+            if ($exists) {
                 $res['location'] = $request->input('location');
             }
         }
 
-        if($request->hasFile('banner_image')) {
+        if ($request->hasFile('banner_image')) {
             collect(Storage::files('public/headers'))
-            ->filter(function($name) {
-                $protected = [
-                    'public/headers/.gitignore',
-                    'public/headers/default.jpg',
-                    'public/headers/missing.png'
-                ];
-                return !in_array($name, $protected);
-            })
-            ->each(function($name) {
-                Storage::delete($name);
-            });
-            $path = $request->file('banner_image')->store('public/headers');
+                ->filter(function ($name) {
+                    $protected = [
+                        'public/headers/.gitignore',
+                        'public/headers/default.jpg',
+                        'public/headers/missing.png',
+                    ];
+
+                    return ! in_array($name, $protected);
+                })
+                ->each(function ($name) {
+                    Storage::delete($name);
+                });
+            $path = $request->file('banner_image')->storePublicly('public/headers');
             $res['banner_image'] = $path;
             ConfigCacheService::put('app.banner_image', url(Storage::url($path)));
 
@@ -239,9 +234,10 @@ trait AdminDirectoryController
 
         ConfigCacheService::put('pixelfed.directory', $config->v);
         $updated = json_decode($config->v, true);
-        if(isset($updated['banner_image'])) {
+        if (isset($updated['banner_image'])) {
             $updated['banner_image'] = url(Storage::url($updated['banner_image']));
         }
+
         return $updated;
     }
 
@@ -249,9 +245,10 @@ trait AdminDirectoryController
     {
         $reqs = [];
         $reqs['feature_config'] = [
-            'open_registration' => config_cache('pixelfed.open_registration'),
+            'open_registration' => (bool) config_cache('pixelfed.open_registration'),
+            'curated_onboarding' => (bool) config_cache('instance.curated_registration.enabled'),
             'activitypub_enabled' => config_cache('federation.activitypub.enabled'),
-            'oauth_enabled' => config_cache('pixelfed.oauth_enabled'),
+            'oauth_enabled' => (bool) config_cache('pixelfed.oauth_enabled'),
             'media_types' => Str::of(config_cache('pixelfed.media_types'))->explode(','),
             'image_quality' => config_cache('pixelfed.image_quality'),
             'optimize_image' => config_cache('pixelfed.optimize_image'),
@@ -265,13 +262,14 @@ trait AdminDirectoryController
         ];
 
         $validator = Validator::make($reqs['feature_config'], [
-            'open_registration' => 'required|accepted',
+            'open_registration' => 'required_unless:curated_onboarding,true',
+            'curated_onboarding' => 'required_unless:open_registration,true',
             'activitypub_enabled' => 'required|accepted',
             'oauth_enabled' => 'required|accepted',
             'media_types' => [
                 'required',
-                 function ($attribute, $value, $fail) {
-                    if (!in_array('image/jpeg', $value->toArray()) || !in_array('image/png', $value->toArray())) {
+                function ($attribute, $value, $fail) {
+                    if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
                         $fail('You must enable image/jpeg and image/png support.');
                     }
                 },
@@ -282,10 +280,10 @@ trait AdminDirectoryController
             'max_account_size' => 'required_if:enforce_account_limit,true|integer|min:1000000',
             'max_album_length' => 'required|integer|min:4|max:20',
             'account_deletion' => 'required|accepted',
-            'max_caption_length' => 'required|integer|min:500|max:10000'
+            'max_caption_length' => 'required|integer|min:500|max:10000',
         ]);
 
-        if(!$validator->validate()) {
+        if (! $validator->validate()) {
             return response()->json($validator->errors(), 422);
         }
 
@@ -294,6 +292,7 @@ trait AdminDirectoryController
 
         $data = (new PixelfedDirectoryController())->buildListing();
         $res = Http::withoutVerifying()->post('https://pixelfed.org/api/v1/directory/submission', $data);
+
         return 200;
     }
 
@@ -301,7 +300,7 @@ trait AdminDirectoryController
     {
         $bannerImage = ConfigCache::whereK('app.banner_image')->first();
         $directory = ConfigCache::whereK('pixelfed.directory')->first();
-        if(!$bannerImage && !$directory || empty($directory->v)) {
+        if (! $bannerImage && ! $directory || empty($directory->v)) {
             return;
         }
         $directoryArr = json_decode($directory->v, true);
@@ -309,12 +308,12 @@ trait AdminDirectoryController
         $protected = [
             'public/headers/.gitignore',
             'public/headers/default.jpg',
-            'public/headers/missing.png'
+            'public/headers/missing.png',
         ];
-        if(!$path || in_array($path, $protected)) {
+        if (! $path || in_array($path, $protected)) {
             return;
         }
-        if(Storage::exists($directoryArr['banner_image'])) {
+        if (Storage::exists($directoryArr['banner_image'])) {
             Storage::delete($directoryArr['banner_image']);
         }
 
@@ -325,12 +324,13 @@ trait AdminDirectoryController
         $bannerImage->save();
         Cache::forget('api:v1:instance-data-response-v1');
         ConfigCacheService::put('pixelfed.directory', $directory);
+
         return $bannerImage->v;
     }
 
     public function directoryGetPopularPosts(Request $request)
     {
-        $ids = Cache::remember('admin:api:popular_posts', 86400, function() {
+        $ids = Cache::remember('admin:api:popular_posts', 86400, function () {
             return Status::whereLocal(true)
                 ->whereScope('public')
                 ->whereType('photo')
@@ -340,21 +340,21 @@ trait AdminDirectoryController
                 ->pluck('id');
         });
 
-        $res = $ids->map(function($id) {
+        $res = $ids->map(function ($id) {
             return StatusService::get($id);
         })
-        ->filter(function($post) {
-            return $post && isset($post['account']);
-        })
-        ->values();
+            ->filter(function ($post) {
+                return $post && isset($post['account']);
+            })
+            ->values();
 
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     }
 
     public function directoryGetAddPostByIdSearch(Request $request)
     {
         $this->validate($request, [
-            'q' => 'required|integer'
+            'q' => 'required|integer',
         ]);
 
         $id = $request->input('q');
@@ -377,11 +377,12 @@ trait AdminDirectoryController
         $profile_id = $request->input('profile_id');
         $testimonials = ConfigCache::whereK('pixelfed.directory.testimonials')->firstOrFail();
         $existing = collect(json_decode($testimonials->v, true))
-            ->filter(function($t) use($profile_id) {
+            ->filter(function ($t) use ($profile_id) {
                 return $t['profile_id'] !== $profile_id;
             })
             ->values();
         ConfigCacheService::put('pixelfed.directory.testimonials', $existing);
+
         return $existing;
     }
 
@@ -389,13 +390,13 @@ trait AdminDirectoryController
     {
         $this->validate($request, [
             'username' => 'required',
-            'body' => 'required|string|min:5|max:500'
+            'body' => 'required|string|min:5|max:500',
         ]);
 
         $user = User::whereUsername($request->input('username'))->whereNull('status')->firstOrFail();
 
         $configCache = ConfigCache::firstOrCreate([
-            'k' => 'pixelfed.directory.testimonials'
+            'k' => 'pixelfed.directory.testimonials',
         ]);
 
         $testimonials = $configCache->v ? collect(json_decode($configCache->v, true)) : collect([]);
@@ -406,7 +407,7 @@ trait AdminDirectoryController
         $testimonials->push([
             'profile_id' => (string) $user->profile_id,
             'username' => $request->input('username'),
-            'body' => $request->input('body')
+            'body' => $request->input('body'),
         ]);
 
         $configCache->v = json_encode($testimonials->toArray());
@@ -414,8 +415,9 @@ trait AdminDirectoryController
         ConfigCacheService::put('pixelfed.directory.testimonials', $configCache->v);
         $res = [
             'profile' => AccountService::get($user->profile_id),
-            'body' => $request->input('body')
+            'body' => $request->input('body'),
         ];
+
         return $res;
     }
 
@@ -423,7 +425,7 @@ trait AdminDirectoryController
     {
         $this->validate($request, [
             'profile_id' => 'required',
-            'body' => 'required|string|min:5|max:500'
+            'body' => 'required|string|min:5|max:500',
         ]);
 
         $profile_id = $request->input('profile_id');
@@ -431,18 +433,19 @@ trait AdminDirectoryController
         $user = User::whereProfileId($profile_id)->firstOrFail();
 
         $configCache = ConfigCache::firstOrCreate([
-            'k' => 'pixelfed.directory.testimonials'
+            'k' => 'pixelfed.directory.testimonials',
         ]);
 
         $testimonials = $configCache->v ? collect(json_decode($configCache->v, true)) : collect([]);
 
-        $updated = $testimonials->map(function($t) use($profile_id, $body) {
-            if($t['profile_id'] == $profile_id) {
+        $updated = $testimonials->map(function ($t) use ($profile_id, $body) {
+            if ($t['profile_id'] == $profile_id) {
                 $t['body'] = $body;
             }
+
             return $t;
         })
-        ->values();
+            ->values();
 
         $configCache->v = json_encode($updated);
         $configCache->save();

+ 49 - 0
app/Http/Controllers/Admin/AdminGroupsController.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupInteraction;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupReport;
+use Cache;
+use Illuminate\Http\Request;
+
+trait AdminGroupsController
+{
+    public function groupsHome(Request $request)
+    {
+        $stats = $this->groupAdminStats();
+
+        return view('admin.groups.home', compact('stats'));
+    }
+
+    protected function groupAdminStats()
+    {
+        return Cache::remember('admin:groups:stats', 3, function () {
+            $res = [
+                'total' => Group::count(),
+                'local' => Group::whereLocal(true)->count(),
+            ];
+
+            $res['remote'] = $res['total'] - $res['local'];
+            $res['categories'] = GroupCategory::count();
+            $res['posts'] = GroupPost::count();
+            $res['members'] = GroupMember::count();
+            $res['interactions'] = GroupInteraction::count();
+            $res['reports'] = GroupReport::count();
+
+            $res['local_30d'] = Cache::remember('admin:groups:stats:local_30d', 43200, function () {
+                return Group::whereLocal(true)->where('created_at', '>', now()->subMonth())->count();
+            });
+
+            $res['remote_30d'] = Cache::remember('admin:groups:stats:remote_30d', 43200, function () {
+                return Group::whereLocal(false)->where('created_at', '>', now()->subMonth())->count();
+            });
+
+            return $res;
+        });
+    }
+}

+ 1578 - 1048
app/Http/Controllers/Admin/AdminReportController.php

@@ -2,461 +2,471 @@
 
 namespace App\Http\Controllers\Admin;
 
-use Cache;
-use Carbon\Carbon;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Redis;
-use App\Services\AccountService;
-use App\Services\StatusService;
-use App\{
-	AccountInterstitial,
-	Contact,
-	Hashtag,
-	Newsroom,
-	Notification,
-	OauthClient,
-	Profile,
-	Report,
-	Status,
-	Story,
-	User
-};
-use Illuminate\Validation\Rule;
-use App\Services\StoryService;
-use App\Services\ModLogService;
+use App\AccountInterstitial;
+use App\Http\Resources\Admin\AdminModeratedProfileResource;
+use App\Http\Resources\AdminRemoteReport;
+use App\Http\Resources\AdminReport;
+use App\Http\Resources\AdminSpamReport;
 use App\Jobs\DeletePipeline\DeleteAccountPipeline;
 use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
-use App\Jobs\StatusPipeline\StatusDelete;
 use App\Jobs\StatusPipeline\RemoteStatusDelete;
-use App\Http\Resources\AdminReport;
-use App\Http\Resources\AdminSpamReport;
+use App\Jobs\StatusPipeline\StatusDelete;
+use App\Jobs\StoryPipeline\StoryDelete;
+use App\Models\ModeratedProfile;
+use App\Models\RemoteReport;
+use App\Notification;
+use App\Profile;
+use App\Report;
+use App\Services\AccountService;
+use App\Services\ModLogService;
+use App\Services\NetworkTimelineService;
 use App\Services\NotificationService;
 use App\Services\PublicTimelineService;
-use App\Services\NetworkTimelineService;
+use App\Services\StatusService;
+use App\Status;
+use App\Story;
+use App\User;
+use App\Util\ActivityPub\Helpers;
+use Cache;
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
+use Storage;
 
 trait AdminReportController
 {
-	public function reports(Request $request)
-	{
-		$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
-		$page = $request->input('page') ?? 1;
-
-		$ai = Cache::remember('admin-dash:reports:ai-count', 3600, function() {
-			return AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count();
-		});
-
-		$spam = Cache::remember('admin-dash:reports:spam-count', 3600, function() {
-			return AccountInterstitial::whereType('post.autospam')->whereNull('appeal_handled_at')->count();
-		});
-
-		$mailVerifications = Redis::scard('email:manual');
-
-		if($filter == 'open' && $page == 1) {
-			$reports = Cache::remember('admin-dash:reports:list-cache', 300, function() use($page, $filter) {
-				return Report::whereHas('status')
-					->whereHas('reportedUser')
-					->whereHas('reporter')
-					->orderBy('created_at','desc')
-					->when($filter, function($q, $filter) {
-						return $filter == 'open' ?
-						$q->whereNull('admin_seen') :
-						$q->whereNotNull('admin_seen');
-					})
-					->paginate(6);
-			});
-		} else {
-			$reports = Report::whereHas('status')
-			->whereHas('reportedUser')
-			->whereHas('reporter')
-			->orderBy('created_at','desc')
-			->when($filter, function($q, $filter) {
-				return $filter == 'open' ?
-				$q->whereNull('admin_seen') :
-				$q->whereNotNull('admin_seen');
-			})
-			->paginate(6);
-		}
-
-		return view('admin.reports.home', compact('reports', 'ai', 'spam', 'mailVerifications'));
-	}
-
-	public function showReport(Request $request, $id)
-	{
-		$report = Report::with('status')->findOrFail($id);
-		if($request->has('ref') && $request->input('ref') == 'email') {
-			return redirect('/i/admin/reports?tab=report&id=' . $report->id);
-		}
-		return view('admin.reports.show', compact('report'));
-	}
-
-	public function appeals(Request $request)
-	{
-		$appeals = AccountInterstitial::whereNotNull('appeal_requested_at')
-			->whereNull('appeal_handled_at')
-			->latest()
-			->paginate(6);
-		return view('admin.reports.appeals', compact('appeals'));
-	}
-
-	public function showAppeal(Request $request, $id)
-	{
-		$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
-			->whereNull('appeal_handled_at')
-			->findOrFail($id);
-		$meta = json_decode($appeal->meta);
-		return view('admin.reports.show_appeal', compact('appeal', 'meta'));
-	}
-
-	public function spam(Request $request)
-	{
-		$this->validate($request, [
-			'tab' => 'sometimes|in:home,not-spam,spam,settings,custom,exemptions'
-		]);
-
-		$tab = $request->input('tab', 'home');
-
-		$openCount = Cache::remember('admin-dash:reports:spam-count', 3600, function() {
-			return AccountInterstitial::whereType('post.autospam')
-				->whereNull('appeal_handled_at')
-				->count();
-		});
-
-		$monthlyCount = Cache::remember('admin-dash:reports:spam-count:30d', 43200, function() {
-			return AccountInterstitial::whereType('post.autospam')
-				->where('created_at', '>', now()->subMonth())
-				->count();
-		});
-
-		$totalCount = Cache::remember('admin-dash:reports:spam-count:total', 43200, function() {
-			return AccountInterstitial::whereType('post.autospam')->count();
-		});
-
-		$uncategorized = Cache::remember('admin-dash:reports:spam-sync', 3600, function() {
-			return AccountInterstitial::whereType('post.autospam')
-				->whereIsSpam(null)
-				->whereNotNull('appeal_handled_at')
-				->exists();
-		});
-
-		$avg = Cache::remember('admin-dash:reports:spam-count:avg', 43200, function() {
-			if(config('database.default') != 'mysql') {
-				return 0;
-			}
-			return AccountInterstitial::selectRaw('*, count(id) as counter')
-				->whereType('post.autospam')
-				->groupBy('user_id')
-				->get()
-				->avg('counter');
-		});
-
-		$avgOpen = Cache::remember('admin-dash:reports:spam-count:avgopen', 43200, function() {
-			if(config('database.default') != 'mysql') {
-				return "0";
-			}
-			$seconds = AccountInterstitial::selectRaw('DATE(created_at) AS start_date, AVG(TIME_TO_SEC(TIMEDIFF(appeal_handled_at, created_at))) AS timediff')->whereType('post.autospam')->whereNotNull('appeal_handled_at')->where('created_at', '>', now()->subMonth())->get();
-			if(!$seconds) {
-				return "0";
-			}
-			$mins = floor($seconds->avg('timediff') / 60);
-
-			if($mins < 60) {
-				return $mins . ' min(s)';
-			}
-
-			if($mins < 2880) {
-				return floor($mins / 60) . ' hour(s)';
-			}
-
-			return floor($mins / 60 / 24) . ' day(s)';
-		});
-		$avgCount = $totalCount && $avg ? floor($totalCount / $avg) : "0";
-
-		if(in_array($tab, ['home', 'spam', 'not-spam'])) {
-			$appeals = AccountInterstitial::whereType('post.autospam')
-				->when($tab, function($q, $tab) {
-					switch($tab) {
-						case 'home':
-							return $q->whereNull('appeal_handled_at');
-						break;
-						case 'spam':
-							return $q->whereIsSpam(true);
-						break;
-						case 'not-spam':
-							return $q->whereIsSpam(false);
-						break;
-					}
-				})
-				->latest()
-				->paginate(6);
-
-			if($tab !== 'home') {
-				$appeals = $appeals->appends(['tab' => $tab]);
-			}
-		} else {
-			$appeals = new class {
-				public function count() {
-					return 0;
-				}
-
-				public function render() {
-					return;
-				}
-			};
-		}
-
-
-		return view('admin.reports.spam', compact('tab', 'appeals', 'openCount', 'monthlyCount', 'totalCount', 'avgCount', 'avgOpen', 'uncategorized'));
-	}
-
-	public function showSpam(Request $request, $id)
-	{
-		$appeal = AccountInterstitial::whereType('post.autospam')
-			->findOrFail($id);
-		if($request->has('ref') && $request->input('ref') == 'email') {
-			return redirect('/i/admin/reports?tab=autospam&id=' . $appeal->id);
-		}
-		$meta = json_decode($appeal->meta);
-		return view('admin.reports.show_spam', compact('appeal', 'meta'));
-	}
-
-	public function fixUncategorizedSpam(Request $request)
-	{
-		if(Cache::get('admin-dash:reports:spam-sync-active')) {
-			return redirect('/i/admin/reports/autospam');
-		}
-
-		Cache::put('admin-dash:reports:spam-sync-active', 1, 900);
-
-		AccountInterstitial::chunk(500, function($reports) {
-			foreach($reports as $report) {
-				if($report->item_type != 'App\Status') {
-					continue;
-				}
-
-				if($report->type != 'post.autospam') {
-					continue;
-				}
-
-				if($report->is_spam != null) {
-					continue;
-				}
-
-				$status = StatusService::get($report->item_id, false);
-				if(!$status) {
-					return;
-				}
-				$scope = $status['visibility'];
-				$report->is_spam = $scope == 'unlisted';
-				$report->in_violation = $report->is_spam;
-				$report->severity_index = 1;
-				$report->save();
-			}
-		});
-
-		Cache::forget('admin-dash:reports:spam-sync');
-		return redirect('/i/admin/reports/autospam');
-	}
-
-	public function updateSpam(Request $request, $id)
-	{
-		$this->validate($request, [
-			'action' => 'required|in:dismiss,approve,dismiss-all,approve-all,delete-account,mark-spammer'
-		]);
-
-		$action = $request->input('action');
-		$appeal = AccountInterstitial::whereType('post.autospam')
-			->whereNull('appeal_handled_at')
-			->findOrFail($id);
-
-		$meta = json_decode($appeal->meta);
-		$res = ['status' => 'success'];
-		$now = now();
-		Cache::forget('admin-dash:reports:spam-count:total');
-		Cache::forget('admin-dash:reports:spam-count:30d');
-
-		if($action == 'delete-account') {
-			if(config('pixelfed.account_deletion') == false) {
-				abort(404);
-			}
-
-			$user = User::findOrFail($appeal->user_id);
-			$profile = $user->profile;
-
-			if($user->is_admin == true) {
-				$mid = $request->user()->id;
-				abort_if($user->id < $mid, 403);
-			}
-
-			$ts = now()->addMonth();
-			$user->status = 'delete';
-			$profile->status = 'delete';
-			$user->delete_after = $ts;
-			$profile->delete_after = $ts;
-			$user->save();
-			$profile->save();
-
-			ModLogService::boot()
-				->objectUid($user->id)
-				->objectId($user->id)
-				->objectType('App\User::class')
-				->user($request->user())
-				->action('admin.user.delete')
-				->accessLevel('admin')
-				->save();
-
-			Cache::forget('profiles:private');
-			DeleteAccountPipeline::dispatch($user);
-			return;
-		}
-
-		if($action == 'dismiss') {
-			$appeal->is_spam = true;
-			$appeal->appeal_handled_at = $now;
-			$appeal->save();
-
-			Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('admin-dash:reports:spam-count');
-			return $res;
-		}
-
-		if($action == 'dismiss-all') {
-			AccountInterstitial::whereType('post.autospam')
-				->whereItemType('App\Status')
-				->whereNull('appeal_handled_at')
-				->whereUserId($appeal->user_id)
-				->update(['appeal_handled_at' => $now, 'is_spam' => true]);
-			Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('admin-dash:reports:spam-count');
-			return $res;
-		}
-
-		if($action == 'approve-all') {
-			AccountInterstitial::whereType('post.autospam')
-				->whereItemType('App\Status')
-				->whereNull('appeal_handled_at')
-				->whereUserId($appeal->user_id)
-				->get()
-				->each(function($report) use($meta) {
-					$report->is_spam = false;
-					$report->appeal_handled_at = now();
-					$report->save();
-					$status = Status::find($report->item_id);
-					if($status) {
-						$status->is_nsfw = $meta->is_nsfw;
-						$status->scope = 'public';
-						$status->visibility = 'public';
-						$status->save();
-						StatusService::del($status->id, true);
-					}
-				});
-			Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('admin-dash:reports:spam-count');
-			return $res;
-		}
-
-		if($action == 'mark-spammer') {
-			AccountInterstitial::whereType('post.autospam')
-				->whereItemType('App\Status')
-				->whereNull('appeal_handled_at')
-				->whereUserId($appeal->user_id)
-				->update(['appeal_handled_at' => $now, 'is_spam' => true]);
-
-			$pro = Profile::whereUserId($appeal->user_id)->firstOrFail();
-
-			$pro->update([
-				'unlisted' => true,
-				'cw' => true,
-				'no_autolink' => true
-			]);
-
-			Status::whereProfileId($pro->id)
-				->get()
-				->each(function($report) {
-					$status->is_nsfw = $meta->is_nsfw;
-					$status->scope = 'public';
-					$status->visibility = 'public';
-					$status->save();
-					StatusService::del($status->id, true);
-				});
-
-			Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
-			Cache::forget('admin-dash:reports:spam-count');
-			return $res;
-		}
-
-		$status = $appeal->status;
-		$status->is_nsfw = $meta->is_nsfw;
-		$status->scope = 'public';
-		$status->visibility = 'public';
-		$status->save();
-
-		$appeal->is_spam = false;
-		$appeal->appeal_handled_at = now();
-		$appeal->save();
-
-		StatusService::del($status->id);
-
-		Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-		Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
-		Cache::forget('admin-dash:reports:spam-count');
-
-		return $res;
-	}
-
-	public function updateAppeal(Request $request, $id)
-	{
-		$this->validate($request, [
-			'action' => 'required|in:dismiss,approve'
-		]);
-
-		$action = $request->input('action');
-		$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
-			->whereNull('appeal_handled_at')
-			->findOrFail($id);
-
-		if($action == 'dismiss') {
-			$appeal->appeal_handled_at = now();
-			$appeal->save();
-			Cache::forget('admin-dash:reports:ai-count');
-			return redirect('/i/admin/reports/appeals');
-		}
-
-		switch ($appeal->type) {
-			case 'post.cw':
-				$status = $appeal->status;
-				$status->is_nsfw = false;
-				$status->save();
-				break;
-
-			case 'post.unlist':
-				$status = $appeal->status;
-				$status->scope = 'public';
-				$status->visibility = 'public';
-				$status->save();
-				break;
-
-			default:
-				# code...
-				break;
-		}
-
-		$appeal->appeal_handled_at = now();
-		$appeal->save();
-		StatusService::del($status->id, true);
-		Cache::forget('admin-dash:reports:ai-count');
-
-		return redirect('/i/admin/reports/appeals');
-	}
+    public function reports(Request $request)
+    {
+        $filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
+        $page = $request->input('page') ?? 1;
+
+        $ai = Cache::remember('admin-dash:reports:ai-count', 3600, function () {
+            return AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count();
+        });
+
+        $spam = Cache::remember('admin-dash:reports:spam-count', 3600, function () {
+            return AccountInterstitial::whereType('post.autospam')->whereNull('appeal_handled_at')->count();
+        });
+
+        $mailVerifications = Redis::scard('email:manual');
+
+        if ($filter == 'open' && $page == 1) {
+            $reports = Cache::remember('admin-dash:reports:list-cache', 300, function () use ($filter) {
+                return Report::whereHas('status')
+                    ->whereHas('reportedUser')
+                    ->whereHas('reporter')
+                    ->orderBy('created_at', 'desc')
+                    ->when($filter, function ($q, $filter) {
+                        return $filter == 'open' ?
+                        $q->whereNull('admin_seen') :
+                        $q->whereNotNull('admin_seen');
+                    })
+                    ->paginate(6);
+            });
+        } else {
+            $reports = Report::whereHas('status')
+                ->whereHas('reportedUser')
+                ->whereHas('reporter')
+                ->orderBy('created_at', 'desc')
+                ->when($filter, function ($q, $filter) {
+                    return $filter == 'open' ?
+                    $q->whereNull('admin_seen') :
+                    $q->whereNotNull('admin_seen');
+                })
+                ->paginate(6);
+        }
+
+        return view('admin.reports.home', compact('reports', 'ai', 'spam', 'mailVerifications'));
+    }
+
+    public function showReport(Request $request, $id)
+    {
+        $report = Report::with('status')->findOrFail($id);
+        if ($request->has('ref') && $request->input('ref') == 'email') {
+            return redirect('/i/admin/reports?tab=report&id='.$report->id);
+        }
+
+        return view('admin.reports.show', compact('report'));
+    }
+
+    public function appeals(Request $request)
+    {
+        $appeals = AccountInterstitial::whereNotNull('appeal_requested_at')
+            ->whereNull('appeal_handled_at')
+            ->latest()
+            ->paginate(6);
+
+        return view('admin.reports.appeals', compact('appeals'));
+    }
+
+    public function showAppeal(Request $request, $id)
+    {
+        $appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
+            ->whereNull('appeal_handled_at')
+            ->findOrFail($id);
+        $meta = json_decode($appeal->meta);
+
+        return view('admin.reports.show_appeal', compact('appeal', 'meta'));
+    }
+
+    public function spam(Request $request)
+    {
+        $this->validate($request, [
+            'tab' => 'sometimes|in:home,not-spam,spam,settings,custom,exemptions',
+        ]);
+
+        $tab = $request->input('tab', 'home');
+
+        $openCount = Cache::remember('admin-dash:reports:spam-count', 3600, function () {
+            return AccountInterstitial::whereType('post.autospam')
+                ->whereNull('appeal_handled_at')
+                ->count();
+        });
+
+        $monthlyCount = Cache::remember('admin-dash:reports:spam-count:30d', 43200, function () {
+            return AccountInterstitial::whereType('post.autospam')
+                ->where('created_at', '>', now()->subMonth())
+                ->count();
+        });
+
+        $totalCount = Cache::remember('admin-dash:reports:spam-count:total', 43200, function () {
+            return AccountInterstitial::whereType('post.autospam')->count();
+        });
+
+        $uncategorized = Cache::remember('admin-dash:reports:spam-sync', 3600, function () {
+            return AccountInterstitial::whereType('post.autospam')
+                ->whereIsSpam(null)
+                ->whereNotNull('appeal_handled_at')
+                ->exists();
+        });
+
+        $avg = Cache::remember('admin-dash:reports:spam-count:avg', 43200, function () {
+            if (config('database.default') != 'mysql') {
+                return 0;
+            }
+
+            return AccountInterstitial::selectRaw('*, count(id) as counter')
+                ->whereType('post.autospam')
+                ->groupBy('user_id')
+                ->get()
+                ->avg('counter');
+        });
+
+        $avgOpen = Cache::remember('admin-dash:reports:spam-count:avgopen', 43200, function () {
+            if (config('database.default') != 'mysql') {
+                return '0';
+            }
+            $seconds = AccountInterstitial::selectRaw('DATE(created_at) AS start_date, AVG(TIME_TO_SEC(TIMEDIFF(appeal_handled_at, created_at))) AS timediff')->whereType('post.autospam')->whereNotNull('appeal_handled_at')->where('created_at', '>', now()->subMonth())->get();
+            if (! $seconds) {
+                return '0';
+            }
+            $mins = floor($seconds->avg('timediff') / 60);
+
+            if ($mins < 60) {
+                return $mins.' min(s)';
+            }
+
+            if ($mins < 2880) {
+                return floor($mins / 60).' hour(s)';
+            }
+
+            return floor($mins / 60 / 24).' day(s)';
+        });
+        $avgCount = $totalCount && $avg ? floor($totalCount / $avg) : '0';
+
+        if (in_array($tab, ['home', 'spam', 'not-spam'])) {
+            $appeals = AccountInterstitial::whereType('post.autospam')
+                ->when($tab, function ($q, $tab) {
+                    switch ($tab) {
+                        case 'home':
+                            return $q->whereNull('appeal_handled_at');
+                            break;
+                        case 'spam':
+                            return $q->whereIsSpam(true);
+                            break;
+                        case 'not-spam':
+                            return $q->whereIsSpam(false);
+                            break;
+                    }
+                })
+                ->latest()
+                ->paginate(6);
+
+            if ($tab !== 'home') {
+                $appeals = $appeals->appends(['tab' => $tab]);
+            }
+        } else {
+            $appeals = new class
+            {
+                public function count()
+                {
+                    return 0;
+                }
+
+                public function render() {}
+            };
+        }
+
+        return view('admin.reports.spam', compact('tab', 'appeals', 'openCount', 'monthlyCount', 'totalCount', 'avgCount', 'avgOpen', 'uncategorized'));
+    }
+
+    public function showSpam(Request $request, $id)
+    {
+        $appeal = AccountInterstitial::whereType('post.autospam')
+            ->findOrFail($id);
+        if ($request->has('ref') && $request->input('ref') == 'email') {
+            return redirect('/i/admin/reports?tab=autospam&id='.$appeal->id);
+        }
+        $meta = json_decode($appeal->meta);
+
+        return view('admin.reports.show_spam', compact('appeal', 'meta'));
+    }
+
+    public function fixUncategorizedSpam(Request $request)
+    {
+        if (Cache::get('admin-dash:reports:spam-sync-active')) {
+            return redirect('/i/admin/reports/autospam');
+        }
+
+        Cache::put('admin-dash:reports:spam-sync-active', 1, 900);
+
+        AccountInterstitial::chunk(500, function ($reports) {
+            foreach ($reports as $report) {
+                if ($report->item_type != 'App\Status') {
+                    continue;
+                }
+
+                if ($report->type != 'post.autospam') {
+                    continue;
+                }
+
+                if ($report->is_spam != null) {
+                    continue;
+                }
+
+                $status = StatusService::get($report->item_id, false);
+                if (! $status) {
+                    return;
+                }
+                $scope = $status['visibility'];
+                $report->is_spam = $scope == 'unlisted';
+                $report->in_violation = $report->is_spam;
+                $report->severity_index = 1;
+                $report->save();
+            }
+        });
+
+        Cache::forget('admin-dash:reports:spam-sync');
+
+        return redirect('/i/admin/reports/autospam');
+    }
+
+    public function updateSpam(Request $request, $id)
+    {
+        $this->validate($request, [
+            'action' => 'required|in:dismiss,approve,dismiss-all,approve-all,delete-account,mark-spammer',
+        ]);
+
+        $action = $request->input('action');
+        $appeal = AccountInterstitial::whereType('post.autospam')
+            ->whereNull('appeal_handled_at')
+            ->findOrFail($id);
+
+        $meta = json_decode($appeal->meta);
+        $res = ['status' => 'success'];
+        $now = now();
+        Cache::forget('admin-dash:reports:spam-count:total');
+        Cache::forget('admin-dash:reports:spam-count:30d');
+
+        if ($action == 'delete-account') {
+            if (config('pixelfed.account_deletion') == false) {
+                abort(404);
+            }
+
+            $user = User::findOrFail($appeal->user_id);
+            $profile = $user->profile;
+
+            if ($user->is_admin == true) {
+                $mid = $request->user()->id;
+                abort_if($user->id < $mid, 403);
+            }
+
+            $ts = now()->addMonth();
+            $user->status = 'delete';
+            $profile->status = 'delete';
+            $user->delete_after = $ts;
+            $profile->delete_after = $ts;
+            $user->save();
+            $profile->save();
+
+            ModLogService::boot()
+                ->objectUid($user->id)
+                ->objectId($user->id)
+                ->objectType('App\User::class')
+                ->user($request->user())
+                ->action('admin.user.delete')
+                ->accessLevel('admin')
+                ->save();
+
+            Cache::forget('profiles:private');
+            DeleteAccountPipeline::dispatch($user);
+
+            return;
+        }
+
+        if ($action == 'dismiss') {
+            $appeal->is_spam = true;
+            $appeal->appeal_handled_at = $now;
+            $appeal->save();
+
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('admin-dash:reports:spam-count');
+
+            return $res;
+        }
+
+        if ($action == 'dismiss-all') {
+            AccountInterstitial::whereType('post.autospam')
+                ->whereItemType('App\Status')
+                ->whereNull('appeal_handled_at')
+                ->whereUserId($appeal->user_id)
+                ->update(['appeal_handled_at' => $now, 'is_spam' => true]);
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('admin-dash:reports:spam-count');
+
+            return $res;
+        }
+
+        if ($action == 'approve-all') {
+            AccountInterstitial::whereType('post.autospam')
+                ->whereItemType('App\Status')
+                ->whereNull('appeal_handled_at')
+                ->whereUserId($appeal->user_id)
+                ->get()
+                ->each(function ($report) use ($meta) {
+                    $report->is_spam = false;
+                    $report->appeal_handled_at = now();
+                    $report->save();
+                    $status = Status::find($report->item_id);
+                    if ($status) {
+                        $status->is_nsfw = $meta->is_nsfw;
+                        $status->scope = 'public';
+                        $status->visibility = 'public';
+                        $status->save();
+                        StatusService::del($status->id, true);
+                    }
+                });
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('admin-dash:reports:spam-count');
+
+            return $res;
+        }
+
+        if ($action == 'mark-spammer') {
+            AccountInterstitial::whereType('post.autospam')
+                ->whereItemType('App\Status')
+                ->whereNull('appeal_handled_at')
+                ->whereUserId($appeal->user_id)
+                ->update(['appeal_handled_at' => $now, 'is_spam' => true]);
+
+            $pro = Profile::whereUserId($appeal->user_id)->firstOrFail();
+
+            $pro->update([
+                'unlisted' => true,
+                'cw' => true,
+                'no_autolink' => true,
+            ]);
+
+            Status::whereProfileId($pro->id)
+                ->get()
+                ->each(function ($report) {
+                    $status->is_nsfw = $meta->is_nsfw;
+                    $status->scope = 'public';
+                    $status->visibility = 'public';
+                    $status->save();
+                    StatusService::del($status->id, true);
+                });
+
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('admin-dash:reports:spam-count');
+
+            return $res;
+        }
+
+        $status = $appeal->status;
+        $status->is_nsfw = $meta->is_nsfw;
+        $status->scope = 'public';
+        $status->visibility = 'public';
+        $status->save();
+
+        $appeal->is_spam = false;
+        $appeal->appeal_handled_at = now();
+        $appeal->save();
+
+        StatusService::del($status->id);
+
+        Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+        Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
+        Cache::forget('admin-dash:reports:spam-count');
+
+        return $res;
+    }
+
+    public function updateAppeal(Request $request, $id)
+    {
+        $this->validate($request, [
+            'action' => 'required|in:dismiss,approve',
+        ]);
+
+        $action = $request->input('action');
+        $appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
+            ->whereNull('appeal_handled_at')
+            ->findOrFail($id);
+
+        if ($action == 'dismiss') {
+            $appeal->appeal_handled_at = now();
+            $appeal->save();
+            Cache::forget('admin-dash:reports:ai-count');
+
+            return redirect('/i/admin/reports/appeals');
+        }
+
+        switch ($appeal->type) {
+            case 'post.cw':
+                $status = $appeal->status;
+                $status->is_nsfw = false;
+                $status->save();
+                break;
+
+            case 'post.unlist':
+                $status = $appeal->status;
+                $status->scope = 'public';
+                $status->visibility = 'public';
+                $status->save();
+                break;
+
+            default:
+                // code...
+                break;
+        }
+
+        $appeal->appeal_handled_at = now();
+        $appeal->save();
+        StatusService::del($status->id, true);
+        Cache::forget('admin-dash:reports:ai-count');
+
+        return redirect('/i/admin/reports/appeals');
+    }
 
     public function updateReport(Request $request, $id)
     {
         $this->validate($request, [
-            'action'	=> 'required|string',
+            'action' => 'required|string',
         ]);
 
         $action = $request->input('action');
@@ -470,7 +480,7 @@ trait AdminReportController
             'ban',
         ];
 
-        if (!in_array($action, $actions)) {
+        if (! in_array($action, $actions)) {
             return abort(403);
         }
 
@@ -479,7 +489,7 @@ trait AdminReportController
         $this->handleReportAction($report, $action);
         Cache::forget('admin-dash:reports:list-cache');
 
-        return response()->json(['msg'=> 'Success']);
+        return response()->json(['msg' => 'Success']);
     }
 
     public function handleReportAction(Report $report, $action)
@@ -541,7 +551,7 @@ trait AdminReportController
             '3' => 'unlist',
             '4' => 'delete',
             '5' => 'shadowban',
-            '6' => 'ban'
+            '6' => 'ban',
         ];
     }
 
@@ -549,675 +559,1195 @@ trait AdminReportController
     {
         $this->validate($request, [
             'action' => 'required|integer|min:1|max:10',
-            'ids'    => 'required|array'
+            'ids' => 'required|array',
         ]);
         $action = $this->actionMap()[$request->input('action')];
         $ids = $request->input('ids');
         $reports = Report::whereIn('id', $ids)->whereNull('admin_seen')->get();
-        foreach($reports as $report) {
+        foreach ($reports as $report) {
             $this->handleReportAction($report, $action);
         }
         $res = [
             'message' => 'Success',
-            'code'    => 200
+            'code' => 200,
         ];
+
         return response()->json($res);
     }
 
     public function reportMailVerifications(Request $request)
     {
-    	$ids = Redis::smembers('email:manual');
-    	$ignored = Redis::smembers('email:manual-ignored');
-    	$reports = [];
-    	if($ids) {
-			$reports = collect($ids)
-				->filter(function($id) use($ignored) {
-					return !in_array($id, $ignored);
-				})
-				->map(function($id) {
-					$user = User::whereProfileId($id)->first();
-					if(!$user || $user->email_verified_at) {
-						return [];
-					}
-					$account = AccountService::get($id, true);
-					if(!$account) {
-						return [];
-					}
-					$account['email'] = $user->email;
-					return $account;
-				})
-				->filter(function($res) {
-					return $res && isset($res['id']);
-				})
-				->values();
-    	}
-    	return view('admin.reports.mail_verification', compact('reports', 'ignored'));
+        $ids = Redis::smembers('email:manual');
+        $ignored = Redis::smembers('email:manual-ignored');
+        $reports = [];
+        if ($ids) {
+            $reports = collect($ids)
+                ->filter(function ($id) use ($ignored) {
+                    return ! in_array($id, $ignored);
+                })
+                ->map(function ($id) {
+                    $user = User::whereProfileId($id)->first();
+                    if (! $user || $user->email_verified_at) {
+                        return [];
+                    }
+                    $account = AccountService::get($id, true);
+                    if (! $account) {
+                        return [];
+                    }
+                    $account['email'] = $user->email;
+
+                    return $account;
+                })
+                ->filter(function ($res) {
+                    return $res && isset($res['id']);
+                })
+                ->values();
+        }
+
+        return view('admin.reports.mail_verification', compact('reports', 'ignored'));
     }
 
     public function reportMailVerifyIgnore(Request $request)
     {
-    	$id = $request->input('id');
-    	Redis::sadd('email:manual-ignored', $id);
-    	return redirect('/i/admin/reports');
+        $id = $request->input('id');
+        Redis::sadd('email:manual-ignored', $id);
+
+        return redirect('/i/admin/reports');
     }
 
     public function reportMailVerifyApprove(Request $request)
     {
-    	$id = $request->input('id');
-    	$user = User::whereProfileId($id)->firstOrFail();
-    	Redis::srem('email:manual', $id);
-    	Redis::srem('email:manual-ignored', $id);
-    	$user->email_verified_at = now();
-    	$user->save();
-    	return redirect('/i/admin/reports');
+        $id = $request->input('id');
+        $user = User::whereProfileId($id)->firstOrFail();
+        Redis::srem('email:manual', $id);
+        Redis::srem('email:manual-ignored', $id);
+        $user->email_verified_at = now();
+        $user->save();
+
+        return redirect('/i/admin/reports');
     }
 
     public function reportMailVerifyClearIgnored(Request $request)
     {
-    	Redis::del('email:manual-ignored');
-    	return [200];
+        Redis::del('email:manual-ignored');
+
+        return [200];
     }
 
     public function reportsStats(Request $request)
     {
-    	$stats = [
-    		'total' => Report::count(),
-    		'open' => Report::whereNull('admin_seen')->count(),
-    		'closed' => Report::whereNotNull('admin_seen')->count(),
-    		'autospam' => AccountInterstitial::whereType('post.autospam')->count(),
-    		'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(),
-    		'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(),
-    		'email_verification_requests' => Redis::scard('email:manual')
-    	];
-    	return $stats;
+        $stats = [
+            'total' => Report::count(),
+            'open' => Report::whereNull('admin_seen')->count(),
+            'closed' => Report::whereNotNull('admin_seen')->count(),
+            'autospam' => AccountInterstitial::whereType('post.autospam')->count(),
+            'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(),
+            'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(),
+            'remote_open' => RemoteReport::whereNull('action_taken_at')->count(),
+            'email_verification_requests' => Redis::scard('email:manual'),
+        ];
+
+        return $stats;
     }
 
     public function reportsApiAll(Request $request)
     {
-    	$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
-
-    	$reports = AdminReport::collection(
-    		Report::orderBy('id','desc')
-			->when($filter, function($q, $filter) {
-				return $filter == 'open' ?
-				$q->whereNull('admin_seen') :
-				$q->whereNotNull('admin_seen');
-			})
-			->groupBy(['id', 'object_id', 'object_type', 'profile_id'])
-			->cursorPaginate(6)
-			->withQueryString()
-		);
-
-		return $reports;
+        $filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
+
+        $reports = AdminReport::collection(
+            Report::orderBy('id', 'desc')
+                ->when($filter, function ($q, $filter) {
+                    return $filter == 'open' ?
+                    $q->whereNull('admin_seen') :
+                    $q->whereNotNull('admin_seen');
+                })
+                ->groupBy(['id', 'object_id', 'object_type', 'profile_id'])
+                ->cursorPaginate(6)
+                ->withQueryString()
+        );
+
+        return $reports;
+    }
+
+    public function reportsApiRemote(Request $request)
+    {
+        $filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
+
+        $reports = AdminRemoteReport::collection(
+            RemoteReport::orderBy('id', 'desc')
+                ->when($filter, function ($q, $filter) {
+                    return $filter == 'open' ?
+                    $q->whereNull('action_taken_at') :
+                    $q->whereNotNull('action_taken_at');
+                })
+                ->cursorPaginate(6)
+                ->withQueryString()
+        );
+
+        return $reports;
     }
 
     public function reportsApiGet(Request $request, $id)
     {
-    	$report = Report::findOrFail($id);
-    	return new AdminReport($report);
+        $report = Report::findOrFail($id);
+
+        return new AdminReport($report);
     }
 
     public function reportsApiHandle(Request $request)
     {
-    	$this->validate($request, [
-    		'object_id' => 'required',
-    		'object_type' => 'required',
-    		'id' => 'required',
-    		'action' => 'required|in:ignore,nsfw,unlist,private,delete',
-    		'action_type' => 'required|in:post,profile'
-    	]);
-
-    	$report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id'));
-
-    	if($request->input('action_type') === 'profile') {
-    		return $this->reportsHandleProfileAction($report, $request->input('action'));
-    	} else if($request->input('action_type') === 'post') {
-    		return $this->reportsHandleStatusAction($report, $request->input('action'));
-    	}
-
-    	return $report;
+        $this->validate($request, [
+            'object_id' => 'required',
+            'object_type' => 'required',
+            'id' => 'required',
+            'action' => 'required|in:ignore,nsfw,unlist,private,delete,delete-all',
+            'action_type' => 'required|in:post,profile,story',
+        ]);
+
+        $report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id'));
+
+        if ($request->input('action_type') === 'profile') {
+            return $this->reportsHandleProfileAction($report, $request->input('action'));
+        } elseif ($request->input('action_type') === 'post') {
+            return $this->reportsHandleStatusAction($report, $request->input('action'));
+        } elseif ($request->input('action_type') === 'story') {
+            return $this->reportsHandleStoryAction($report, $request->input('action'));
+        }
+
+        return $report;
+    }
+
+    protected function reportsHandleStoryAction($report, $action)
+    {
+        switch ($action) {
+            case 'ignore':
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'delete':
+                $profile = Profile::find($report->reported_profile_id);
+                $story = Story::whereProfileId($profile->id)->find($report->object_id);
+
+                abort_if(! $story, 400, 'Invalid or missing story');
+
+                $story->active = false;
+                $story->save();
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($report->object_id)
+                    ->objectType('App\Story::class')
+                    ->user(request()->user())
+                    ->action('admin.user.moderate')
+                    ->metadata([
+                        'action' => 'delete',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+                StoryDelete::dispatch($story)->onQueue('story');
+
+                return [200];
+                break;
+
+            case 'delete-all':
+                $profile = Profile::find($report->reported_profile_id);
+                $stories = Story::whereProfileId($profile->id)->whereActive(true)->get();
+
+                abort_if(! $stories || ! $stories->count(), 400, 'Invalid or missing stories');
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($report->object_id)
+                    ->objectType('App\Story::class')
+                    ->user(request()->user())
+                    ->action('admin.user.moderate')
+                    ->metadata([
+                        'action' => 'delete-all',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::where('reported_profile_id', $profile->id)
+                    ->whereObjectType('App\Story')
+                    ->whereNull('admin_seen')
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+                $stories->each(function ($story) {
+                    StoryDelete::dispatch($story)->onQueue('story');
+                });
+
+                return [200];
+                break;
+        }
     }
 
     protected function reportsHandleProfileAction($report, $action)
     {
-    	switch($action) {
-    		case 'ignore':
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'nsfw':
-    			if($report->object_type === 'App\Profile') {
-    				$profile = Profile::find($report->object_id);
-    			} else if($report->object_type === 'App\Status') {
-    				$status = Status::find($report->object_id);
-    				if(!$status) {
-    					return [200];
-    				}
-    				$profile = Profile::find($status->profile_id);
-    			}
-
-    			if(!$profile) {
-    				return;
-    			}
-
-    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
-
-    			$profile->cw = true;
-    			$profile->save();
-
-    			foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
-    				$status->is_nsfw = true;
-    				$status->save();
-    				StatusService::del($status->id);
-    				PublicTimelineService::rem($status->id);
-    			}
-
-				ModLogService::boot()
-					->objectUid($profile->id)
-					->objectId($profile->id)
-					->objectType('App\Profile::class')
-					->user(request()->user())
-					->action('admin.user.moderate')
-					->metadata([
-	                    'action' => 'cw',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'nsfw' => true,
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'unlist':
-    			if($report->object_type === 'App\Profile') {
-    				$profile = Profile::find($report->object_id);
-    			} else if($report->object_type === 'App\Status') {
-    				$status = Status::find($report->object_id);
-    				if(!$status) {
-    					return [200];
-    				}
-    				$profile = Profile::find($status->profile_id);
-    			}
-
-    			if(!$profile) {
-    				return;
-    			}
-
-    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
-
-    			$profile->unlisted = true;
-    			$profile->save();
-
-    			foreach(Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
-					$status->scope = 'unlisted';
-					$status->visibility = 'unlisted';
-					$status->save();
-					StatusService::del($status->id);
-					PublicTimelineService::rem($status->id);
-    			}
-
-				ModLogService::boot()
-					->objectUid($profile->id)
-					->objectId($profile->id)
-					->objectType('App\Profile::class')
-					->user(request()->user())
-					->action('admin.user.moderate')
-					->metadata([
-	                    'action' => 'unlisted',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'private':
-    			if($report->object_type === 'App\Profile') {
-    				$profile = Profile::find($report->object_id);
-    			} else if($report->object_type === 'App\Status') {
-    				$status = Status::find($report->object_id);
-    				if(!$status) {
-    					return [200];
-    				}
-    				$profile = Profile::find($status->profile_id);
-    			}
-
-    			if(!$profile) {
-    				return;
-    			}
-
-    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
-
-    			$profile->unlisted = true;
-    			$profile->save();
-
-    			foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
-					$status->scope = 'private';
-					$status->visibility = 'private';
-					$status->save();
-					StatusService::del($status->id);
-					PublicTimelineService::rem($status->id);
-    			}
-
-				ModLogService::boot()
-					->objectUid($profile->id)
-					->objectId($profile->id)
-					->objectType('App\Profile::class')
-					->user(request()->user())
-					->action('admin.user.moderate')
-					->metadata([
-	                    'action' => 'private',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'delete':
-				if(config('pixelfed.account_deletion') == false) {
-					abort(404);
-				}
-
-    			if($report->object_type === 'App\Profile') {
-    				$profile = Profile::find($report->object_id);
-    			} else if($report->object_type === 'App\Status') {
-    				$status = Status::find($report->object_id);
-    				if(!$status) {
-    					return [200];
-    				}
-    				$profile = Profile::find($status->profile_id);
-    			}
-
-    			if(!$profile) {
-    				return;
-    			}
-
-    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.');
-
-				$ts = now()->addMonth();
-
-    			if($profile->user_id) {
-	    			$user = $profile->user;
-					abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
-					$user->status = 'delete';
-					$user->delete_after = $ts;
-					$user->save();
-    			}
-
-				$profile->status = 'delete';
-				$profile->delete_after = $ts;
-				$profile->save();
-
-				ModLogService::boot()
-					->objectUid($profile->id)
-					->objectId($profile->id)
-					->objectType('App\Profile::class')
-					->user(request()->user())
-					->action('admin.user.delete')
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-
-    			if($profile->user_id) {
-    				DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
-    				DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
-					$user->email = $user->id;
-					$user->password = '';
-					$user->status = 'delete';
-					$user->save();
-					$profile->status = 'delete';
-					$profile->delete_after = now()->addMonth();
-					$profile->save();
-    				AccountService::del($profile->id);
-    				DeleteAccountPipeline::dispatch($user)->onQueue('high');
-    			} else {
-    				$profile->status = 'delete';
-					$profile->delete_after = now()->addMonth();
-					$profile->save();
-    				AccountService::del($profile->id);
-    				DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
-    			}
-    			return [200];
-    		break;
-    	}
+        switch ($action) {
+            case 'ignore':
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'nsfw':
+                if ($report->object_type === 'App\Profile') {
+                    $profile = Profile::find($report->object_id);
+                } elseif ($report->object_type === 'App\Status') {
+                    $status = Status::find($report->object_id);
+                    if (! $status) {
+                        return [200];
+                    }
+                    $profile = Profile::find($status->profile_id);
+                }
+
+                if (! $profile) {
+                    return;
+                }
+
+                abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+                $profile->cw = true;
+                $profile->save();
+
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_nsfw' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
+                foreach (Status::whereProfileId($profile->id)->cursor() as $status) {
+                    $status->is_nsfw = true;
+                    $status->save();
+                    StatusService::del($status->id);
+                    PublicTimelineService::rem($status->id);
+                }
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($profile->id)
+                    ->objectType('App\Profile::class')
+                    ->user(request()->user())
+                    ->action('admin.user.moderate')
+                    ->metadata([
+                        'action' => 'cw',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'nsfw' => true,
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'unlist':
+                if ($report->object_type === 'App\Profile') {
+                    $profile = Profile::find($report->object_id);
+                } elseif ($report->object_type === 'App\Status') {
+                    $status = Status::find($report->object_id);
+                    if (! $status) {
+                        return [200];
+                    }
+                    $profile = Profile::find($status->profile_id);
+                }
+
+                if (! $profile) {
+                    return;
+                }
+
+                abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+                $profile->unlisted = true;
+                $profile->save();
+
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_unlisted' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
+                foreach (Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
+                    $status->scope = 'unlisted';
+                    $status->visibility = 'unlisted';
+                    $status->save();
+                    StatusService::del($status->id);
+                    PublicTimelineService::rem($status->id);
+                }
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($profile->id)
+                    ->objectType('App\Profile::class')
+                    ->user(request()->user())
+                    ->action('admin.user.moderate')
+                    ->metadata([
+                        'action' => 'unlisted',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'private':
+                if ($report->object_type === 'App\Profile') {
+                    $profile = Profile::find($report->object_id);
+                } elseif ($report->object_type === 'App\Status') {
+                    $status = Status::find($report->object_id);
+                    if (! $status) {
+                        return [200];
+                    }
+                    $profile = Profile::find($status->profile_id);
+                }
+
+                if (! $profile) {
+                    return;
+                }
+
+                abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+                $profile->unlisted = true;
+                $profile->save();
+
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_unlisted' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
+                foreach (Status::whereProfileId($profile->id)->cursor() as $status) {
+                    $status->scope = 'private';
+                    $status->visibility = 'private';
+                    $status->save();
+                    StatusService::del($status->id);
+                    PublicTimelineService::rem($status->id);
+                }
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($profile->id)
+                    ->objectType('App\Profile::class')
+                    ->user(request()->user())
+                    ->action('admin.user.moderate')
+                    ->metadata([
+                        'action' => 'private',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'delete':
+                if (config('pixelfed.account_deletion') == false) {
+                    abort(404);
+                }
+
+                if ($report->object_type === 'App\Profile') {
+                    $profile = Profile::find($report->object_id);
+                } elseif ($report->object_type === 'App\Status') {
+                    $status = Status::find($report->object_id);
+                    if (! $status) {
+                        return [200];
+                    }
+                    $profile = Profile::find($status->profile_id);
+                }
+
+                if (! $profile) {
+                    return;
+                }
+
+                abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.');
+
+                $ts = now()->addMonth();
+
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_banned' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
+                if ($profile->user_id) {
+                    $user = $profile->user;
+                    abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
+                    $user->status = 'delete';
+                    $user->delete_after = $ts;
+                    $user->save();
+                }
+
+                $profile->status = 'delete';
+                $profile->delete_after = $ts;
+                $profile->save();
+
+                ModLogService::boot()
+                    ->objectUid($profile->id)
+                    ->objectId($profile->id)
+                    ->objectType('App\Profile::class')
+                    ->user(request()->user())
+                    ->action('admin.user.delete')
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                if ($profile->user_id) {
+                    DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
+                    DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
+                    $user->email = $user->id;
+                    $user->password = '';
+                    $user->status = 'delete';
+                    $user->save();
+                    $profile->status = 'delete';
+                    $profile->delete_after = now()->addMonth();
+                    $profile->save();
+                    AccountService::del($profile->id);
+                    DeleteAccountPipeline::dispatch($user)->onQueue('high');
+                } else {
+                    $profile->status = 'delete';
+                    $profile->delete_after = now()->addMonth();
+                    $profile->save();
+                    AccountService::del($profile->id);
+                    DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
+                }
+
+                return [200];
+                break;
+        }
     }
 
     protected function reportsHandleStatusAction($report, $action)
     {
-    	switch($action) {
-    		case 'ignore':
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'nsfw':
-    			$status = Status::find($report->object_id);
-
-    			if(!$status) {
-    				return [200];
-    			}
-
-				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
-    			$status->is_nsfw = true;
-    			$status->save();
-    			StatusService::del($status->id);
-
-				ModLogService::boot()
-					->objectUid($status->profile_id)
-					->objectId($status->profile_id)
-					->objectType('App\Status::class')
-					->user(request()->user())
-					->action('admin.status.moderate')
-					->metadata([
-	                    'action' => 'cw',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'nsfw' => true,
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'private':
-    			$status = Status::find($report->object_id);
-
-    			if(!$status) {
-    				return [200];
-    			}
-
-				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
-
-    			$status->scope = 'private';
-    			$status->visibility = 'private';
-    			$status->save();
-    			StatusService::del($status->id);
-				PublicTimelineService::rem($status->id);
-
-				ModLogService::boot()
-					->objectUid($status->profile_id)
-					->objectId($status->profile_id)
-					->objectType('App\Status::class')
-					->user(request()->user())
-					->action('admin.status.moderate')
-					->metadata([
-	                    'action' => 'private',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'unlist':
-    			$status = Status::find($report->object_id);
-
-    			if(!$status) {
-    				return [200];
-    			}
-
-				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
-
-    			if($status->scope === 'public') {
-	    			$status->scope = 'unlisted';
-	    			$status->visibility = 'unlisted';
-	    			$status->save();
-	    			StatusService::del($status->id);
-    				PublicTimelineService::rem($status->id);
-    			}
-
-				ModLogService::boot()
-					->objectUid($status->profile_id)
-					->objectId($status->profile_id)
-					->objectType('App\Status::class')
-					->user(request()->user())
-					->action('admin.status.moderate')
-					->metadata([
-	                    'action' => 'unlist',
-	                    'message' => 'Success!'
-                	])
-					->accessLevel('admin')
-					->save();
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-    			return [200];
-    		break;
-
-    		case 'delete':
-    			$status = Status::find($report->object_id);
-
-    			if(!$status) {
-    				return [200];
-    			}
-
-    			$profile = $status->profile;
-
-    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.');
-
-    			StatusService::del($status->id);
-
-    			if($profile->user_id != null && $profile->domain == null) {
-    				PublicTimelineService::del($status->id);
-    				StatusDelete::dispatch($status)->onQueue('high');
-    			} else {
-    				NetworkTimelineService::del($status->id);
-    				RemoteStatusDelete::dispatch($status)->onQueue('high');
-    			}
-
-    			Report::whereObjectId($report->object_id)
-    				->whereObjectType($report->object_type)
-    				->update([
-    					'admin_seen' => now()
-    				]);
-
-    			return [200];
-    		break;
-    	}
+        switch ($action) {
+            case 'ignore':
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'nsfw':
+                $status = Status::find($report->object_id);
+
+                if (! $status) {
+                    return [200];
+                }
+
+                abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+                $status->is_nsfw = true;
+                $status->save();
+                StatusService::del($status->id);
+
+                ModLogService::boot()
+                    ->objectUid($status->profile_id)
+                    ->objectId($status->profile_id)
+                    ->objectType('App\Status::class')
+                    ->user(request()->user())
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'cw',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'nsfw' => true,
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'private':
+                $status = Status::find($report->object_id);
+
+                if (! $status) {
+                    return [200];
+                }
+
+                abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+
+                $status->scope = 'private';
+                $status->visibility = 'private';
+                $status->save();
+                StatusService::del($status->id);
+                PublicTimelineService::rem($status->id);
+
+                ModLogService::boot()
+                    ->objectUid($status->profile_id)
+                    ->objectId($status->profile_id)
+                    ->objectType('App\Status::class')
+                    ->user(request()->user())
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'private',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'unlist':
+                $status = Status::find($report->object_id);
+
+                if (! $status) {
+                    return [200];
+                }
+
+                abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+
+                if ($status->scope === 'public') {
+                    $status->scope = 'unlisted';
+                    $status->visibility = 'unlisted';
+                    $status->save();
+                    StatusService::del($status->id);
+                    PublicTimelineService::rem($status->id);
+                }
+
+                ModLogService::boot()
+                    ->objectUid($status->profile_id)
+                    ->objectId($status->profile_id)
+                    ->objectType('App\Status::class')
+                    ->user(request()->user())
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'unlist',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+
+            case 'delete':
+                $status = Status::find($report->object_id);
+
+                if (! $status) {
+                    return [200];
+                }
+
+                $profile = $status->profile;
+
+                abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.');
+
+                StatusService::del($status->id);
+
+                if ($profile->user_id != null && $profile->domain == null) {
+                    PublicTimelineService::del($status->id);
+                    StatusDelete::dispatch($status)->onQueue('high');
+                } else {
+                    NetworkTimelineService::del($status->id);
+                    RemoteStatusDelete::dispatch($status)->onQueue('high');
+                }
+
+                Report::whereObjectId($report->object_id)
+                    ->whereObjectType($report->object_type)
+                    ->update([
+                        'admin_seen' => now(),
+                    ]);
+
+                return [200];
+                break;
+        }
     }
 
     public function reportsApiSpamAll(Request $request)
     {
-    	$tab = $request->input('tab', 'home');
+        $tab = $request->input('tab', 'home');
 
-		$appeals = AdminSpamReport::collection(
-			AccountInterstitial::orderBy('id', 'desc')
-			->whereType('post.autospam')
-			->whereNull('appeal_handled_at')
-			->cursorPaginate(6)
-			->withQueryString()
-		);
+        $appeals = AdminSpamReport::collection(
+            AccountInterstitial::orderBy('id', 'desc')
+                ->whereType('post.autospam')
+                ->whereNull('appeal_handled_at')
+                ->cursorPaginate(6)
+                ->withQueryString()
+        );
 
-		return $appeals;
+        return $appeals;
     }
 
     public function reportsApiSpamHandle(Request $request)
     {
-    	$this->validate($request, [
-    		'id' => 'required',
-    		'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile',
-    	]);
-
-    	$action = $request->input('action');
-
-		abort_if(
-			$action === 'delete-profile' &&
-			!config('pixelfed.account_deletion'),
-			404,
-			"Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config."
-		);
-
-    	$report = AccountInterstitial::with('user')
-    		->whereType('post.autospam')
-    		->whereNull('appeal_handled_at')
-    		->findOrFail($request->input('id'));
-
-    	$this->reportsHandleSpamAction($report, $action);
-    	Cache::forget('admin-dash:reports:spam-count');
-    	Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id);
-		Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id);
-    	return [$action, $report];
+        $this->validate($request, [
+            'id' => 'required',
+            'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile',
+        ]);
+
+        $action = $request->input('action');
+
+        abort_if(
+            $action === 'delete-profile' &&
+            ! config('pixelfed.account_deletion'),
+            404,
+            "Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config."
+        );
+
+        $report = AccountInterstitial::with('user')
+            ->whereType('post.autospam')
+            ->whereNull('appeal_handled_at')
+            ->findOrFail($request->input('id'));
+
+        $this->reportsHandleSpamAction($report, $action);
+        Cache::forget('admin-dash:reports:spam-count');
+        Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$report->user->profile_id);
+        Cache::forget('pf:bouncer_v0:recent_by_pid:'.$report->user->profile_id);
+
+        return [$action, $report];
     }
 
     public function reportsHandleSpamAction($appeal, $action)
     {
-    	$meta = json_decode($appeal->meta);
-
-		if($action == 'mark-read') {
-			$appeal->is_spam = true;
-			$appeal->appeal_handled_at = now();
-			$appeal->save();
-			PublicTimelineService::del($appeal->item_id);
-		}
-
-		if($action == 'mark-not-spam') {
-			$status = $appeal->status;
-			$status->is_nsfw = $meta->is_nsfw;
-			$status->scope = 'public';
-			$status->visibility = 'public';
-			$status->save();
-
-			$appeal->is_spam = false;
-			$appeal->appeal_handled_at = now();
-			$appeal->save();
-
-			Notification::whereAction('autospam.warning')
-				->whereProfileId($appeal->user->profile_id)
-				->get()
-				->each(function($n) use($appeal) {
-					NotificationService::del($appeal->user->profile_id, $n->id);
-					$n->forceDelete();
-				});
-
-			StatusService::del($status->id);
-			StatusService::get($status->id);
-			if($status->in_reply_to_id == null && $status->reblog_of_id == null) {
-				PublicTimelineService::add($status->id);
-			}
-		}
-
-		if($action == 'mark-all-read') {
-			AccountInterstitial::whereType('post.autospam')
-				->whereItemType('App\Status')
-				->whereNull('appeal_handled_at')
-				->whereUserId($appeal->user_id)
-				->update([
-					'appeal_handled_at' => now(),
-					'is_spam' => true
-				]);
-		}
-
-		if($action == 'mark-all-not-spam') {
-			AccountInterstitial::whereType('post.autospam')
-				->whereItemType('App\Status')
-				->whereUserId($appeal->user_id)
-				->get()
-				->each(function($report) use($meta) {
-					$report->is_spam = false;
-					$report->appeal_handled_at = now();
-					$report->save();
-					$status = Status::find($report->item_id);
-					if($status) {
-						$status->is_nsfw = $meta->is_nsfw;
-						$status->scope = 'public';
-						$status->visibility = 'public';
-						$status->save();
-						StatusService::del($status->id);
-					}
-					Notification::whereAction('autospam.warning')
-						->whereProfileId($report->user->profile_id)
-						->get()
-						->each(function($n) use($report) {
-							NotificationService::del($report->user->profile_id, $n->id);
-							$n->forceDelete();
-						});
-				});
-		}
-
-		if($action == 'delete-profile') {
-			$user = User::findOrFail($appeal->user_id);
-			$profile = $user->profile;
-
-			if($user->is_admin == true) {
-				$mid = request()->user()->id;
-				abort_if($user->id < $mid, 403, 'You cannot delete an admin account.');
-			}
-
-			$ts = now()->addMonth();
-			$user->status = 'delete';
-			$profile->status = 'delete';
-			$user->delete_after = $ts;
-			$profile->delete_after = $ts;
-			$user->save();
-			$profile->save();
-
-			$appeal->appeal_handled_at = now();
-			$appeal->save();
-
-			ModLogService::boot()
-				->objectUid($user->id)
-				->objectId($user->id)
-				->objectType('App\User::class')
-				->user(request()->user())
-				->action('admin.user.delete')
-				->accessLevel('admin')
-				->save();
-
-			Cache::forget('profiles:private');
-			DeleteAccountPipeline::dispatch($user);
-		}
+        $meta = json_decode($appeal->meta);
+
+        if ($action == 'mark-read') {
+            $appeal->is_spam = true;
+            $appeal->appeal_handled_at = now();
+            $appeal->save();
+            PublicTimelineService::del($appeal->item_id);
+        }
+
+        if ($action == 'mark-not-spam') {
+            $status = $appeal->status;
+            $status->is_nsfw = $meta->is_nsfw;
+            $status->scope = 'public';
+            $status->visibility = 'public';
+            $status->save();
+
+            $appeal->is_spam = false;
+            $appeal->appeal_handled_at = now();
+            $appeal->save();
+
+            Notification::whereAction('autospam.warning')
+                ->whereProfileId($appeal->user->profile_id)
+                ->get()
+                ->each(function ($n) use ($appeal) {
+                    NotificationService::del($appeal->user->profile_id, $n->id);
+                    $n->forceDelete();
+                });
+
+            StatusService::del($status->id);
+            StatusService::get($status->id);
+            if ($status->in_reply_to_id == null && $status->reblog_of_id == null) {
+                PublicTimelineService::add($status->id);
+            }
+        }
+
+        if ($action == 'mark-all-read') {
+            AccountInterstitial::whereType('post.autospam')
+                ->whereItemType('App\Status')
+                ->whereNull('appeal_handled_at')
+                ->whereUserId($appeal->user_id)
+                ->update([
+                    'appeal_handled_at' => now(),
+                    'is_spam' => true,
+                ]);
+        }
+
+        if ($action == 'mark-all-not-spam') {
+            AccountInterstitial::whereType('post.autospam')
+                ->whereItemType('App\Status')
+                ->whereUserId($appeal->user_id)
+                ->get()
+                ->each(function ($report) use ($meta) {
+                    $report->is_spam = false;
+                    $report->appeal_handled_at = now();
+                    $report->save();
+                    $status = Status::find($report->item_id);
+                    if ($status) {
+                        $status->is_nsfw = $meta->is_nsfw;
+                        $status->scope = 'public';
+                        $status->visibility = 'public';
+                        $status->save();
+                        StatusService::del($status->id);
+                    }
+                    Notification::whereAction('autospam.warning')
+                        ->whereProfileId($report->user->profile_id)
+                        ->get()
+                        ->each(function ($n) use ($report) {
+                            NotificationService::del($report->user->profile_id, $n->id);
+                            $n->forceDelete();
+                        });
+                });
+        }
+
+        if ($action == 'delete-profile') {
+            $user = User::findOrFail($appeal->user_id);
+            $profile = $user->profile;
+
+            if ($user->is_admin == true) {
+                $mid = request()->user()->id;
+                abort_if($user->id < $mid, 403, 'You cannot delete an admin account.');
+            }
+
+            $ts = now()->addMonth();
+            $user->status = 'delete';
+            $profile->status = 'delete';
+            $user->delete_after = $ts;
+            $profile->delete_after = $ts;
+            $user->save();
+            $profile->save();
+
+            $appeal->appeal_handled_at = now();
+            $appeal->save();
+
+            ModLogService::boot()
+                ->objectUid($user->id)
+                ->objectId($user->id)
+                ->objectType('App\User::class')
+                ->user(request()->user())
+                ->action('admin.user.delete')
+                ->accessLevel('admin')
+                ->save();
+
+            Cache::forget('profiles:private');
+            DeleteAccountPipeline::dispatch($user);
+        }
     }
 
     public function reportsApiSpamGet(Request $request, $id)
     {
-    	$report = AccountInterstitial::findOrFail($id);
-    	return new AdminSpamReport($report);
+        $report = AccountInterstitial::findOrFail($id);
+
+        return new AdminSpamReport($report);
+    }
+
+    public function reportsApiRemoteHandle(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|exists:remote_reports,id',
+            'action' => 'required|in:mark-read,cw-posts,unlist-posts,delete-posts,private-posts,mark-all-read-by-domain,mark-all-read-by-username,cw-all-posts,private-all-posts,unlist-all-posts',
+        ]);
+
+        $report = RemoteReport::findOrFail($request->input('id'));
+        $user = User::whereProfileId($report->account_id)->first();
+        $ogPublicStatuses = [];
+        $ogUnlistedStatuses = [];
+        $ogNonCwStatuses = [];
+
+        switch ($request->input('action')) {
+            case 'mark-read':
+                $report->action_taken_at = now();
+                $report->save();
+                break;
+            case 'mark-all-read-by-domain':
+                RemoteReport::whereInstanceId($report->instance_id)->update(['action_taken_at' => now()]);
+                break;
+            case 'cw-posts':
+                $statuses = Status::find($report->status_ids);
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
+                        continue;
+                    }
+                    if (! $status->is_nsfw) {
+                        $ogNonCwStatuses[] = $status->id;
+                    }
+                    $status->is_nsfw = true;
+                    $status->saveQuietly();
+                    StatusService::del($status->id);
+                }
+                $report->action_taken_at = now();
+                $report->save();
+                break;
+            case 'cw-all-posts':
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if ($status->is_nsfw || $status->reblog_of_id) {
+                        continue;
+                    }
+                    if (! $status->is_nsfw) {
+                        $ogNonCwStatuses[] = $status->id;
+                    }
+                    $status->is_nsfw = true;
+                    $status->saveQuietly();
+                    StatusService::del($status->id);
+                }
+                break;
+            case 'unlist-posts':
+                $statuses = Status::find($report->status_ids);
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
+                        continue;
+                    }
+                    if ($status->scope === 'public') {
+                        $ogPublicStatuses[] = $status->id;
+                        $status->scope = 'unlisted';
+                        $status->visibility = 'unlisted';
+                        $status->saveQuietly();
+                        StatusService::del($status->id);
+                    }
+                }
+                $report->action_taken_at = now();
+                $report->save();
+                break;
+            case 'unlist-all-posts':
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if ($status->visibility !== 'public' || $status->reblog_of_id) {
+                        continue;
+                    }
+                    $ogPublicStatuses[] = $status->id;
+                    $status->visibility = 'unlisted';
+                    $status->scope = 'unlisted';
+                    $status->saveQuietly();
+                    StatusService::del($status->id);
+                }
+                break;
+            case 'private-posts':
+                $statuses = Status::find($report->status_ids);
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
+                        continue;
+                    }
+                    if (in_array($status->scope, ['public', 'unlisted', 'private'])) {
+                        if ($status->scope === 'public') {
+                            $ogPublicStatuses[] = $status->id;
+                        }
+                        $status->scope = 'private';
+                        $status->visibility = 'private';
+                        $status->saveQuietly();
+                        StatusService::del($status->id);
+                    }
+                }
+                $report->action_taken_at = now();
+                $report->save();
+                break;
+            case 'private-all-posts':
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if (! in_array($status->visibility, ['public', 'unlisted']) || $status->reblog_of_id) {
+                        continue;
+                    }
+                    if ($status->visibility === 'public') {
+                        $ogPublicStatuses[] = $status->id;
+                    } elseif ($status->visibility === 'unlisted') {
+                        $ogUnlistedStatuses[] = $status->id;
+                    }
+                    $status->visibility = 'private';
+                    $status->scope = 'private';
+                    $status->saveQuietly();
+                    StatusService::del($status->id);
+                }
+                break;
+            case 'delete-posts':
+                $statuses = Status::find($report->status_ids);
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
+                        continue;
+                    }
+                    StatusDelete::dispatch($status);
+                }
+                $report->action_taken_at = now();
+                $report->save();
+                break;
+            case 'mark-all-read-by-username':
+                RemoteReport::whereNull('action_taken_at')->whereAccountId($report->account_id)->update(['action_taken_at' => now()]);
+                break;
+
+            default:
+                abort(404);
+                break;
+        }
+
+        if ($ogPublicStatuses && count($ogPublicStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-public-statuses.json', json_encode($ogPublicStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+        }
+
+        if ($ogNonCwStatuses && count($ogNonCwStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-noncw-statuses.json', json_encode($ogNonCwStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+        }
+
+        if ($ogUnlistedStatuses && count($ogUnlistedStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-unlisted-statuses.json', json_encode($ogUnlistedStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+        }
+
+        ModLogService::boot()
+            ->user(request()->user())
+            ->objectUid($user ? $user->id : null)
+            ->objectId($report->id)
+            ->objectType('App\Report::class')
+            ->action('admin.report.moderate')
+            ->metadata([
+                'action' => $request->input('action'),
+                'duration_active' => now()->parse($report->created_at)->diffForHumans(),
+            ])
+            ->accessLevel('admin')
+            ->save();
+
+        if ($report->status_ids) {
+            foreach ($report->status_ids as $sid) {
+                RemoteReport::whereNull('action_taken_at')
+                    ->whereJsonContains('status_ids', [$sid])
+                    ->update(['action_taken_at' => now()]);
+            }
+        }
+
+        return [200];
+    }
+
+    public function getModeratedProfiles(Request $request)
+    {
+        $this->validate($request, [
+            'search' => 'sometimes|string|min:3|max:120',
+        ]);
+
+        if ($request->filled('search')) {
+            $query = '%'.$request->input('search').'%';
+            $profiles = DB::table('moderated_profiles')
+                ->join('profiles', 'moderated_profiles.profile_id', '=', 'profiles.id')
+                ->where('profiles.username', 'LIKE', $query)
+                ->select('moderated_profiles.*', 'profiles.username')
+                ->orderByDesc('moderated_profiles.id')
+                ->cursorPaginate(10);
+
+            return AdminModeratedProfileResource::collection($profiles);
+        }
+        $profiles = ModeratedProfile::orderByDesc('id')->cursorPaginate(10);
+
+        return AdminModeratedProfileResource::collection($profiles);
+    }
+
+    public function getModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+
+        return new AdminModeratedProfileResource($profile);
+    }
+
+    public function exportModeratedProfiles(Request $request)
+    {
+        return response()->streamDownload(function () {
+            $profiles = ModeratedProfile::get();
+            $res = AdminModeratedProfileResource::collection($profiles);
+            echo json_encode([
+                '_pixelfed_export' => true,
+                'meta' => [
+                    'ns' => 'https://pixelfed.org',
+                    'origin' => config('pixelfed.domain.app'),
+                    'date' => now()->format('c'),
+                    'type' => 'moderated-profiles',
+                    'version' => "1.0"
+                ],
+                'data' => $res
+            ], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+        }, 'data-export.json');
+    }
+
+    public function deleteModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+
+        ModLogService::boot()
+            ->objectUid($profile->profile_id)
+            ->objectId($profile->id)
+            ->objectType('App\Models\ModeratedProfile::class')
+            ->user(request()->user())
+            ->action('admin.moderated-profiles.delete')
+            ->metadata([
+                'profile_url' => $profile->profile_url,
+                'profile_id' => $profile->profile_id,
+                'domain' => $profile->domain,
+                'note' => $profile->note,
+                'is_banned' => $profile->is_banned,
+                'is_nsfw' => $profile->is_nsfw,
+                'is_unlisted' => $profile->is_unlisted,
+                'is_noautolink' => $profile->is_noautolink,
+                'is_nodms' => $profile->is_nodms,
+                'is_notrending' => $profile->is_notrending,
+            ])
+            ->accessLevel('admin')
+            ->save();
+
+        $profile->delete();
+
+        return ['status' => 200, 'message' => 'Successfully deleted moderated profile!'];
+    }
+
+    public function updateModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|exists:moderated_profiles',
+            'note' => 'sometimes|nullable|string|max:500',
+            'is_banned' => 'required|boolean',
+            'is_noautolink' => 'required|boolean',
+            'is_nodms' => 'required|boolean',
+            'is_notrending' => 'required|boolean',
+            'is_nsfw' => 'required|boolean',
+            'is_unlisted' => 'required|boolean',
+        ]);
+
+        $fields = [
+            'note',
+            'is_banned',
+            'is_noautolink',
+            'is_nodms',
+            'is_notrending',
+            'is_nsfw',
+            'is_unlisted',
+        ];
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+        $profile->update($request->only($fields));
+
+        ModLogService::boot()
+            ->objectUid($profile->profile_id)
+            ->objectId($profile->id)
+            ->objectType('App\Models\ModeratedProfile::class')
+            ->user(request()->user())
+            ->action('admin.moderated-profiles.update')
+            ->metadata($request->only($fields))
+            ->accessLevel('admin')
+            ->save();
+
+        return [200];
+    }
+
+    public function createModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'url' => 'required|url|starts_with:https://',
+        ]);
+
+        $url = $request->input('url');
+        $host = parse_url($url, PHP_URL_HOST);
+
+        abort_if($host === config('pixelfed.domain.app'), 400, 'You cannot add local users!');
+
+        $exists = ModeratedProfile::whereProfileUrl($url)->exists();
+        abort_if($exists, 400, 'Moderated profile already exists!');
+
+        $profile = Profile::whereRemoteUrl($url)->first();
+
+        if ($profile) {
+            $rec = ModeratedProfile::updateOrCreate([
+                'profile_id' => $profile->id,
+            ], [
+                'profile_url' => $profile->remote_url,
+                'domain' => $profile->domain,
+            ]);
+
+            ModLogService::boot()
+                ->objectUid($rec->profile_id)
+                ->objectId($rec->id)
+                ->objectType('App\Models\ModeratedProfile::class')
+                ->user(request()->user())
+                ->action('admin.moderated-profiles.create')
+                ->metadata([
+                    'profile_existed' => true,
+                ])
+                ->accessLevel('admin')
+                ->save();
+
+            return $rec;
+        }
+
+        $remoteSearch = Helpers::profileFetch($url);
+
+        if ($remoteSearch) {
+            $rec = ModeratedProfile::updateOrCreate([
+                'profile_id' => $remoteSearch->id,
+            ], [
+                'profile_url' => $remoteSearch->remote_url,
+                'domain' => $remoteSearch->domain,
+            ]);
+
+            ModLogService::boot()
+                ->objectUid($rec->profile_id)
+                ->objectId($rec->id)
+                ->objectType('App\Models\ModeratedProfile::class')
+                ->user(request()->user())
+                ->action('admin.moderated-profiles.create')
+                ->metadata([
+                    'profile_existed' => false,
+                ])
+                ->accessLevel('admin')
+                ->save();
+
+            return $rec;
+        }
+        abort(400, 'Invalid account');
     }
 }

+ 877 - 273
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -2,284 +2,888 @@
 
 namespace App\Http\Controllers\Admin;
 
-use Artisan, Cache, DB;
-use Illuminate\Http\Request;
-use Carbon\Carbon;
-use App\{Comment, Like, Media, Page, Profile, Report, Status, User};
-use App\Models\InstanceActor;
-use App\Http\Controllers\Controller;
-use App\Util\Lexer\PrettyNumber;
 use App\Models\ConfigCache;
+use App\Models\InstanceActor;
+use App\Page;
+use App\Profile;
 use App\Services\AccountService;
+use App\Services\AdminSettingsService;
 use App\Services\ConfigCacheService;
+use App\Services\FilesystemService;
+use App\User;
 use App\Util\Site\Config;
-use Illuminate\Support\Str;
+use Artisan;
+use Cache;
+use DB;
+use Illuminate\Http\Request;
 
 trait AdminSettingsController
 {
-	public function settings(Request $request)
-	{
-		$cloud_storage = ConfigCacheService::get('pixelfed.cloud_storage');
-		$cloud_disk = config('filesystems.cloud');
-		$cloud_ready = !empty(config('filesystems.disks.' . $cloud_disk . '.key')) && !empty(config('filesystems.disks.' . $cloud_disk . '.secret'));
-		$types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
-		$rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : null;
-		$jpeg = in_array('image/jpg', $types) || in_array('image/jpeg', $types);
-		$png = in_array('image/png', $types);
-		$gif = in_array('image/gif', $types);
-		$mp4 = in_array('video/mp4', $types);
-		$webp = in_array('image/webp', $types);
-
-		$availableAdmins = User::whereIsAdmin(true)->get();
-		$currentAdmin = config_cache('instance.admin.pid') ? AccountService::get(config_cache('instance.admin.pid'), true) : null;
-
-		// $system = [
-		// 	'permissions' => is_writable(base_path('storage')) && is_writable(base_path('bootstrap')),
-		// 	'max_upload_size' => ini_get('post_max_size'),
-		// 	'image_driver' => config('image.driver'),
-		// 	'image_driver_loaded' => extension_loaded(config('image.driver'))
-		// ];
-
-		return view('admin.settings.home', compact(
-			'jpeg',
-			'png',
-			'gif',
-			'mp4',
-			'webp',
-			'rules',
-			'cloud_storage',
-			'cloud_disk',
-			'cloud_ready',
-			'availableAdmins',
-			'currentAdmin'
-			// 'system'
-		));
-	}
-
-	public function settingsHomeStore(Request $request)
-	{
-		$this->validate($request, [
-			'name' => 'nullable|string',
-			'short_description' => 'nullable',
-			'long_description' => 'nullable',
-			'max_photo_size' => 'nullable|integer|min:1',
-			'max_album_length' => 'nullable|integer|min:1|max:100',
-			'image_quality' => 'nullable|integer|min:1|max:100',
-			'type_jpeg' => 'nullable',
-			'type_png' => 'nullable',
-			'type_gif' => 'nullable',
-			'type_mp4' => 'nullable',
-			'type_webp' => 'nullable',
-			'admin_account_id' => 'nullable',
-		]);
-
-		if($request->filled('admin_account_id')) {
-			ConfigCacheService::put('instance.admin.pid', $request->admin_account_id);
-			Cache::forget('api:v1:instance-data:contact');
-			Cache::forget('api:v1:instance-data-response-v1');
-		}
-		if($request->filled('rule_delete')) {
-			$index = (int) $request->input('rule_delete');
-			$rules = ConfigCacheService::get('app.rules');
-			$json = json_decode($rules, true);
-			if(!$rules || empty($json)) {
-				return;
-			}
-			unset($json[$index]);
-			$json = json_encode(array_values($json));
-			ConfigCacheService::put('app.rules', $json);
-			Cache::forget('api:v1:instance-data:rules');
-			Cache::forget('api:v1:instance-data-response-v1');
-			return 200;
-		}
-
-		$media_types = explode(',', config_cache('pixelfed.media_types'));
-		$media_types_original = $media_types;
-
-		$mimes = [
-			'type_jpeg' => 'image/jpeg',
-			'type_png' => 'image/png',
-			'type_gif' => 'image/gif',
-			'type_mp4' => 'video/mp4',
-			'type_webp' => 'image/webp',
-		];
-
-		foreach ($mimes as $key => $value) {
-			if($request->input($key) == 'on') {
-				if(!in_array($value, $media_types)) {
-					array_push($media_types, $value);
-				}
-			} else {
-				$media_types = array_diff($media_types, [$value]);
-			}
-		}
-
-		if($media_types !== $media_types_original) {
-			ConfigCacheService::put('pixelfed.media_types', implode(',', array_unique($media_types)));
-		}
-
-		$keys = [
-			'name' => 'app.name',
-			'short_description' => 'app.short_description',
-			'long_description' => 'app.description',
-			'max_photo_size' => 'pixelfed.max_photo_size',
-			'max_album_length' => 'pixelfed.max_album_length',
-			'image_quality' => 'pixelfed.image_quality',
-			'account_limit' => 'pixelfed.max_account_size',
-			'custom_css' => 'uikit.custom.css',
-			'custom_js' => 'uikit.custom.js',
-			'about_title' => 'about.title'
-		];
-
-		foreach ($keys as $key => $value) {
-			$cc = ConfigCache::whereK($value)->first();
-			$val = $request->input($key);
-			if($cc && $cc->v != $val) {
-				ConfigCacheService::put($value, $val);
-			} else if(!empty($val)) {
-				ConfigCacheService::put($value, $val);
-			}
-		}
-
-		$bools = [
-			'activitypub' => 'federation.activitypub.enabled',
-			'open_registration' => 'pixelfed.open_registration',
-			'mobile_apis' => 'pixelfed.oauth_enabled',
-			'stories' => 'instance.stories.enabled',
-			'ig_import' => 'pixelfed.import.instagram.enabled',
-			'spam_detection' => 'pixelfed.bouncer.enabled',
-			'require_email_verification' => 'pixelfed.enforce_email_verification',
-			'enforce_account_limit' => 'pixelfed.enforce_account_limit',
-			'show_custom_css' => 'uikit.show_custom.css',
-			'show_custom_js' => 'uikit.show_custom.js',
-			'cloud_storage' => 'pixelfed.cloud_storage',
-			'account_autofollow' => 'account.autofollow',
-			'show_directory' => 'instance.landing.show_directory',
-			'show_explore_feed' => 'instance.landing.show_explore',
-		];
-
-		foreach ($bools as $key => $value) {
-			$active = $request->input($key) == 'on';
-
-			if($key == 'activitypub' && $active && !InstanceActor::exists()) {
-				Artisan::call('instance:actor');
-			}
-
-			if( $key == 'mobile_apis' &&
-				$active &&
-				!file_exists(storage_path('oauth-public.key')) &&
-				!file_exists(storage_path('oauth-private.key'))
-			) {
-				Artisan::call('passport:keys');
-				Artisan::call('route:cache');
-			}
-
-			if(config_cache($value) !== $active) {
-				ConfigCacheService::put($value, (bool) $active);
-			}
-		}
-
-		if($request->filled('new_rule')) {
-			$rules = ConfigCacheService::get('app.rules');
-			$val = $request->input('new_rule');
-			if(!$rules) {
-				ConfigCacheService::put('app.rules', json_encode([$val]));
-			} else {
-				$json = json_decode($rules, true);
-				$json[] = $val;
-				ConfigCacheService::put('app.rules', json_encode(array_values($json)));
-			}
-			Cache::forget('api:v1:instance-data:rules');
-			Cache::forget('api:v1:instance-data-response-v1');
-		}
-
-		if($request->filled('account_autofollow_usernames')) {
-			$usernames = explode(',', $request->input('account_autofollow_usernames'));
-			$names = [];
-
-			foreach($usernames as $n) {
-				$p = Profile::whereUsername($n)->first();
-				if(!$p) {
-					continue;
-				}
-				array_push($names, $p->username);
-			}
-
-			ConfigCacheService::put('account.autofollow_usernames', implode(',', $names));
-		}
-
-		Cache::forget(Config::CACHE_KEY);
-
-		return redirect('/i/admin/settings')->with('status', 'Successfully updated settings!');
-	}
-
-	public function settingsBackups(Request $request)
-	{
-		$path = storage_path('app/'.config('app.name'));
-		$files = is_dir($path) ? new \DirectoryIterator($path) : [];
-		return view('admin.settings.backups', compact('files'));
-	}
-
-	public function settingsMaintenance(Request $request)
-	{
-		return view('admin.settings.maintenance');
-	}
-
-	public function settingsStorage(Request $request)
-	{
-		$storage = [];
-		return view('admin.settings.storage', compact('storage'));
-	}
-
-	public function settingsFeatures(Request $request)
-	{
-		return view('admin.settings.features');
-	}
-
-	public function settingsPages(Request $request)
-	{
-		$pages = Page::orderByDesc('updated_at')->paginate(10);
-		return view('admin.pages.home', compact('pages'));
-	}
-
-	public function settingsPageEdit(Request $request)
-	{
-		return view('admin.pages.edit');
-	}
-
-	public function settingsSystem(Request $request)
-	{
-		$sys = [
-			'pixelfed' => config('pixelfed.version'),
-			'php' => phpversion(),
-			'laravel' => app()->version(),
-		];
-		switch (config('database.default')) {
-			case 'pgsql':
-			$exp = DB::raw('select version();');
-			$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
-			$sys['database'] = [
-				'name' => 'Postgres',
-				'version' => explode(' ', DB::select($expQuery)[0]->version)[1]
-			];
-			break;
-
-			case 'mysql':
-			$exp = DB::raw('select version()');
-			$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
-			$sys['database'] = [
-				'name' => 'MySQL',
-				'version' => DB::select($expQuery)[0]->{'version()'}
-			];
-			break;
-
-			default:
-			$sys['database'] = [
-				'name' => 'Unknown',
-				'version' => '?'
-			];
-			break;
-		}
-		return view('admin.settings.system', compact('sys'));
-	}
+    public function settings(Request $request)
+    {
+        $cloud_storage = ConfigCacheService::get('pixelfed.cloud_storage');
+        $cloud_disk = config('filesystems.cloud');
+        $cloud_ready = ! empty(config('filesystems.disks.'.$cloud_disk.'.key')) && ! empty(config('filesystems.disks.'.$cloud_disk.'.secret'));
+        $types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
+        $rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : null;
+        $jpeg = in_array('image/jpg', $types) || in_array('image/jpeg', $types);
+        $png = in_array('image/png', $types);
+        $gif = in_array('image/gif', $types);
+        $mp4 = in_array('video/mp4', $types);
+        $webp = in_array('image/webp', $types);
+
+        $availableAdmins = User::whereIsAdmin(true)->get();
+        $currentAdmin = config_cache('instance.admin.pid') ? AccountService::get(config_cache('instance.admin.pid'), true) : null;
+        $openReg = (bool) config_cache('pixelfed.open_registration');
+        $curOnboarding = (bool) config_cache('instance.curated_registration.enabled');
+        $regState = $openReg ? 'open' : ($curOnboarding ? 'filtered' : 'closed');
+        $accountMigration = (bool) config_cache('federation.migration');
+
+        return view('admin.settings.home', compact(
+            'jpeg',
+            'png',
+            'gif',
+            'mp4',
+            'webp',
+            'rules',
+            'cloud_storage',
+            'cloud_disk',
+            'cloud_ready',
+            'availableAdmins',
+            'currentAdmin',
+            'regState',
+            'accountMigration'
+        ));
+    }
+
+    public function settingsHomeStore(Request $request)
+    {
+        $this->validate($request, [
+            'name' => 'nullable|string',
+            'short_description' => 'nullable',
+            'long_description' => 'nullable',
+            'max_photo_size' => 'nullable|integer|min:1',
+            'max_album_length' => 'nullable|integer|min:1|max:100',
+            'image_quality' => 'nullable|integer|min:1|max:100',
+            'type_jpeg' => 'nullable',
+            'type_png' => 'nullable',
+            'type_gif' => 'nullable',
+            'type_mp4' => 'nullable',
+            'type_webp' => 'nullable',
+            'admin_account_id' => 'nullable',
+            'regs' => 'required|in:open,filtered,closed',
+            'account_migration' => 'nullable',
+            'rule_delete' => 'sometimes',
+        ]);
+
+        $orb = false;
+        $cob = false;
+        switch ($request->input('regs')) {
+            case 'open':
+                $orb = true;
+                $cob = false;
+                break;
+
+            case 'filtered':
+                $orb = false;
+                $cob = true;
+                break;
+
+            case 'closed':
+                $orb = false;
+                $cob = false;
+                break;
+        }
+
+        ConfigCacheService::put('pixelfed.open_registration', (bool) $orb);
+        ConfigCacheService::put('instance.curated_registration.enabled', (bool) $cob);
+
+        if ($request->filled('admin_account_id')) {
+            ConfigCacheService::put('instance.admin.pid', $request->admin_account_id);
+            Cache::forget('api:v1:instance-data:contact');
+            Cache::forget('api:v1:instance-data-response-v1');
+        }
+        if ($request->filled('rule_delete')) {
+            $index = (int) $request->input('rule_delete');
+            $rules = ConfigCacheService::get('app.rules');
+            $json = json_decode($rules, true);
+            if (! $rules || empty($json)) {
+                return;
+            }
+            unset($json[$index]);
+            $json = json_encode(array_values($json));
+            ConfigCacheService::put('app.rules', $json);
+            Cache::forget('api:v1:instance-data:rules');
+            Cache::forget('api:v1:instance-data-response-v1');
+
+            return 200;
+        }
+
+        $media_types = explode(',', config_cache('pixelfed.media_types'));
+        $media_types_original = $media_types;
+
+        $mimes = [
+            'type_jpeg' => 'image/jpeg',
+            'type_png' => 'image/png',
+            'type_gif' => 'image/gif',
+            'type_mp4' => 'video/mp4',
+            'type_webp' => 'image/webp',
+        ];
+
+        foreach ($mimes as $key => $value) {
+            if ($request->input($key) == 'on') {
+                if (! in_array($value, $media_types)) {
+                    array_push($media_types, $value);
+                }
+            } else {
+                $media_types = array_diff($media_types, [$value]);
+            }
+        }
+
+        if ($media_types !== $media_types_original) {
+            ConfigCacheService::put('pixelfed.media_types', implode(',', array_unique($media_types)));
+        }
+
+        $keys = [
+            'name' => 'app.name',
+            'short_description' => 'app.short_description',
+            'long_description' => 'app.description',
+            'max_photo_size' => 'pixelfed.max_photo_size',
+            'max_album_length' => 'pixelfed.max_album_length',
+            'image_quality' => 'pixelfed.image_quality',
+            'account_limit' => 'pixelfed.max_account_size',
+            'custom_css' => 'uikit.custom.css',
+            'custom_js' => 'uikit.custom.js',
+            'about_title' => 'about.title',
+        ];
+
+        foreach ($keys as $key => $value) {
+            $cc = ConfigCache::whereK($value)->first();
+            $val = $request->input($key);
+            if ($cc && $cc->v != $val) {
+                ConfigCacheService::put($value, $val);
+            } elseif (! empty($val)) {
+                ConfigCacheService::put($value, $val);
+            }
+        }
+
+        $bools = [
+            'activitypub' => 'federation.activitypub.enabled',
+            // 'open_registration' => 'pixelfed.open_registration',
+            'mobile_apis' => 'pixelfed.oauth_enabled',
+            'stories' => 'instance.stories.enabled',
+            'ig_import' => 'pixelfed.import.instagram.enabled',
+            'spam_detection' => 'pixelfed.bouncer.enabled',
+            'require_email_verification' => 'pixelfed.enforce_email_verification',
+            'enforce_account_limit' => 'pixelfed.enforce_account_limit',
+            'show_custom_css' => 'uikit.show_custom.css',
+            'show_custom_js' => 'uikit.show_custom.js',
+            'cloud_storage' => 'pixelfed.cloud_storage',
+            'account_autofollow' => 'account.autofollow',
+            'show_directory' => 'instance.landing.show_directory',
+            'show_explore_feed' => 'instance.landing.show_explore',
+            'account_migration' => 'federation.migration',
+        ];
+
+        foreach ($bools as $key => $value) {
+            $active = $request->input($key) == 'on';
+
+            if ($key == 'activitypub' && $active && ! InstanceActor::exists()) {
+                Artisan::call('instance:actor');
+            }
+
+            if ($key == 'mobile_apis' &&
+                $active &&
+                ! file_exists(storage_path('oauth-public.key')) &&
+                ! config_cache('passport.public_key') &&
+                ! file_exists(storage_path('oauth-private.key')) &&
+                ! config_cache('passport.private_key')
+            ) {
+                Artisan::call('passport:keys');
+                Artisan::call('route:cache');
+            }
+
+            if (config_cache($value) !== $active) {
+                ConfigCacheService::put($value, (bool) $active);
+            }
+        }
+
+        if ($request->filled('new_rule')) {
+            $rules = ConfigCacheService::get('app.rules');
+            $val = $request->input('new_rule');
+            if (! $rules) {
+                ConfigCacheService::put('app.rules', json_encode([$val]));
+            } else {
+                $json = json_decode($rules, true);
+                $json[] = $val;
+                ConfigCacheService::put('app.rules', json_encode(array_values($json)));
+            }
+            Cache::forget('api:v1:instance-data:rules');
+            Cache::forget('api:v1:instance-data-response-v1');
+        }
+
+        if ($request->filled('account_autofollow_usernames')) {
+            $usernames = explode(',', $request->input('account_autofollow_usernames'));
+            $names = [];
+
+            foreach ($usernames as $n) {
+                $p = Profile::whereUsername($n)->first();
+                if (! $p) {
+                    continue;
+                }
+                array_push($names, $p->username);
+            }
+
+            ConfigCacheService::put('account.autofollow_usernames', implode(',', $names));
+        }
+
+        Cache::forget(Config::CACHE_KEY);
+
+        return redirect('/i/admin/settings')->with('status', 'Successfully updated settings!');
+    }
+
+    public function settingsBackups(Request $request)
+    {
+        $path = storage_path('app/'.config('app.name'));
+        $files = is_dir($path) ? new \DirectoryIterator($path) : [];
+
+        return view('admin.settings.backups', compact('files'));
+    }
+
+    public function settingsMaintenance(Request $request)
+    {
+        return view('admin.settings.maintenance');
+    }
+
+    public function settingsStorage(Request $request)
+    {
+        $storage = [];
+
+        return view('admin.settings.storage', compact('storage'));
+    }
+
+    public function settingsFeatures(Request $request)
+    {
+        return view('admin.settings.features');
+    }
+
+    public function settingsPages(Request $request)
+    {
+        $pages = Page::orderByDesc('updated_at')->paginate(10);
+
+        return view('admin.pages.home', compact('pages'));
+    }
+
+    public function settingsPageEdit(Request $request)
+    {
+        return view('admin.pages.edit');
+    }
+
+    public function settingsSystem(Request $request)
+    {
+        $sys = [
+            'pixelfed' => config('pixelfed.version'),
+            'php' => phpversion(),
+            'laravel' => app()->version(),
+        ];
+        switch (config('database.default')) {
+            case 'pgsql':
+                $exp = DB::raw('select version();');
+                $expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
+                $sys['database'] = [
+                    'name' => 'Postgres',
+                    'version' => explode(' ', DB::select($expQuery)[0]->version)[1],
+                ];
+                break;
+
+            case 'mysql':
+                $exp = DB::raw('select version()');
+                $expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
+                $sys['database'] = [
+                    'name' => 'MySQL',
+                    'version' => DB::select($expQuery)[0]->{'version()'},
+                ];
+                break;
+
+            default:
+                $sys['database'] = [
+                    'name' => 'Unknown',
+                    'version' => '?',
+                ];
+                break;
+        }
+
+        return view('admin.settings.system', compact('sys'));
+    }
+
+    public function settingsApiFetch(Request $request)
+    {
+        $cloud_storage = ConfigCacheService::get('pixelfed.cloud_storage');
+        $cloud_disk = config('filesystems.cloud');
+        $cloud_ready = ! empty(config('filesystems.disks.'.$cloud_disk.'.key')) && ! empty(config('filesystems.disks.'.$cloud_disk.'.secret'));
+        $types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
+        $rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : [];
+        $jpeg = in_array('image/jpg', $types) || in_array('image/jpeg', $types);
+        $png = in_array('image/png', $types);
+        $gif = in_array('image/gif', $types);
+        $mp4 = in_array('video/mp4', $types);
+        $webp = in_array('image/webp', $types);
+
+        $availableAdmins = User::whereIsAdmin(true)->get();
+        $currentAdmin = config_cache('instance.admin.pid') ? AccountService::get(config_cache('instance.admin.pid'), true) : null;
+        $openReg = (bool) config_cache('pixelfed.open_registration');
+        $curOnboarding = (bool) config_cache('instance.curated_registration.enabled');
+        $regState = $openReg ? 'open' : ($curOnboarding ? 'filtered' : 'closed');
+        $accountMigration = (bool) config_cache('federation.migration');
+        $autoFollow = config_cache('account.autofollow_usernames');
+        if (strlen($autoFollow) > 3) {
+            $autoFollow = explode(',', $autoFollow);
+        }
+
+        $res = AdminSettingsService::getAll();
+
+        return response()->json($res);
+    }
+
+    public function settingsApiRulesAdd(Request $request)
+    {
+        $this->validate($request, [
+            'rule' => 'required|string|min:5|max:1000',
+        ]);
+
+        $rules = ConfigCacheService::get('app.rules');
+        $val = $request->input('rule');
+        if (! $rules) {
+            ConfigCacheService::put('app.rules', json_encode([$val]));
+        } else {
+            $json = json_decode($rules, true);
+            $count = count($json);
+            if ($count >= 30) {
+                return response()->json(['message' => 'Max rules limit reached, you can set up to 30 rules at a time.'], 400);
+            }
+            $json[] = $val;
+            ConfigCacheService::put('app.rules', json_encode(array_values($json)));
+        }
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return [$val];
+    }
+
+    public function settingsApiRulesDelete(Request $request)
+    {
+        $this->validate($request, [
+            'rule' => 'required|string',
+        ]);
+
+        $rules = ConfigCacheService::get('app.rules');
+        $val = $request->input('rule');
+
+        if (! $rules) {
+            return [];
+        } else {
+            $json = json_decode($rules, true);
+            $idx = array_search($val, $json);
+            if ($idx !== false) {
+                unset($json[$idx]);
+                $json = array_values($json);
+            }
+            ConfigCacheService::put('app.rules', json_encode(array_values($json)));
+        }
+
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return response()->json($json);
+    }
+
+    public function settingsApiRulesDeleteAll(Request $request)
+    {
+        $rules = ConfigCacheService::get('app.rules');
+
+        if (! $rules) {
+            return [];
+        } else {
+            ConfigCacheService::put('app.rules', json_encode([]));
+        }
+
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return response()->json([]);
+    }
+
+    public function settingsApiAutofollowDelete(Request $request)
+    {
+        $this->validate($request, [
+            'username' => 'required|string',
+        ]);
+
+        $username = $request->input('username');
+        $names = [];
+        $existing = config_cache('account.autofollow_usernames');
+        if ($existing) {
+            $names = explode(',', $existing);
+        }
+
+        if (in_array($username, $names)) {
+            $key = array_search($username, $names);
+            if ($key !== false) {
+                unset($names[$key]);
+            }
+        }
+        ConfigCacheService::put('account.autofollow_usernames', implode(',', $names));
+
+        return response()->json(['accounts' => array_values($names)]);
+    }
+
+    public function settingsApiAutofollowAdd(Request $request)
+    {
+        $this->validate($request, [
+            'username' => 'required|string',
+        ]);
+
+        $username = $request->input('username');
+        $names = [];
+        $existing = config_cache('account.autofollow_usernames');
+        if ($existing) {
+            $names = explode(',', $existing);
+        }
+
+        if ($existing && count($names)) {
+            if (count($names) >= 5) {
+                return response()->json(['message' => 'You can only add up to 5 accounts to be autofollowed.'], 400);
+            }
+            if (in_array(strtolower($username), array_map('strtolower', $names))) {
+                return response()->json(['message' => 'User already exists, please try again.'], 400);
+            }
+        }
+
+        $p = User::whereUsername($username)->whereNull('status')->first();
+        if (! $p || in_array($p->username, $names)) {
+            abort(404);
+        }
+        array_push($names, $p->username);
+        ConfigCacheService::put('account.autofollow_usernames', implode(',', $names));
+
+        return response()->json(['accounts' => array_values($names)]);
+    }
+
+    public function settingsApiUpdateType(Request $request, $type)
+    {
+        abort_unless(in_array($type, [
+            'posts',
+            'platform',
+            'home',
+            'landing',
+            'branding',
+            'media',
+            'users',
+            'storage',
+        ]), 400);
+
+        switch ($type) {
+            case 'home':
+                return $this->settingsApiUpdateHomeType($request);
+                break;
+
+            case 'landing':
+                return $this->settingsApiUpdateLandingType($request);
+                break;
+
+            case 'posts':
+                return $this->settingsApiUpdatePostsType($request);
+                break;
+
+            case 'platform':
+                return $this->settingsApiUpdatePlatformType($request);
+                break;
+
+            case 'branding':
+                return $this->settingsApiUpdateBrandingType($request);
+                break;
+
+            case 'media':
+                return $this->settingsApiUpdateMediaType($request);
+                break;
+
+            case 'users':
+                return $this->settingsApiUpdateUsersType($request);
+                break;
+
+            case 'storage':
+                return $this->settingsApiUpdateStorageType($request);
+                break;
+
+            default:
+                abort(404);
+                break;
+        }
+    }
+
+    public function settingsApiUpdateHomeType($request)
+    {
+        $this->validate($request, [
+            'registration_status' => 'required|in:open,filtered,closed',
+            'cloud_storage' => 'required',
+            'activitypub_enabled' => 'required',
+            'authorized_fetch' => 'required',
+            'account_migration' => 'required',
+            'mobile_apis' => 'required',
+            'stories' => 'required',
+            'instagram_import' => 'required',
+            'autospam_enabled' => 'required',
+        ]);
+
+        $regStatus = $request->input('registration_status');
+        ConfigCacheService::put('pixelfed.open_registration', $regStatus === 'open');
+        ConfigCacheService::put('instance.curated_registration.enabled', $regStatus === 'filtered');
+        $cloudStorage = $request->boolean('cloud_storage');
+        if ($cloudStorage !== (bool) config_cache('pixelfed.cloud_storage')) {
+            if (! $cloudStorage) {
+                ConfigCacheService::put('pixelfed.cloud_storage', false);
+            } else {
+                $cloud_disk = config('filesystems.cloud');
+                $cloud_ready = ! empty(config('filesystems.disks.'.$cloud_disk.'.key')) && ! empty(config('filesystems.disks.'.$cloud_disk.'.secret'));
+                if (! $cloud_ready) {
+                    return redirect()->back()->withErrors(['cloud_storage' => 'Must configure cloud storage before enabling!']);
+                } else {
+                    ConfigCacheService::put('pixelfed.cloud_storage', true);
+                }
+            }
+        }
+        ConfigCacheService::put('federation.activitypub.authorized_fetch', $request->boolean('authorized_fetch'));
+        ConfigCacheService::put('federation.activitypub.enabled', $request->boolean('activitypub_enabled'));
+        ConfigCacheService::put('federation.migration', $request->boolean('account_migration'));
+        ConfigCacheService::put('pixelfed.oauth_enabled', $request->boolean('mobile_apis'));
+        ConfigCacheService::put('instance.stories.enabled', $request->boolean('stories'));
+        ConfigCacheService::put('pixelfed.import.instagram.enabled', $request->boolean('instagram_import'));
+        ConfigCacheService::put('pixelfed.bouncer.enabled', $request->boolean('autospam_enabled'));
+
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Cache::forget('api:v1:instance-data:contact');
+        Config::refresh();
+
+        return $request->all();
+    }
+
+    public function settingsApiUpdateLandingType($request)
+    {
+        $this->validate($request, [
+            'current_admin' => 'required',
+            'show_directory' => 'required',
+            'show_explore' => 'required',
+        ]);
+
+        ConfigCacheService::put('instance.admin.pid', $request->input('current_admin'));
+        ConfigCacheService::put('instance.landing.show_directory', $request->boolean('show_directory'));
+        ConfigCacheService::put('instance.landing.show_explore', $request->boolean('show_explore'));
+
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Cache::forget('api:v1:instance-data:contact');
+        Config::refresh();
+
+        return $request->all();
+    }
+
+    public function settingsApiUpdateMediaType($request)
+    {
+        $this->validate($request, [
+            'image_quality' => 'required|integer|min:1|max:100',
+            'max_album_length' => 'required|integer|min:1|max:20',
+            'max_photo_size' => 'required|integer|min:100|max:50000',
+            'media_types' => 'required',
+            'optimize_image' => 'required',
+            'optimize_video' => 'required',
+        ]);
+
+        $mediaTypes = $request->input('media_types');
+        $mediaArray = explode(',', $mediaTypes);
+        foreach ($mediaArray as $mediaType) {
+            if (! in_array($mediaType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4'])) {
+                return redirect()->back()->withErrors(['media_types' => 'Invalid media type']);
+            }
+        }
+
+        ConfigCacheService::put('pixelfed.media_types', $request->input('media_types'));
+        ConfigCacheService::put('pixelfed.image_quality', $request->input('image_quality'));
+        ConfigCacheService::put('pixelfed.max_album_length', $request->input('max_album_length'));
+        ConfigCacheService::put('pixelfed.max_photo_size', $request->input('max_photo_size'));
+        ConfigCacheService::put('pixelfed.optimize_image', $request->boolean('optimize_image'));
+        ConfigCacheService::put('pixelfed.optimize_video', $request->boolean('optimize_video'));
+
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Cache::forget('api:v1:instance-data:contact');
+        Config::refresh();
+
+        return $request->all();
+    }
+
+    public function settingsApiUpdateBrandingType($request)
+    {
+        $this->validate($request, [
+            'name' => 'required',
+            'short_description' => 'required',
+            'long_description' => 'required',
+        ]);
+
+        ConfigCacheService::put('app.name', $request->input('name'));
+        ConfigCacheService::put('app.short_description', $request->input('short_description'));
+        ConfigCacheService::put('app.description', $request->input('long_description'));
+
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Cache::forget('api:v1:instance-data:contact');
+        Config::refresh();
+
+        return $request->all();
+    }
+
+    public function settingsApiUpdatePostsType($request)
+    {
+        $this->validate($request, [
+            'max_caption_length' => 'required|integer|min:5|max:10000',
+            'max_altext_length' => 'required|integer|min:5|max:40000',
+        ]);
+
+        ConfigCacheService::put('pixelfed.max_caption_length', $request->input('max_caption_length'));
+        ConfigCacheService::put('pixelfed.max_altext_length', $request->input('max_altext_length'));
+        $res = [
+            'max_caption_length' => $request->input('max_caption_length'),
+            'max_altext_length' => $request->input('max_altext_length'),
+        ];
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return $res;
+    }
+
+    public function settingsApiUpdatePlatformType($request)
+    {
+        $this->validate($request, [
+            'allow_app_registration' => 'required',
+            'app_registration_rate_limit_attempts' => 'required|integer|min:1',
+            'app_registration_rate_limit_decay' => 'required|integer|min:1',
+            'app_registration_confirm_rate_limit_attempts' => 'required|integer|min:1',
+            'app_registration_confirm_rate_limit_decay' => 'required|integer|min:1',
+            'allow_post_embeds' => 'required',
+            'allow_profile_embeds' => 'required',
+            'captcha_enabled' => 'required',
+            'captcha_on_login' => 'required_if_accepted:captcha_enabled',
+            'captcha_on_register' => 'required_if_accepted:captcha_enabled',
+            'captcha_secret' => 'required_if_accepted:captcha_enabled',
+            'captcha_sitekey' => 'required_if_accepted:captcha_enabled',
+            'custom_emoji_enabled' => 'required',
+        ]);
+
+        ConfigCacheService::put('pixelfed.allow_app_registration', $request->boolean('allow_app_registration'));
+        ConfigCacheService::put('pixelfed.app_registration_rate_limit_attempts', $request->input('app_registration_rate_limit_attempts'));
+        ConfigCacheService::put('pixelfed.app_registration_rate_limit_decay', $request->input('app_registration_rate_limit_decay'));
+        ConfigCacheService::put('pixelfed.app_registration_confirm_rate_limit_attempts', $request->input('app_registration_confirm_rate_limit_attempts'));
+        ConfigCacheService::put('pixelfed.app_registration_confirm_rate_limit_decay', $request->input('app_registration_confirm_rate_limit_decay'));
+        ConfigCacheService::put('instance.embed.post', $request->boolean('allow_post_embeds'));
+        ConfigCacheService::put('instance.embed.profile', $request->boolean('allow_profile_embeds'));
+        ConfigCacheService::put('federation.custom_emoji.enabled', $request->boolean('custom_emoji_enabled'));
+        $captcha = $request->boolean('captcha_enabled');
+        if ($captcha) {
+            $secret = $request->input('captcha_secret');
+            $sitekey = $request->input('captcha_sitekey');
+            if (config_cache('captcha.secret') != $secret && strpos($secret, '*') === false) {
+                ConfigCacheService::put('captcha.secret', $secret);
+            }
+            if (config_cache('captcha.sitekey') != $sitekey && strpos($sitekey, '*') === false) {
+                ConfigCacheService::put('captcha.sitekey', $sitekey);
+            }
+            ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login'));
+            ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register'));
+            ConfigCacheService::put('captcha.triggers.login.enabled', $request->boolean('captcha_on_login'));
+            ConfigCacheService::put('captcha.enabled', true);
+        } else {
+            ConfigCacheService::put('captcha.enabled', false);
+        }
+        $res = [
+            'allow_app_registration' => $request->boolean('allow_app_registration'),
+            'app_registration_rate_limit_attempts' => $request->input('app_registration_rate_limit_attempts'),
+            'app_registration_rate_limit_decay' => $request->input('app_registration_rate_limit_decay'),
+            'app_registration_confirm_rate_limit_attempts' => $request->input('app_registration_confirm_rate_limit_attempts'),
+            'app_registration_confirm_rate_limit_decay' => $request->input('app_registration_confirm_rate_limit_decay'),
+            'allow_post_embeds' => $request->boolean('allow_post_embeds'),
+            'allow_profile_embeds' => $request->boolean('allow_profile_embeds'),
+            'captcha_enabled' => $request->boolean('captcha_enabled'),
+            'captcha_on_login' => $request->boolean('captcha_on_login'),
+            'captcha_on_register' => $request->boolean('captcha_on_register'),
+            'captcha_secret' => $request->input('captcha_secret'),
+            'captcha_sitekey' => $request->input('captcha_sitekey'),
+            'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'),
+        ];
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return $res;
+    }
+
+    public function settingsApiUpdateUsersType($request)
+    {
+        $this->validate($request, [
+            'require_email_verification' => 'required',
+            'enforce_account_limit' => 'required',
+            'max_account_size' => 'required|integer|min:50000',
+            'admin_autofollow' => 'required',
+            'admin_autofollow_accounts' => 'sometimes',
+            'max_user_blocks' => 'required|integer|min:0|max:5000',
+            'max_user_mutes' => 'required|integer|min:0|max:5000',
+            'max_domain_blocks' => 'required|integer|min:0|max:5000',
+        ]);
+
+        $adminAutofollow = $request->boolean('admin_autofollow');
+        $adminAutofollowAccounts = $request->input('admin_autofollow_accounts');
+        if ($adminAutofollow) {
+            if ($request->filled('admin_autofollow_accounts')) {
+                $names = [];
+                $existing = config_cache('account.autofollow_usernames');
+                if ($existing) {
+                    $names = explode(',', $existing);
+                    foreach (array_map('strtolower', $adminAutofollowAccounts) as $afc) {
+                        if (in_array(strtolower($afc), array_map('strtolower', $names))) {
+                            continue;
+                        }
+                        $names[] = $afc;
+                    }
+                } else {
+                    $names = $adminAutofollowAccounts;
+                }
+                if (! $names || count($names) == 0) {
+                    return response()->json(['message' => 'You need to assign autofollow accounts before you can enable it.'], 400);
+                }
+                if (count($names) > 5) {
+                    return response()->json(['message' => 'You can only add up to 5 accounts to be autofollowed.'.json_encode($names)], 400);
+                }
+                $autofollows = User::whereIn('username', $names)->whereNull('status')->pluck('username');
+                $adminAutofollowAccounts = $autofollows->implode(',');
+                ConfigCacheService::put('account.autofollow_usernames', $adminAutofollowAccounts);
+            } else {
+                return response()->json(['message' => 'You need to assign autofollow accounts before you can enable it.'], 400);
+            }
+        }
+
+        ConfigCacheService::put('pixelfed.enforce_email_verification', $request->boolean('require_email_verification'));
+        ConfigCacheService::put('pixelfed.enforce_account_limit', $request->boolean('enforce_account_limit'));
+        ConfigCacheService::put('pixelfed.max_account_size', $request->input('max_account_size'));
+        ConfigCacheService::put('account.autofollow', $request->boolean('admin_autofollow'));
+        ConfigCacheService::put('instance.user_filters.max_user_blocks', (int) $request->input('max_user_blocks'));
+        ConfigCacheService::put('instance.user_filters.max_user_mutes', (int) $request->input('max_user_mutes'));
+        ConfigCacheService::put('instance.user_filters.max_domain_blocks', (int) $request->input('max_domain_blocks'));
+        $res = [
+            'require_email_verification' => $request->boolean('require_email_verification'),
+            'enforce_account_limit' => $request->boolean('enforce_account_limit'),
+            'admin_autofollow' => $request->boolean('admin_autofollow'),
+            'admin_autofollow_accounts' => $adminAutofollowAccounts,
+            'max_user_blocks' => $request->input('max_user_blocks'),
+            'max_user_mutes' => $request->input('max_user_mutes'),
+            'max_domain_blocks' => $request->input('max_domain_blocks'),
+        ];
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return $res;
+    }
+
+    public function settingsApiUpdateStorageType($request)
+    {
+        $this->validate($request, [
+            'primary_disk' => 'required|in:local,cloud',
+            'update_disk' => 'sometimes',
+            'disk_config' => 'required_if_accepted:update_disk',
+            'disk_config.driver' => 'required|in:s3,spaces',
+            'disk_config.key' => 'required',
+            'disk_config.secret' => 'required',
+            'disk_config.region' => 'required',
+            'disk_config.bucket' => 'required',
+            'disk_config.visibility' => 'required',
+            'disk_config.endpoint' => 'required',
+            'disk_config.url' => 'nullable',
+        ]);
+
+        ConfigCacheService::put('pixelfed.cloud_storage', $request->input('primary_disk') === 'cloud');
+        $res = [
+            'primary_disk' => $request->input('primary_disk'),
+        ];
+        if ($request->has('update_disk')) {
+            $res['disk_config'] = $request->input('disk_config');
+            $changes = [];
+            $dkey = $request->input('disk_config.driver') === 's3' ? 'filesystems.disks.s3.' : 'filesystems.disks.spaces.';
+            $key = $request->input('disk_config.key');
+            $ckey = null;
+            $secret = $request->input('disk_config.secret');
+            $csecret = null;
+            $region = $request->input('disk_config.region');
+            $bucket = $request->input('disk_config.bucket');
+            $visibility = $request->input('disk_config.visibility');
+            $url = $request->input('disk_config.url');
+            $endpoint = $request->input('disk_config.endpoint');
+            if (strpos($key, '*') === false && $key != config_cache($dkey.'key')) {
+                array_push($changes, 'key');
+            } else {
+                $ckey = config_cache($dkey.'key');
+            }
+            if (strpos($secret, '*') === false && $secret != config_cache($dkey.'secret')) {
+                array_push($changes, 'secret');
+            } else {
+                $csecret = config_cache($dkey.'secret');
+            }
+            if ($region != config_cache($dkey.'region')) {
+                array_push($changes, 'region');
+            }
+            if ($bucket != config_cache($dkey.'bucket')) {
+                array_push($changes, 'bucket');
+            }
+            if ($visibility != config_cache($dkey.'visibility')) {
+                array_push($changes, 'visibility');
+            }
+            if ($url != config_cache($dkey.'url')) {
+                array_push($changes, 'url');
+            }
+            if ($endpoint != config_cache($dkey.'endpoint')) {
+                array_push($changes, 'endpoint');
+            }
+
+            if ($changes && count($changes)) {
+                $isValid = FilesystemService::getVerifyCredentials(
+                    $ckey ?? $key,
+                    $csecret ?? $secret,
+                    $region,
+                    $bucket,
+                    $endpoint,
+                );
+                if (! $isValid) {
+                    return response()->json(['error' => true, 's3_vce' => true, 'message' => "<div class='border border-danger text-danger p-3 font-weight-bold rounded-lg'>The S3/Spaces credentials you provided are invalid, or the bucket does not have the proper permissions.</div><br/>Please check all fields and try again.<br/><br/><strong>Any cloud storage configuration changes you made have NOT been saved due to invalid credentials.</strong>"], 400);
+                }
+            }
+            $res['changes'] = json_encode($changes);
+        }
+        Cache::forget('api:v1:instance-data:rules');
+        Cache::forget('api:v1:instance-data-response-v1');
+        Cache::forget('api:v2:instance-data-response-v2');
+        Config::refresh();
+
+        return $res;
+    }
 }

+ 658 - 552
app/Http/Controllers/AdminController.php

@@ -2,562 +2,668 @@
 
 namespace App\Http\Controllers;
 
-use App\{
-	AccountInterstitial,
-	Contact,
-	Hashtag,
-	Instance,
-	Newsroom,
-	OauthClient,
-	Profile,
-	Report,
-	Status,
-	StatusHashtag,
-	Story,
-	User
-};
-use DB, Cache, Storage;
-use Carbon\Carbon;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Redis;
-use App\Http\Controllers\Admin\{
-	AdminAutospamController,
-	AdminDirectoryController,
-	AdminDiscoverController,
-	AdminHashtagsController,
-	AdminInstanceController,
-	AdminReportController,
-	// AdminGroupsController,
-	AdminMediaController,
-	AdminSettingsController,
-	// AdminStorageController,
-	AdminSupportController,
-	AdminUserController
-};
-use Illuminate\Validation\Rule;
-use App\Services\AdminStatsService;
+use App\Contact;
+use App\Http\Controllers\Admin\AdminAutospamController;
+use App\Http\Controllers\Admin\AdminDirectoryController;
+use App\Http\Controllers\Admin\AdminDiscoverController;
+use App\Http\Controllers\Admin\AdminHashtagsController;
+use App\Http\Controllers\Admin\AdminInstanceController;
+use App\Http\Controllers\Admin\AdminMediaController;
+use App\Http\Controllers\Admin\AdminReportController;
+use App\Http\Controllers\Admin\AdminSettingsController;
+use App\Http\Controllers\Admin\AdminUserController;
+use App\Instance;
+use App\Mail\AdminMessageResponse;
+use App\Models\CustomEmoji;
+use App\Newsroom;
+use App\OauthClient;
+use App\Profile;
 use App\Services\AccountService;
+use App\Services\AdminStatsService;
+use App\Services\ConfigCacheService;
 use App\Services\StatusService;
 use App\Services\StoryService;
-use App\Models\CustomEmoji;
+use App\Status;
+use App\Story;
+use App\User;
+use Cache;
+use DB;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use Mail;
+use Storage;
 
 class AdminController extends Controller
 {
-	use AdminReportController,
-	AdminAutospamController,
-	AdminDirectoryController,
-	AdminDiscoverController,
-	AdminHashtagsController,
-	// AdminGroupsController,
-	AdminMediaController,
-	AdminSettingsController,
-	AdminInstanceController,
-	// AdminStorageController,
-	AdminUserController;
-
-	public function __construct()
-	{
-		$this->middleware('admin');
-		$this->middleware('dangerzone');
-		$this->middleware('twofactor');
-	}
-
-	public function home()
-	{
-		return view('admin.home');
-	}
-
-	public function stats()
-	{
-		$data = AdminStatsService::get();
-		return view('admin.stats', compact('data'));
-	}
-
-	public function getStats()
-	{
-		return AdminStatsService::summary();
-	}
-
-	public function getAccounts()
-	{
-		$users = User::orderByDesc('id')->cursorPaginate(10);
-
-		$res = [
-			"next_page_url" => $users->nextPageUrl(),
-			"data" => $users->map(function($user) {
-				$account = AccountService::get($user->profile_id, true);
-				if(!$account) {
-					return [
-						"id" => $user->profile_id,
-						"username" => $user->username,
-						"status" => "deleted",
-						"avatar" => "/storage/avatars/default.jpg",
-						"created_at" => $user->created_at
-					];
-				}
-				$account['user_id'] = $user->id;
-				return $account;
-			})
-			->filter(function($user) {
-				return $user;
-			})
-		];
-		return $res;
-	}
-
-	public function getPosts()
-	{
-		$posts = DB::table('statuses')
-			->orderByDesc('id')
-			->cursorPaginate(10);
-
-		$res = [
-			"next_page_url" => $posts->nextPageUrl(),
-			"data" => $posts->map(function($post) {
-				$status = StatusService::get($post->id, false);
-				if(!$status) {
-					return ["id" => $post->id, "created_at" => $post->created_at];
-				}
-				return $status;
-			})
-		];
-
-		return $res;
-	}
-
-	public function getInstances()
-	{
-		return Instance::orderByDesc('id')->cursorPaginate(10);
-	}
-
-	public function statuses(Request $request)
-	{
-		$statuses = Status::orderBy('id', 'desc')->cursorPaginate(10);
-		$data = $statuses->map(function($status) {
-			return StatusService::get($status->id, false);
-		})
-		->filter(function($s) {
-			return $s;
-		})
-		->toArray();
-		return view('admin.statuses.home', compact('statuses', 'data'));
-	}
-
-	public function showStatus(Request $request, $id)
-	{
-		$status = Status::findOrFail($id);
-
-		return view('admin.statuses.show', compact('status'));
-	}
-
-	public function profiles(Request $request)
-	{
-		$this->validate($request, [
-			'search' => 'nullable|string|max:250',
-			'filter' => [
-				'nullable',
-				'string',
-				Rule::in(['all', 'local', 'remote'])
-			]
-		]);
-		$search = $request->input('search');
-		$filter = $request->input('filter');
-		$limit = 12;
-		$profiles = Profile::select('id','username')
-			->whereNull('status')
-			->when($search, function($q, $search) {
-				return $q->where('username', 'like', "%$search%");
-			})->when($filter, function($q, $filter) {
-				if($filter == 'local') {
-					return $q->whereNull('domain');
-				}
-				if($filter == 'remote') {
-					return $q->whereNotNull('domain');
-				}
-				return $q;
-			})->orderByDesc('id')
-			->simplePaginate($limit);
-
-		return view('admin.profiles.home', compact('profiles'));
-	}
-
-	public function profileShow(Request $request, $id)
-	{
-		$profile = Profile::findOrFail($id);
-		$user = $profile->user;
-		return view('admin.profiles.edit', compact('profile', 'user'));
-	}
-
-	public function appsHome(Request $request)
-	{
-		$filter = $request->input('filter');
-		if($filter == 'revoked') {
-			$apps = OauthClient::with('user')
-			->whereNotNull('user_id')
-			->whereRevoked(true)
-			->orderByDesc('id')
-			->paginate(10);
-		} else {
-			$apps = OauthClient::with('user')
-			->whereNotNull('user_id')
-			->orderByDesc('id')
-			->paginate(10);
-		}
-		return view('admin.apps.home', compact('apps'));
-	}
-
-	public function messagesHome(Request $request)
-	{
-		$messages = Contact::orderByDesc('id')->paginate(10);
-		return view('admin.messages.home', compact('messages'));
-	}
-
-	public function messagesShow(Request $request, $id)
-	{
-		$message = Contact::findOrFail($id);
-		return view('admin.messages.show', compact('message'));
-	}
-
-	public function messagesMarkRead(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required|integer|min:1'
-		]);
-		$id = $request->input('id');
-		$message = Contact::findOrFail($id);
-		if($message->read_at) {
-			return;
-		}
-		$message->read_at = now();
-		$message->save();
-		return;
-	}
-
-	public function newsroomHome(Request $request)
-	{
-		$newsroom = Newsroom::latest()->paginate(10);
-		return view('admin.newsroom.home', compact('newsroom'));
-	}
-
-	public function newsroomCreate(Request $request)
-	{
-		return view('admin.newsroom.create');
-	}
-
-	public function newsroomEdit(Request $request, $id)
-	{
-		$news = Newsroom::findOrFail($id);
-		return view('admin.newsroom.edit', compact('news'));
-	}
-
-	public function newsroomDelete(Request $request, $id)
-	{
-		$news = Newsroom::findOrFail($id);
-		$news->delete();
-		return redirect('/i/admin/newsroom');
-	}
-
-	public function newsroomUpdate(Request $request, $id)
-	{
-		$this->validate($request, [
-			'title' => 'required|string|min:1|max:100',
-			'summary' => 'nullable|string|max:200',
-			'body'  => 'nullable|string'
-		]);
-		$changed = false;
-		$changedFields = [];
-		$slug = str_slug($request->input('title'));
-		if(Newsroom::whereSlug($slug)->exists()) {
-			$slug = $slug . '-' . str_random(4);
-		}
-		$news = Newsroom::findOrFail($id);
-		$fields = [
-			'title' => 'string',
-			'summary' => 'string',
-			'body' => 'string',
-			'category' => 'string',
-			'show_timeline' => 'boolean',
-			'auth_only' => 'boolean',
-			'show_link' => 'boolean',
-			'force_modal' => 'boolean',
-			'published' => 'published'
-		];
-		foreach($fields as $field => $type) {
-			switch ($type) {
-				case 'string':
-				if($request->{$field} != $news->{$field}) {
-					if($field == 'title') {
-						$news->slug = $slug;
-					}
-					$news->{$field} = $request->{$field};
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-
-				case 'boolean':
-				$state = $request->{$field} == 'on' ? true : false;
-				if($state != $news->{$field}) {
-					$news->{$field} = $state;
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-				case 'published':
-				$state = $request->{$field} == 'on' ? true : false;
-				$published = $news->published_at != null;
-				if($state != $published) {
-					$news->published_at = $state ? now() : null;
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-
-			}
-		}
-
-		if($changed) {
-			$news->save();
-		}
-		$redirect = $news->published_at ? $news->permalink() : $news->editUrl();
-		return redirect($redirect);
-	}
-
-
-	public function newsroomStore(Request $request)
-	{
-		$this->validate($request, [
-			'title' => 'required|string|min:1|max:100',
-			'summary' => 'nullable|string|max:200',
-			'body'  => 'nullable|string'
-		]);
-		$changed = false;
-		$changedFields = [];
-		$slug = str_slug($request->input('title'));
-		if(Newsroom::whereSlug($slug)->exists()) {
-			$slug = $slug . '-' . str_random(4);
-		}
-		$news = new Newsroom();
-		$fields = [
-			'title' => 'string',
-			'summary' => 'string',
-			'body' => 'string',
-			'category' => 'string',
-			'show_timeline' => 'boolean',
-			'auth_only' => 'boolean',
-			'show_link' => 'boolean',
-			'force_modal' => 'boolean',
-			'published' => 'published'
-		];
-		foreach($fields as $field => $type) {
-			switch ($type) {
-				case 'string':
-				if($request->{$field} != $news->{$field}) {
-					if($field == 'title') {
-						$news->slug = $slug;
-					}
-					$news->{$field} = $request->{$field};
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-
-				case 'boolean':
-				$state = $request->{$field} == 'on' ? true : false;
-				if($state != $news->{$field}) {
-					$news->{$field} = $state;
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-				case 'published':
-				$state = $request->{$field} == 'on' ? true : false;
-				$published = $news->published_at != null;
-				if($state != $published) {
-					$news->published_at = $state ? now() : null;
-					$changed = true;
-					array_push($changedFields, $field);
-				}
-				break;
-
-			}
-		}
-
-		if($changed) {
-			$news->save();
-		}
-		$redirect = $news->published_at ? $news->permalink() : $news->editUrl();
-		return redirect($redirect);
-	}
-
-	public function diagnosticsHome(Request $request)
-	{
-		return view('admin.diagnostics.home');
-	}
-
-	public function diagnosticsDecrypt(Request $request)
-	{
-		$this->validate($request, [
-			'payload' => 'required'
-		]);
-
-		$key = 'exception_report:';
-		$decrypted = decrypt($request->input('payload'));
-
-		if(!starts_with($decrypted, $key)) {
-			abort(403, 'Can only decrypt error diagnostics');
-		}
-
-		$res = [
-			'decrypted' => substr($decrypted, strlen($key))
-		];
-
-		return response()->json($res);
-	}
-
-	public function stories(Request $request)
-	{
-		$stories = Story::with('profile')->latest()->paginate(10);
-		$stats = StoryService::adminStats();
-		return view('admin.stories.home', compact('stories', 'stats'));
-	}
-
-	public function customEmojiHome(Request $request)
-	{
-		if(!config('federation.custom_emoji.enabled')) {
-			return view('admin.custom-emoji.not-enabled');
-		}
-		$this->validate($request, [
-			'sort' => 'sometimes|in:all,local,remote,duplicates,disabled,search'
-		]);
-
-		if($request->has('cc')) {
-			Cache::forget('pf:admin:custom_emoji:stats');
-			Cache::forget('pf:custom_emoji');
-			return redirect(route('admin.custom-emoji'));
-		}
-
-		$sort = $request->input('sort') ?? 'all';
-
-		if($sort == 'search' && empty($request->input('q'))) {
-			return redirect(route('admin.custom-emoji'));
-		}
-
-		$pg = config('database.default') == 'pgsql';
-
-		$emojis = CustomEmoji::when($sort, function($query, $sort) use($request, $pg) {
-			if($sort == 'all') {
-				if($pg) {
-					return $query->latest();
-				} else {
-					return $query->groupBy('shortcode')->latest();
-				}
-			} else if($sort == 'local') {
-				return $query->latest()->where('domain', '=', config('pixelfed.domain.app'));
-			} else if($sort == 'remote') {
-				return $query->latest()->where('domain', '!=', config('pixelfed.domain.app'));
-			} else if($sort == 'duplicates') {
-				return $query->latest()->groupBy('shortcode')->havingRaw('count(*) > 1');
-			} else if($sort == 'disabled') {
-				return $query->latest()->whereDisabled(true);
-			} else if($sort == 'search') {
-				$q = $query
-					->latest()
-					->where('shortcode', 'like', '%' . $request->input('q') . '%')
-					->orWhere('domain', 'like', '%' . $request->input('q') . '%');
-				if(!$request->has('dups')) {
-					if(!$pg) {
-						$q = $q->groupBy('shortcode');
-					}
-				}
-				return $q;
-			}
-		})
-		->simplePaginate(10)
-		->withQueryString();
-
-		$stats = Cache::remember('pf:admin:custom_emoji:stats', 43200, function() use($pg) {
-			$res = [
-				'total' => CustomEmoji::count(),
-				'active' => CustomEmoji::whereDisabled(false)->count(),
-				'remote' => CustomEmoji::where('domain', '!=', config('pixelfed.domain.app'))->count(),
-			];
-
-			if($pg) {
-				$res['duplicate'] = CustomEmoji::select('shortcode')->groupBy('shortcode')->havingRaw('count(*) > 1')->count();
-			} else {
-				$res['duplicate'] = CustomEmoji::groupBy('shortcode')->havingRaw('count(*) > 1')->count();
-			}
-
-			return $res;
-		});
-
-		return view('admin.custom-emoji.home', compact('emojis', 'sort', 'stats'));
-	}
-
-	public function customEmojiToggleActive(Request $request, $id)
-	{
-		abort_unless(config('federation.custom_emoji.enabled'), 404);
-		$emoji = CustomEmoji::findOrFail($id);
-		$emoji->disabled = !$emoji->disabled;
-		$emoji->save();
-		$key = CustomEmoji::CACHE_KEY . str_replace(':', '', $emoji->shortcode);
-		Cache::forget($key);
-		return redirect()->back();
-	}
-
-	public function customEmojiAdd(Request $request)
-	{
-		abort_unless(config('federation.custom_emoji.enabled'), 404);
-		return view('admin.custom-emoji.add');
-	}
-
-	public function customEmojiStore(Request $request)
-	{
-		abort_unless(config('federation.custom_emoji.enabled'), 404);
-		$this->validate($request, [
-			'shortcode' => [
-				'required',
-				'min:3',
-				'max:80',
-				'starts_with::',
-				'ends_with::',
-				Rule::unique('custom_emoji')->where(function ($query) use($request) {
-					return $query->whereDomain(config('pixelfed.domain.app'))
-					->whereShortcode($request->input('shortcode'));
-				})
-			],
-			'emoji' => 'required|file|mimes:jpg,png|max:' . (config('federation.custom_emoji.max_size') / 1000)
-		]);
-
-		$emoji = new CustomEmoji;
-		$emoji->shortcode = $request->input('shortcode');
-		$emoji->domain = config('pixelfed.domain.app');
-		$emoji->save();
-
-		$fileName = $emoji->id . '.' . $request->emoji->extension();
-		$request->emoji->storePubliclyAs('public/emoji', $fileName);
-		$emoji->media_path = 'emoji/' . $fileName;
-		$emoji->save();
-		Cache::forget('pf:custom_emoji');
-		return redirect(route('admin.custom-emoji'));
-	}
-
-	public function customEmojiDelete(Request $request, $id)
-	{
-		abort_unless(config('federation.custom_emoji.enabled'), 404);
-		$emoji = CustomEmoji::findOrFail($id);
-		Storage::delete("public/{$emoji->media_path}");
-		Cache::forget('pf:custom_emoji');
-		$emoji->delete();
-		return redirect(route('admin.custom-emoji'));
-	}
-
-	public function customEmojiShowDuplicates(Request $request, $id)
-	{
-		abort_unless(config('federation.custom_emoji.enabled'), 404);
-		$emoji = CustomEmoji::orderBy('id')->whereDisabled(false)->whereShortcode($id)->firstOrFail();
-		$emojis = CustomEmoji::whereShortcode($id)->where('id', '!=', $emoji->id)->cursorPaginate(10);
-		return view('admin.custom-emoji.duplicates', compact('emoji', 'emojis'));
-	}
+    use AdminAutospamController,
+        AdminDirectoryController,
+        AdminDiscoverController,
+        AdminHashtagsController,
+        AdminInstanceController,
+        AdminMediaController,
+        AdminReportController,
+        AdminSettingsController,
+        AdminUserController;
+
+    public function __construct()
+    {
+        $this->middleware('admin');
+        $this->middleware('dangerzone');
+        $this->middleware('twofactor');
+    }
+
+    public function home()
+    {
+        return view('admin.home');
+    }
+
+    public function customCss()
+    {
+        return view('admin.settings.customcss');
+    }
+
+    public function saveCustomCss(Request $request)
+    {
+        $this->validate($request, [
+            'css' => 'sometimes|max:5000',
+            'show' => 'sometimes',
+        ]);
+        ConfigCacheService::put('uikit.custom.css', $request->input('css'));
+        ConfigCacheService::put('uikit.show_custom.css', $request->boolean('show'));
+
+        return view('admin.settings.customcss');
+    }
+
+    public function stats()
+    {
+        $data = AdminStatsService::get();
+
+        return view('admin.stats', compact('data'));
+    }
+
+    public function getStats()
+    {
+        return AdminStatsService::summary();
+    }
+
+    public function getAccounts()
+    {
+        $users = User::orderByDesc('id')->cursorPaginate(10);
+
+        $res = [
+            'next_page_url' => $users->nextPageUrl(),
+            'data' => $users->map(function ($user) {
+                $account = AccountService::get($user->profile_id, true);
+                if (! $account) {
+                    return [
+                        'id' => $user->profile_id,
+                        'username' => $user->username,
+                        'status' => 'deleted',
+                        'avatar' => '/storage/avatars/default.jpg',
+                        'created_at' => $user->created_at,
+                    ];
+                }
+                $account['user_id'] = $user->id;
+
+                return $account;
+            })
+                ->filter(function ($user) {
+                    return $user;
+                }),
+        ];
+
+        return $res;
+    }
+
+    public function getPosts()
+    {
+        $posts = DB::table('statuses')
+            ->orderByDesc('id')
+            ->cursorPaginate(10);
+
+        $res = [
+            'next_page_url' => $posts->nextPageUrl(),
+            'data' => $posts->map(function ($post) {
+                $status = StatusService::get($post->id, false);
+                if (! $status) {
+                    return ['id' => $post->id, 'created_at' => $post->created_at];
+                }
+
+                return $status;
+            }),
+        ];
+
+        return $res;
+    }
+
+    public function getInstances()
+    {
+        return Instance::orderByDesc('id')->cursorPaginate(10);
+    }
+
+    public function statuses(Request $request)
+    {
+        $statuses = Status::orderBy('id', 'desc')->cursorPaginate(10);
+        $data = $statuses->map(function ($status) {
+            return StatusService::get($status->id, false);
+        })
+            ->filter(function ($s) {
+                return $s;
+            })
+            ->toArray();
+
+        return view('admin.statuses.home', compact('statuses', 'data'));
+    }
+
+    public function showStatus(Request $request, $id)
+    {
+        $status = Status::findOrFail($id);
+
+        return view('admin.statuses.show', compact('status'));
+    }
+
+    public function profiles(Request $request)
+    {
+        $this->validate($request, [
+            'search' => 'nullable|string|max:250',
+            'filter' => [
+                'nullable',
+                'string',
+                Rule::in(['all', 'local', 'remote']),
+            ],
+        ]);
+        $search = $request->input('search');
+        $filter = $request->input('filter');
+        $limit = 12;
+        $profiles = Profile::select('id', 'username')
+            ->whereNull('status')
+            ->when($search, function ($q, $search) {
+                return $q->where('username', 'like', "%$search%");
+            })->when($filter, function ($q, $filter) {
+                if ($filter == 'local') {
+                    return $q->whereNull('domain');
+                }
+                if ($filter == 'remote') {
+                    return $q->whereNotNull('domain');
+                }
+
+                return $q;
+            })->orderByDesc('id')
+            ->simplePaginate($limit);
+
+        return view('admin.profiles.home', compact('profiles'));
+    }
+
+    public function profileShow(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $user = $profile->user;
+
+        return view('admin.profiles.edit', compact('profile', 'user'));
+    }
+
+    public function appsHome(Request $request)
+    {
+        $filter = $request->input('filter');
+        if ($filter == 'revoked') {
+            $apps = OauthClient::with('user')
+                ->whereNotNull('user_id')
+                ->whereRevoked(true)
+                ->orderByDesc('id')
+                ->paginate(10);
+        } else {
+            $apps = OauthClient::with('user')
+                ->whereNotNull('user_id')
+                ->orderByDesc('id')
+                ->paginate(10);
+        }
+
+        return view('admin.apps.home', compact('apps'));
+    }
+
+    public function messagesHome(Request $request)
+    {
+        $this->validate($request, [
+            'sort' => 'sometimes|string|in:all,open,closed',
+        ]);
+        $sort = $request->input('sort', 'open');
+
+        $messages = Contact::when($sort, function ($query, $sort) {
+            if ($sort === 'open') {
+                $query->whereNull('read_at');
+            }
+            if ($sort === 'closed') {
+                $query->whereNotNull('read_at');
+            }
+        })
+            ->orderByDesc('id')
+            ->paginate(10)
+            ->withQueryString();
+
+        return view('admin.messages.home', compact('messages', 'sort'));
+    }
+
+    public function messagesShow(Request $request, $id)
+    {
+        $message = Contact::findOrFail($id);
+        $user = User::whereNull('status')->find($message->user_id);
+        if(!$user) {
+            $message->read_at = now();
+            $message->save();
+            return redirect('/i/admin/messages/home')->with('status', 'Redirected from message sent from a deleted account');
+        }
+
+        return view('admin.messages.show', compact('message'));
+    }
+
+    public function messagesReply(Request $request, $id)
+    {
+        $this->validate($request, [
+            'message' => 'required|string|min:1|max:500',
+        ]);
+
+        if(config('mail.default') === 'log') {
+            return redirect('/i/admin/messages/home')->with('error', 'Mail driver not configured, please setup before you can sent email.');
+        }
+
+        $message = Contact::whereNull('responded_at')->findOrFail($id);
+        $user = User::whereNull('status')->find($message->user_id);
+        if(!$user) {
+            $message->read_at = now();
+            $message->save();
+            return redirect('/i/admin/messages/home')->with('status', 'Redirected from message sent from a deleted account');
+        }
+        $message->response = $request->input('message');
+        $message->read_at = now();
+        $message->responded_at = now();
+        $message->save();
+
+        Mail::to($message->user->email)->send(new AdminMessageResponse($message));
+
+        return redirect('/i/admin/messages/home')->with('status', 'Sent response to '.$message->user->username);
+    }
+
+    public function messagesReplyPreview(Request $request, $id)
+    {
+        $this->validate($request, [
+            'message' => 'required|string|min:1|max:500',
+        ]);
+
+        if(config('mail.default') === 'log') {
+            return redirect('/i/admin/messages/home')->with('error', 'Mail driver not configured, please setup before you can sent email.');
+        }
+
+        $message = Contact::whereNull('read_at')->findOrFail($id);
+        $user = User::whereNull('status')->find($message->user_id);
+        if(!$user) {
+            $message->read_at = now();
+            $message->save();
+            return redirect('/i/admin/messages/home')->with('error', 'Redirected from message sent from a deleted account');
+        }
+        return new AdminMessageResponse($message);
+    }
+
+    public function messagesMarkRead(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|integer|min:1',
+        ]);
+        $id = $request->input('id');
+        $message = Contact::findOrFail($id);
+
+        $user = User::whereNull('status')->find($message->user_id);
+        if(!$user) {
+            $message->read_at = now();
+            $message->save();
+            return redirect('/i/admin/messages/home')->with('error', 'Redirected from message sent from a deleted account');
+        }
+        if ($message->read_at) {
+            return;
+        }
+        $message->read_at = now();
+        $message->save();
+        $request->session()->flash('status', 'Marked response from '.$message->user->username.' as read!');
+
+        return ['status' => 200];
+    }
+
+    public function newsroomHome(Request $request)
+    {
+        $newsroom = Newsroom::latest()->paginate(10);
+
+        return view('admin.newsroom.home', compact('newsroom'));
+    }
+
+    public function newsroomCreate(Request $request)
+    {
+        return view('admin.newsroom.create');
+    }
+
+    public function newsroomEdit(Request $request, $id)
+    {
+        $news = Newsroom::findOrFail($id);
+
+        return view('admin.newsroom.edit', compact('news'));
+    }
+
+    public function newsroomDelete(Request $request, $id)
+    {
+        $news = Newsroom::findOrFail($id);
+        $news->delete();
+
+        return redirect('/i/admin/newsroom');
+    }
+
+    public function newsroomUpdate(Request $request, $id)
+    {
+        $this->validate($request, [
+            'title' => 'required|string|min:1|max:100',
+            'summary' => 'nullable|string|max:200',
+            'body' => 'nullable|string',
+        ]);
+        $changed = false;
+        $changedFields = [];
+        $slug = str_slug($request->input('title'));
+        if (Newsroom::whereSlug($slug)->exists()) {
+            $slug = $slug.'-'.str_random(4);
+        }
+        $news = Newsroom::findOrFail($id);
+        $fields = [
+            'title' => 'string',
+            'summary' => 'string',
+            'body' => 'string',
+            'category' => 'string',
+            'show_timeline' => 'boolean',
+            'auth_only' => 'boolean',
+            'show_link' => 'boolean',
+            'force_modal' => 'boolean',
+            'published' => 'published',
+        ];
+        foreach ($fields as $field => $type) {
+            switch ($type) {
+                case 'string':
+                    if ($request->{$field} != $news->{$field}) {
+                        if ($field == 'title') {
+                            $news->slug = $slug;
+                        }
+                        $news->{$field} = $request->{$field};
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+
+                case 'boolean':
+                    $state = $request->{$field} == 'on' ? true : false;
+                    if ($state != $news->{$field}) {
+                        $news->{$field} = $state;
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+                case 'published':
+                    $state = $request->{$field} == 'on' ? true : false;
+                    $published = $news->published_at != null;
+                    if ($state != $published) {
+                        $news->published_at = $state ? now() : null;
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+
+            }
+        }
+
+        if ($changed) {
+            $news->save();
+        }
+        $redirect = $news->published_at ? $news->permalink() : $news->editUrl();
+
+        return redirect($redirect);
+    }
+
+    public function newsroomStore(Request $request)
+    {
+        $this->validate($request, [
+            'title' => 'required|string|min:1|max:100',
+            'summary' => 'nullable|string|max:200',
+            'body' => 'nullable|string',
+        ]);
+        $changed = false;
+        $changedFields = [];
+        $slug = str_slug($request->input('title'));
+        if (Newsroom::whereSlug($slug)->exists()) {
+            $slug = $slug.'-'.str_random(4);
+        }
+        $news = new Newsroom;
+        $fields = [
+            'title' => 'string',
+            'summary' => 'string',
+            'body' => 'string',
+            'category' => 'string',
+            'show_timeline' => 'boolean',
+            'auth_only' => 'boolean',
+            'show_link' => 'boolean',
+            'force_modal' => 'boolean',
+            'published' => 'published',
+        ];
+        foreach ($fields as $field => $type) {
+            switch ($type) {
+                case 'string':
+                    if ($request->{$field} != $news->{$field}) {
+                        if ($field == 'title') {
+                            $news->slug = $slug;
+                        }
+                        $news->{$field} = $request->{$field};
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+
+                case 'boolean':
+                    $state = $request->{$field} == 'on' ? true : false;
+                    if ($state != $news->{$field}) {
+                        $news->{$field} = $state;
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+                case 'published':
+                    $state = $request->{$field} == 'on' ? true : false;
+                    $published = $news->published_at != null;
+                    if ($state != $published) {
+                        $news->published_at = $state ? now() : null;
+                        $changed = true;
+                        array_push($changedFields, $field);
+                    }
+                    break;
+
+            }
+        }
+
+        if ($changed) {
+            $news->save();
+        }
+        $redirect = $news->published_at ? $news->permalink() : $news->editUrl();
+
+        return redirect($redirect);
+    }
+
+    public function diagnosticsHome(Request $request)
+    {
+        return view('admin.diagnostics.home');
+    }
+
+    public function diagnosticsDecrypt(Request $request)
+    {
+        $this->validate($request, [
+            'payload' => 'required',
+        ]);
+
+        $key = 'exception_report:';
+        $decrypted = decrypt($request->input('payload'));
+
+        if (! starts_with($decrypted, $key)) {
+            abort(403, 'Can only decrypt error diagnostics');
+        }
+
+        $res = [
+            'decrypted' => substr($decrypted, strlen($key)),
+        ];
+
+        return response()->json($res);
+    }
+
+    public function stories(Request $request)
+    {
+        $stories = Story::with('profile')->latest()->paginate(10);
+        $stats = StoryService::adminStats();
+
+        return view('admin.stories.home', compact('stories', 'stats'));
+    }
+
+    public function customEmojiHome(Request $request)
+    {
+        if (! (bool) config_cache('federation.custom_emoji.enabled')) {
+            return view('admin.custom-emoji.not-enabled');
+        }
+        $this->validate($request, [
+            'sort' => 'sometimes|in:all,local,remote,duplicates,disabled,search',
+        ]);
+
+        if ($request->has('cc')) {
+            Cache::forget('pf:admin:custom_emoji:stats');
+            Cache::forget('pf:custom_emoji');
+
+            return redirect(route('admin.custom-emoji'));
+        }
+
+        $sort = $request->input('sort') ?? 'all';
+
+        if ($sort == 'search' && empty($request->input('q'))) {
+            return redirect(route('admin.custom-emoji'));
+        }
+
+        $pg = config('database.default') == 'pgsql';
+
+        $emojis = CustomEmoji::when($sort, function ($query, $sort) use ($request, $pg) {
+            if ($sort == 'all') {
+                if ($pg) {
+                    return $query->latest();
+                } else {
+                    return $query->groupBy('shortcode')->latest();
+                }
+            } elseif ($sort == 'local') {
+                return $query->latest()->where('domain', '=', config('pixelfed.domain.app'));
+            } elseif ($sort == 'remote') {
+                return $query->latest()->where('domain', '!=', config('pixelfed.domain.app'));
+            } elseif ($sort == 'duplicates') {
+                return $query->latest()->groupBy('shortcode')->havingRaw('count(*) > 1');
+            } elseif ($sort == 'disabled') {
+                return $query->latest()->whereDisabled(true);
+            } elseif ($sort == 'search') {
+                $q = $query
+                    ->latest()
+                    ->where('shortcode', 'like', '%'.$request->input('q').'%')
+                    ->orWhere('domain', 'like', '%'.$request->input('q').'%');
+                if (! $request->has('dups')) {
+                    if (! $pg) {
+                        $q = $q->groupBy('shortcode');
+                    }
+                }
+
+                return $q;
+            }
+        })
+            ->simplePaginate(10)
+            ->withQueryString();
+
+        $stats = Cache::remember('pf:admin:custom_emoji:stats', 43200, function () use ($pg) {
+            $res = [
+                'total' => CustomEmoji::count(),
+                'active' => CustomEmoji::whereDisabled(false)->count(),
+                'remote' => CustomEmoji::where('domain', '!=', config('pixelfed.domain.app'))->count(),
+            ];
+
+            if ($pg) {
+                $res['duplicate'] = CustomEmoji::select('shortcode')->groupBy('shortcode')->havingRaw('count(*) > 1')->count();
+            } else {
+                $res['duplicate'] = CustomEmoji::groupBy('shortcode')->havingRaw('count(*) > 1')->count();
+            }
+
+            return $res;
+        });
+
+        return view('admin.custom-emoji.home', compact('emojis', 'sort', 'stats'));
+    }
+
+    public function customEmojiToggleActive(Request $request, $id)
+    {
+        abort_unless((bool) config_cache('federation.custom_emoji.enabled'), 404);
+        $emoji = CustomEmoji::findOrFail($id);
+        $emoji->disabled = ! $emoji->disabled;
+        $emoji->save();
+        $key = CustomEmoji::CACHE_KEY.str_replace(':', '', $emoji->shortcode);
+        Cache::forget($key);
+
+        return redirect()->back();
+    }
+
+    public function customEmojiAdd(Request $request)
+    {
+        abort_unless((bool) config_cache('federation.custom_emoji.enabled'), 404);
+
+        return view('admin.custom-emoji.add');
+    }
+
+    public function customEmojiStore(Request $request)
+    {
+        abort_unless((bool) config_cache('federation.custom_emoji.enabled'), 404);
+        $this->validate($request, [
+            'shortcode' => [
+                'required',
+                'min:3',
+                'max:80',
+                'starts_with::',
+                'ends_with::',
+                Rule::unique('custom_emoji')->where(function ($query) use ($request) {
+                    return $query->whereDomain(config('pixelfed.domain.app'))
+                        ->whereShortcode($request->input('shortcode'));
+                }),
+            ],
+            'emoji' => 'required|file|mimes:jpg,png|max:'.(config('federation.custom_emoji.max_size') / 1000),
+        ]);
+
+        $emoji = new CustomEmoji;
+        $emoji->shortcode = $request->input('shortcode');
+        $emoji->domain = config('pixelfed.domain.app');
+        $emoji->save();
+
+        $fileName = $emoji->id.'.'.$request->emoji->extension();
+        $request->emoji->storePubliclyAs('public/emoji', $fileName);
+        $emoji->media_path = 'emoji/'.$fileName;
+        $emoji->save();
+        Cache::forget('pf:custom_emoji');
+
+        return redirect(route('admin.custom-emoji'));
+    }
+
+    public function customEmojiDelete(Request $request, $id)
+    {
+        abort_unless((bool) config_cache('federation.custom_emoji.enabled'), 404);
+        $emoji = CustomEmoji::findOrFail($id);
+        Storage::delete("public/{$emoji->media_path}");
+        Cache::forget('pf:custom_emoji');
+        $emoji->delete();
+
+        return redirect(route('admin.custom-emoji'));
+    }
+
+    public function customEmojiShowDuplicates(Request $request, $id)
+    {
+        abort_unless((bool) config_cache('federation.custom_emoji.enabled'), 404);
+        $emoji = CustomEmoji::orderBy('id')->whereDisabled(false)->whereShortcode($id)->firstOrFail();
+        $emojis = CustomEmoji::whereShortcode($id)->where('id', '!=', $emoji->id)->cursorPaginate(10);
+
+        return view('admin.custom-emoji.duplicates', compact('emoji', 'emojis'));
+    }
 }

+ 340 - 0
app/Http/Controllers/AdminCuratedRegisterController.php

@@ -0,0 +1,340 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Mail\CuratedRegisterAcceptUser;
+use App\Mail\CuratedRegisterRejectUser;
+use App\Mail\CuratedRegisterRequestDetailsFromUser;
+use App\Models\CuratedRegister;
+use App\Models\CuratedRegisterActivity;
+use App\Models\CuratedRegisterTemplate;
+use App\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+
+class AdminCuratedRegisterController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware(['auth', 'admin']);
+    }
+
+    public function index(Request $request)
+    {
+        $this->validate($request, [
+            'filter' => 'sometimes|in:open,all,awaiting,approved,rejected,responses',
+            'sort' => 'sometimes|in:asc,desc',
+        ]);
+        $filter = $request->input('filter', 'open');
+        $sort = $request->input('sort', 'asc');
+        $records = CuratedRegister::when($filter, function ($q, $filter) {
+            if ($filter === 'open') {
+                return $q->where('is_rejected', false)
+                    ->where(function ($query) {
+                        return $query->where('user_has_responded', true)->orWhere('is_awaiting_more_info', false);
+                    })
+                    ->whereNotNull('email_verified_at')
+                    ->whereIsClosed(false);
+            } elseif ($filter === 'all') {
+                return $q;
+            } elseif ($filter === 'responses') {
+                return $q->whereIsClosed(false)
+                    ->whereNotNull('email_verified_at')
+                    ->where('user_has_responded', true)
+                    ->where('is_awaiting_more_info', true);
+            } elseif ($filter === 'awaiting') {
+                return $q->whereIsClosed(false)
+                    ->where('is_rejected', false)
+                    ->where('is_approved', false)
+                    ->where('user_has_responded', false)
+                    ->where('is_awaiting_more_info', true);
+            } elseif ($filter === 'approved') {
+                return $q->whereIsClosed(true)->whereIsApproved(true);
+            } elseif ($filter === 'rejected') {
+                return $q->whereIsClosed(true)->whereIsRejected(true);
+            }
+        })
+            ->when($sort, function ($query, $sort) {
+                return $query->orderBy('id', $sort);
+            })
+            ->paginate(10)
+            ->withQueryString();
+
+        return view('admin.curated-register.index', compact('records', 'filter'));
+    }
+
+    public function show(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+
+        return view('admin.curated-register.show', compact('record'));
+    }
+
+    public function apiActivityLog(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+
+        $res = collect([
+            [
+                'id' => 1,
+                'action' => 'created',
+                'title' => 'Onboarding application created',
+                'message' => null,
+                'link' => null,
+                'timestamp' => $record->created_at,
+            ],
+        ]);
+
+        if ($record->email_verified_at) {
+            $res->push([
+                'id' => 3,
+                'action' => 'email_verified_at',
+                'title' => 'Applicant successfully verified email address',
+                'message' => null,
+                'link' => null,
+                'timestamp' => $record->email_verified_at,
+            ]);
+        }
+
+        $activities = CuratedRegisterActivity::whereRegisterId($record->id)->get();
+
+        $idx = 4;
+        $userResponses = collect([]);
+
+        foreach ($activities as $activity) {
+            $idx++;
+
+            if ($activity->type === 'user_resend_email_confirmation') {
+                continue;
+            }
+            if ($activity->from_user) {
+                $userResponses->push($activity);
+
+                continue;
+            }
+            $res->push([
+                'id' => $idx,
+                'aid' => $activity->id,
+                'action' => $activity->type,
+                'title' => $activity->from_admin ? 'Admin requested info' : 'User responded',
+                'message' => $activity->message,
+                'link' => $activity->adminReviewUrl(),
+                'timestamp' => $activity->created_at,
+            ]);
+        }
+
+        foreach ($userResponses as $ur) {
+            $res = $res->map(function ($r) use ($ur) {
+                if (! isset($r['aid'])) {
+                    return $r;
+                }
+                if ($ur->reply_to_id === $r['aid']) {
+                    $r['user_response'] = $ur;
+
+                    return $r;
+                }
+
+                return $r;
+            });
+        }
+
+        if ($record->is_approved) {
+            $idx++;
+            $res->push([
+                'id' => $idx,
+                'action' => 'approved',
+                'title' => 'Application Approved',
+                'message' => null,
+                'link' => null,
+                'timestamp' => $record->action_taken_at,
+            ]);
+        } elseif ($record->is_rejected) {
+            $idx++;
+            $res->push([
+                'id' => $idx,
+                'action' => 'rejected',
+                'title' => 'Application Rejected',
+                'message' => null,
+                'link' => null,
+                'timestamp' => $record->action_taken_at,
+            ]);
+        }
+
+        return $res->reverse()->values();
+    }
+
+    public function apiMessagePreviewStore(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+
+        return $request->all();
+    }
+
+    public function apiMessageSendStore(Request $request, $id)
+    {
+        $this->validate($request, [
+            'message' => 'required|string|min:5|max:3000',
+        ]);
+        $record = CuratedRegister::findOrFail($id);
+        abort_if($record->email_verified_at === null, 400, 'Cannot message an unverified email');
+        $activity = new CuratedRegisterActivity;
+        $activity->register_id = $record->id;
+        $activity->admin_id = $request->user()->id;
+        $activity->secret_code = Str::random(32);
+        $activity->type = 'request_details';
+        $activity->from_admin = true;
+        $activity->message = $request->input('message');
+        $activity->save();
+        $record->is_awaiting_more_info = true;
+        $record->user_has_responded = false;
+        $record->save();
+        Mail::to($record->email)->send(new CuratedRegisterRequestDetailsFromUser($record, $activity));
+
+        return $request->all();
+    }
+
+    public function previewDetailsMessageShow(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+        abort_if($record->email_verified_at === null, 400, 'Cannot message an unverified email');
+        $activity = new CuratedRegisterActivity;
+        $activity->message = $request->input('message');
+
+        return new \App\Mail\CuratedRegisterRequestDetailsFromUser($record, $activity);
+    }
+
+    public function previewMessageShow(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+        abort_if($record->email_verified_at === null, 400, 'Cannot message an unverified email');
+        $record->message = $request->input('message');
+
+        return new \App\Mail\CuratedRegisterSendMessage($record);
+    }
+
+    public function apiHandleReject(Request $request, $id)
+    {
+        $this->validate($request, [
+            'action' => 'required|in:reject-email,reject-silent',
+        ]);
+        $action = $request->input('action');
+        $record = CuratedRegister::findOrFail($id);
+        abort_if($record->email_verified_at === null, 400, 'Cannot reject an unverified email');
+        $record->is_rejected = true;
+        $record->is_closed = true;
+        $record->action_taken_at = now();
+        $record->save();
+        if ($action === 'reject-email') {
+            Mail::to($record->email)->send(new CuratedRegisterRejectUser($record));
+        }
+
+        return [200];
+    }
+
+    public function apiHandleApprove(Request $request, $id)
+    {
+        $record = CuratedRegister::findOrFail($id);
+        abort_if($record->email_verified_at === null, 400, 'Cannot reject an unverified email');
+        $record->is_approved = true;
+        $record->is_closed = true;
+        $record->action_taken_at = now();
+        $record->save();
+
+        if (User::withTrashed()->whereEmail($record->email)->exists()) {
+            return [200];
+        }
+
+        $user = User::create([
+            'name' => $record->username,
+            'username' => $record->username,
+            'email' => $record->email,
+            'password' => $record->password,
+            'app_register_ip' => $record->ip_address,
+            'email_verified_at' => now(),
+            'register_source' => 'cur_onboarding',
+        ]);
+
+        Mail::to($record->email)->send(new CuratedRegisterAcceptUser($record));
+
+        return [200];
+    }
+
+    public function templates(Request $request)
+    {
+        $templates = CuratedRegisterTemplate::paginate(10);
+
+        return view('admin.curated-register.templates', compact('templates'));
+    }
+
+    public function templateCreate(Request $request)
+    {
+        return view('admin.curated-register.template-create');
+    }
+
+    public function templateEdit(Request $request, $id)
+    {
+        $template = CuratedRegisterTemplate::findOrFail($id);
+
+        return view('admin.curated-register.template-edit', compact('template'));
+    }
+
+    public function templateEditStore(Request $request, $id)
+    {
+        $this->validate($request, [
+            'name' => 'required|string|max:30',
+            'content' => 'required|string|min:5|max:3000',
+            'description' => 'nullable|sometimes|string|max:1000',
+            'active' => 'sometimes',
+        ]);
+        $template = CuratedRegisterTemplate::findOrFail($id);
+        $template->name = $request->input('name');
+        $template->content = $request->input('content');
+        $template->description = $request->input('description');
+        $template->is_active = $request->boolean('active');
+        $template->save();
+
+        return redirect()->back()->with('status', 'Successfully updated template!');
+    }
+
+    public function templateDelete(Request $request, $id)
+    {
+        $template = CuratedRegisterTemplate::findOrFail($id);
+        $template->delete();
+
+        return redirect(route('admin.curated-onboarding.templates'))->with('status', 'Successfully deleted template!');
+    }
+
+    public function templateStore(Request $request)
+    {
+        $this->validate($request, [
+            'name' => 'required|string|max:30',
+            'content' => 'required|string|min:5|max:3000',
+            'description' => 'nullable|sometimes|string|max:1000',
+            'active' => 'sometimes',
+        ]);
+        CuratedRegisterTemplate::create([
+            'name' => $request->input('name'),
+            'content' => $request->input('content'),
+            'description' => $request->input('description'),
+            'is_active' => $request->boolean('active'),
+        ]);
+
+        return redirect(route('admin.curated-onboarding.templates'))->with('status', 'Successfully created new template!');
+    }
+
+    public function getActiveTemplates(Request $request)
+    {
+        $templates = CuratedRegisterTemplate::whereIsActive(true)
+            ->orderBy('order')
+            ->get()
+            ->map(function ($tmp) {
+                return [
+                    'name' => $tmp->name,
+                    'content' => $tmp->content,
+                ];
+            });
+
+        return response()->json($templates);
+    }
+}

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

@@ -19,7 +19,8 @@ class AdminShadowFilterController extends Controller
     {
         $filter = $request->input('filter');
         $searchQuery = $request->input('q');
-        $filters = AdminShadowFilter::when($filter, function($q, $filter) {
+        $filters = AdminShadowFilter::whereHas('profile')
+        ->when($filter, function($q, $filter) {
             if($filter == 'all') {
                 return $q;
             } else if($filter == 'inactive') {

+ 184 - 137
app/Http/Controllers/Api/AdminApiController.php

@@ -2,91 +2,94 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
+use App\AccountInterstitial;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\AdminInstance;
+use App\Http\Resources\AdminUser;
+use App\Instance;
+use App\Jobs\DeletePipeline\DeleteAccountPipeline;
+use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
 use App\Jobs\StatusPipeline\StatusDelete;
-use Auth, Cache, DB;
-use Carbon\Carbon;
-use App\{
-    AccountInterstitial,
-    Instance,
-    Like,
-    Notification,
-    Media,
-    Profile,
-    Report,
-    Status,
-    User
-};
 use App\Models\Conversation;
 use App\Models\RemoteReport;
+use App\Notification;
+use App\Profile;
+use App\Report;
 use App\Services\AccountService;
 use App\Services\AdminStatsService;
 use App\Services\ConfigCacheService;
 use App\Services\InstanceService;
 use App\Services\ModLogService;
-use App\Services\SnowflakeService;
-use App\Services\StatusService;
-use App\Services\PublicTimelineService;
 use App\Services\NetworkTimelineService;
 use App\Services\NotificationService;
-use App\Http\Resources\AdminInstance;
-use App\Http\Resources\AdminUser;
-use App\Jobs\DeletePipeline\DeleteAccountPipeline;
-use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
-use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
+use App\Services\PublicTimelineService;
+use App\Services\SnowflakeService;
+use App\Services\StatusService;
+use App\Status;
+use App\User;
+use Cache;
+use DB;
+use Illuminate\Http\Request;
 
 class AdminApiController extends Controller
 {
     public function supported(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         return response()->json(['supported' => true]);
     }
 
     public function getStats(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         $res = AdminStatsService::summary();
         $res['autospam_count'] = AccountInterstitial::whereType('post.autospam')
             ->whereNull('appeal_handled_at')
             ->count();
+
         return $res;
     }
 
     public function autospam(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         $appeals = AccountInterstitial::whereType('post.autospam')
             ->whereNull('appeal_handled_at')
             ->latest()
             ->simplePaginate(6)
-            ->map(function($report) {
+            ->map(function ($report) {
                 $r = [
                     'id' => $report->id,
                     'type' => $report->type,
                     'item_id' => $report->item_id,
                     'item_type' => $report->item_type,
-                    'created_at' => $report->created_at
+                    'created_at' => $report->created_at,
                 ];
-                if($report->item_type === 'App\\Status') {
+                if ($report->item_type === 'App\\Status') {
                     $status = StatusService::get($report->item_id, false);
-                    if(!$status) {
+                    if (! $status) {
                         return;
                     }
 
                     $r['status'] = $status;
 
-                    if($status['in_reply_to_id']) {
+                    if ($status['in_reply_to_id']) {
                         $r['parent'] = StatusService::get($status['in_reply_to_id'], false);
                     }
                 }
+
                 return $r;
             });
 
@@ -95,12 +98,14 @@ class AdminApiController extends Controller
 
     public function autospamHandle(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
             'action' => 'required|in:dismiss,approve,dismiss-all,approve-all,delete-post,delete-account',
-            'id' => 'required'
+            'id' => 'required',
         ]);
 
         $action = $request->input('action');
@@ -114,18 +119,19 @@ class AdminApiController extends Controller
         $user = $appeal->user;
         $profile = $user->profile;
 
-        if($action == 'dismiss') {
+        if ($action == 'dismiss') {
             $appeal->is_spam = true;
             $appeal->appeal_handled_at = $now;
             $appeal->save();
 
-            Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $profile->id);
-            Cache::forget('pf:bouncer_v0:recent_by_pid:' . $profile->id);
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$profile->id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$profile->id);
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
-        if($action == 'delete-post') {
+        if ($action == 'delete-post') {
             $appeal->appeal_handled_at = now();
             $appeal->is_spam = true;
             $appeal->save();
@@ -140,10 +146,11 @@ class AdminApiController extends Controller
             PublicTimelineService::deleteByProfileId($profile->id);
             StatusDelete::dispatch($appeal->status)->onQueue('high');
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
-        if($action == 'delete-account') {
+        if ($action == 'delete-account') {
             abort_if($user->is_admin, 400, 'Cannot delete an admin account.');
             $appeal->appeal_handled_at = now();
             $appeal->is_spam = true;
@@ -159,22 +166,24 @@ class AdminApiController extends Controller
             PublicTimelineService::deleteByProfileId($profile->id);
             DeleteAccountPipeline::dispatch($appeal->user)->onQueue('high');
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
-        if($action == 'dismiss-all') {
+        if ($action == 'dismiss-all') {
             AccountInterstitial::whereType('post.autospam')
                 ->whereItemType('App\Status')
                 ->whereNull('appeal_handled_at')
                 ->whereUserId($appeal->user_id)
                 ->update(['appeal_handled_at' => $now, 'is_spam' => true]);
-            Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-            Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
-        if($action == 'approve') {
+        if ($action == 'approve') {
             $status = $appeal->status;
             $status->is_nsfw = $meta->is_nsfw;
             $status->scope = 'public';
@@ -190,29 +199,30 @@ class AdminApiController extends Controller
             Notification::whereAction('autospam.warning')
                 ->whereProfileId($appeal->user->profile_id)
                 ->get()
-                ->each(function($n) use($appeal) {
+                ->each(function ($n) use ($appeal) {
                     NotificationService::del($appeal->user->profile_id, $n->id);
                     $n->forceDelete();
                 });
 
-            Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-            Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
-        if($action == 'approve-all') {
+        if ($action == 'approve-all') {
             AccountInterstitial::whereType('post.autospam')
                 ->whereItemType('App\Status')
                 ->whereNull('appeal_handled_at')
                 ->whereUserId($appeal->user_id)
                 ->get()
-                ->each(function($report) use($meta) {
+                ->each(function ($report) use ($meta) {
                     $report->is_spam = false;
                     $report->appeal_handled_at = now();
                     $report->save();
                     $status = Status::find($report->item_id);
-                    if($status) {
+                    if ($status) {
                         $status->is_nsfw = $meta->is_nsfw;
                         $status->scope = 'public';
                         $status->visibility = 'public';
@@ -223,14 +233,15 @@ class AdminApiController extends Controller
                     Notification::whereAction('autospam.warning')
                         ->whereProfileId($report->user->profile_id)
                         ->get()
-                        ->each(function($n) use($report) {
+                        ->each(function ($n) use ($report) {
                             NotificationService::del($report->user->profile_id, $n->id);
                             $n->forceDelete();
                         });
                 });
-            Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
-            Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:exemption_by_pid:'.$appeal->user->profile_id);
+            Cache::forget('pf:bouncer_v0:recent_by_pid:'.$appeal->user->profile_id);
             Cache::forget('admin-dash:reports:spam-count');
+
             return $res;
         }
 
@@ -239,42 +250,48 @@ class AdminApiController extends Controller
 
     public function modReports(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         $reports = Report::whereNull('admin_seen')
-            ->orderBy('created_at','desc')
+            ->orderBy('created_at', 'desc')
             ->paginate(6)
-            ->map(function($report) {
+            ->map(function ($report) {
                 $r = [
                     'id' => $report->id,
                     'type' => $report->type,
                     'message' => $report->message,
                     'object_id' => $report->object_id,
                     'object_type' => $report->object_type,
-                    'created_at' => $report->created_at
+                    'created_at' => $report->created_at,
                 ];
 
-                if($report->profile_id) {
+                if ($report->profile_id) {
                     $r['reported_by_account'] = AccountService::get($report->profile_id, true);
                 }
 
-                if($report->object_type === 'App\\Status') {
+                if ($report->object_type === 'App\\Status') {
                     $status = StatusService::get($report->object_id, false);
-                    if(!$status) {
+                    if (! $status) {
                         return;
                     }
 
                     $r['status'] = $status;
 
-                    if($status['in_reply_to_id']) {
+                    if (isset($status['in_reply_to_id'])) {
                         $r['parent'] = StatusService::get($status['in_reply_to_id'], false);
                     }
                 }
 
-                if($report->object_type === 'App\\Profile') {
-                    $r['account'] = AccountService::get($report->object_id, false);
+                if ($report->object_type === 'App\\Profile') {
+                    $acct = AccountService::get($report->object_id, true);
+                    if ($acct) {
+                        $r['account'] = $acct;
+                    }
                 }
+
                 return $r;
             })
             ->filter()
@@ -285,12 +302,14 @@ class AdminApiController extends Controller
 
     public function modReportHandle(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
-            'action'    => 'required|string',
-            'id' => 'required'
+            'action' => 'required|string',
+            'id' => 'required',
         ]);
 
         $action = $request->input('action');
@@ -299,10 +318,10 @@ class AdminApiController extends Controller
         $actions = [
             'ignore',
             'cw',
-            'unlist'
+            'unlist',
         ];
 
-        if (!in_array($action, $actions)) {
+        if (! in_array($action, $actions)) {
             return abort(403);
         }
 
@@ -343,56 +362,63 @@ class AdminApiController extends Controller
 
     public function getConfiguration(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
+
         abort_unless(config('instance.enable_cc'), 400);
 
         return collect([
             [
                 'name' => 'ActivityPub Federation',
                 'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.',
-                'key' => 'federation.activitypub.enabled'
+                'key' => 'federation.activitypub.enabled',
             ],
 
             [
                 'name' => 'Open Registration',
                 'description' => 'Allow new account registrations.',
-                'key' => 'pixelfed.open_registration'
+                'key' => 'pixelfed.open_registration',
             ],
 
             [
                 'name' => 'Stories',
                 'description' => 'Enable the ephemeral Stories feature.',
-                'key' => 'instance.stories.enabled'
+                'key' => 'instance.stories.enabled',
             ],
 
             [
                 'name' => 'Require Email Verification',
                 'description' => 'Require new accounts to verify their email address.',
-                'key' => 'pixelfed.enforce_email_verification'
+                'key' => 'pixelfed.enforce_email_verification',
             ],
 
             [
                 'name' => 'AutoSpam Detection',
                 'description' => 'Detect and remove spam from public timelines.',
-                'key' => 'pixelfed.bouncer.enabled'
+                'key' => 'pixelfed.bouncer.enabled',
             ],
         ])
-        ->map(function($s) {
-            $s['state'] = (bool) config_cache($s['key']);
-            return $s;
-        });
+            ->map(function ($s) {
+                $s['state'] = (bool) config_cache($s['key']);
+
+                return $s;
+            });
     }
 
     public function updateConfiguration(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
+
         abort_unless(config('instance.enable_cc'), 400);
 
         $this->validate($request, [
             'key' => 'required',
-            'value' => 'required'
+            'value' => 'required',
         ]);
 
         $allowedKeys = [
@@ -405,76 +431,84 @@ class AdminApiController extends Controller
 
         $key = $request->input('key');
         $value = (bool) filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
-        abort_if(!in_array($key, $allowedKeys), 400, 'Invalid cache key.');
+        abort_if(! in_array($key, $allowedKeys), 400, 'Invalid cache key.');
 
         ConfigCacheService::put($key, $value);
 
-                return collect([
+        return collect([
             [
                 'name' => 'ActivityPub Federation',
                 'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.',
-                'key' => 'federation.activitypub.enabled'
+                'key' => 'federation.activitypub.enabled',
             ],
 
             [
                 'name' => 'Open Registration',
                 'description' => 'Allow new account registrations.',
-                'key' => 'pixelfed.open_registration'
+                'key' => 'pixelfed.open_registration',
             ],
 
             [
                 'name' => 'Stories',
                 'description' => 'Enable the ephemeral Stories feature.',
-                'key' => 'instance.stories.enabled'
+                'key' => 'instance.stories.enabled',
             ],
 
             [
                 'name' => 'Require Email Verification',
                 'description' => 'Require new accounts to verify their email address.',
-                'key' => 'pixelfed.enforce_email_verification'
+                'key' => 'pixelfed.enforce_email_verification',
             ],
 
             [
                 'name' => 'AutoSpam Detection',
                 'description' => 'Detect and remove spam from public timelines.',
-                'key' => 'pixelfed.bouncer.enabled'
+                'key' => 'pixelfed.bouncer.enabled',
             ],
         ])
-        ->map(function($s) {
-            $s['state'] = (bool) config_cache($s['key']);
-            return $s;
-        });
+            ->map(function ($s) {
+                $s['state'] = (bool) config_cache($s['key']);
+
+                return $s;
+            });
     }
 
     public function getUsers(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
+
         $this->validate($request, [
             'sort' => 'sometimes|in:asc,desc',
         ]);
         $q = $request->input('q');
         $sort = $request->input('sort', 'desc') === 'asc' ? 'asc' : 'desc';
         $res = User::whereNull('status')
-            ->when($q, function($query, $q) {
-                return $query->where('username', 'like', '%' . $q . '%');
+            ->when($q, function ($query, $q) {
+                return $query->where('username', 'like', '%'.$q.'%');
             })
             ->orderBy('id', $sort)
             ->cursorPaginate(10);
+
         return AdminUser::collection($res);
     }
 
     public function getUser(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         $id = $request->input('user_id');
-        $key = 'pf-admin-api:getUser:byId:' . $id;
-        if($request->has('refresh')) {
+        $key = 'pf-admin-api:getUser:byId:'.$id;
+        if ($request->has('refresh')) {
             Cache::forget($key);
         }
-        return Cache::remember($key, 86400, function() use($id) {
+
+        return Cache::remember($key, 86400, function () use ($id) {
             $user = User::findOrFail($id);
             $profile = $user->profile;
             $account = AccountService::get($user->profile_id, true);
@@ -487,8 +521,8 @@ class AdminApiController extends Controller
                 'moderation' => [
                     'unlisted' => (bool) $profile->unlisted,
                     'cw' => (bool) $profile->cw,
-                    'no_autolink' => (bool) $profile->no_autolink
-                ]
+                    'no_autolink' => (bool) $profile->no_autolink,
+                ],
             ]]);
 
             return $res;
@@ -497,13 +531,15 @@ class AdminApiController extends Controller
 
     public function userAdminAction(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
             'id' => 'required',
             'action' => 'required|in:unlisted,cw,no_autolink,refresh_stats,verify_email,delete',
-            'value' => 'sometimes'
+            'value' => 'sometimes',
         ]);
 
         $id = $request->input('id');
@@ -513,8 +549,8 @@ class AdminApiController extends Controller
 
         abort_if($user->is_admin == true && $action !== 'refresh_stats', 400, 'Cannot moderate admin accounts');
 
-        if($action === 'delete') {
-            if(config('pixelfed.account_deletion') == false) {
+        if ($action === 'delete') {
+            if (config('pixelfed.account_deletion') == false) {
                 abort(404);
             }
 
@@ -542,7 +578,7 @@ class AdminApiController extends Controller
             PublicTimelineService::deleteByProfileId($profile->id);
             NetworkTimelineService::deleteByProfileId($profile->id);
 
-            if($profile->user_id) {
+            if ($profile->user_id) {
                 DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
                 DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
                 $user->email = $user->id;
@@ -561,11 +597,12 @@ class AdminApiController extends Controller
                 AccountService::del($profile->id);
                 DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
             }
+
             return [
                 'status' => 200,
                 'msg' => 'deleted',
             ];
-        } else if($action === 'refresh_stats') {
+        } elseif ($action === 'refresh_stats') {
             $profile->following_count = DB::table('followers')->whereProfileId($user->profile_id)->count();
             $profile->followers_count = DB::table('followers')->whereFollowingId($user->profile_id)->count();
             $statusCount = Status::whereProfileId($user->profile_id)
@@ -575,7 +612,7 @@ class AdminApiController extends Controller
                 ->count();
             $profile->status_count = $statusCount;
             $profile->save();
-        } else if($action === 'verify_email') {
+        } elseif ($action === 'verify_email') {
             $user->email_verified_at = now();
             $user->save();
 
@@ -587,11 +624,11 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => 'Manually verified email address',
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
-        } else if($action === 'unlisted') {
+        } elseif ($action === 'unlisted') {
             ModLogService::boot()
                 ->objectUid($profile->id)
                 ->objectId($profile->id)
@@ -600,13 +637,13 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => $action,
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
-            $profile->unlisted = !$profile->unlisted;
+            $profile->unlisted = ! $profile->unlisted;
             $profile->save();
-        } else if($action === 'cw') {
+        } elseif ($action === 'cw') {
             ModLogService::boot()
                 ->objectUid($profile->id)
                 ->objectId($profile->id)
@@ -615,13 +652,13 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => $action,
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
-            $profile->cw = !$profile->cw;
+            $profile->cw = ! $profile->cw;
             $profile->save();
-        } else if($action === 'no_autolink') {
+        } elseif ($action === 'no_autolink') {
             ModLogService::boot()
                 ->objectUid($profile->id)
                 ->objectId($profile->id)
@@ -630,11 +667,11 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => $action,
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
-            $profile->no_autolink = !$profile->no_autolink;
+            $profile->no_autolink = ! $profile->no_autolink;
             $profile->save();
         } else {
             $profile->{$action} = filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
@@ -648,7 +685,7 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => $action,
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
@@ -662,15 +699,17 @@ class AdminApiController extends Controller
             'moderation' => [
                 'unlisted' => (bool) $profile->unlisted,
                 'cw' => (bool) $profile->cw,
-                'no_autolink' => (bool) $profile->no_autolink
-            ]
+                'no_autolink' => (bool) $profile->no_autolink,
+            ],
         ]]);
     }
 
     public function instances(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
             'q' => 'sometimes',
@@ -684,19 +723,19 @@ class AdminApiController extends Controller
         $sortBy = $request->input('sort_by', 'id');
         $filter = $request->input('filter');
 
-        $res = Instance::when($q, function($query, $q) {
-                return $query->where('domain', 'like', '%' . $q . '%');
-            })
-            ->when($filter, function($query, $filter) {
-                if($filter === 'all') {
+        $res = Instance::when($q, function ($query, $q) {
+            return $query->where('domain', 'like', '%'.$q.'%');
+        })
+            ->when($filter, function ($query, $filter) {
+                if ($filter === 'all') {
                     return $query;
                 } else {
                     return $query->where($filter, true);
                 }
             })
-            ->when($sortBy, function($query, $sortBy) use($sort) {
+            ->when($sortBy, function ($query, $sortBy) use ($sort) {
                 return $query->orderBy($sortBy, $sort);
-            }, function($query) {
+            }, function ($query) {
                 return $query->orderBy('id', 'desc');
             })
             ->cursorPaginate(10)
@@ -707,8 +746,10 @@ class AdminApiController extends Controller
 
     public function getInstance(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
         $id = $request->input('id');
         $res = Instance::findOrFail($id);
@@ -718,13 +759,15 @@ class AdminApiController extends Controller
 
     public function moderateInstance(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
             'id' => 'required',
             'key' => 'required|in:unlisted,auto_cw,banned',
-            'value' => 'required'
+            'value' => 'required',
         ]);
 
         $id = $request->input('id');
@@ -742,8 +785,10 @@ class AdminApiController extends Controller
 
     public function refreshInstanceStats(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin == 1, 404);
+        abort_unless($request->user()->tokenCan('admin:write'), 404);
 
         $this->validate($request, [
             'id' => 'required',
@@ -760,49 +805,51 @@ class AdminApiController extends Controller
 
     public function getAllStats(Request $request)
     {
-        abort_if(!$request->user(), 404);
+        abort_if(! $request->user() || ! $request->user()->token(), 404);
+
         abort_unless($request->user()->is_admin === 1, 404);
+        abort_unless($request->user()->tokenCan('admin:read'), 404);
 
-        if($request->has('refresh')) {
+        if ($request->has('refresh')) {
             Cache::forget('admin-api:instance-all-stats-v1');
         }
 
-        return Cache::remember('admin-api:instance-all-stats-v1', 1209600, function() {
+        return Cache::remember('admin-api:instance-all-stats-v1', 1209600, function () {
             $days = range(1, 7);
             $res = [
                 'cached_at' => now()->format('c'),
             ];
             $minStatusId = SnowflakeService::byDate(now()->subDays(7));
 
-            foreach($days as $day) {
+            foreach ($days as $day) {
                 $label = now()->subDays($day)->format('D');
                 $labelShort = substr($label, 0, 1);
                 $res['users']['days'][] = [
                     'date' => now()->subDays($day)->format('M j Y'),
                     'label_full' => $label,
                     'label' => $labelShort,
-                    'count' => User::whereDate('created_at', now()->subDays($day))->count()
+                    'count' => User::whereDate('created_at', now()->subDays($day))->count(),
                 ];
 
                 $res['posts']['days'][] = [
                     'date' => now()->subDays($day)->format('M j Y'),
                     'label_full' => $label,
                     'label' => $labelShort,
-                    'count' => Status::whereNull('uri')->where('id', '>', $minStatusId)->whereDate('created_at', now()->subDays($day))->count()
+                    'count' => Status::whereNull('uri')->where('id', '>', $minStatusId)->whereDate('created_at', now()->subDays($day))->count(),
                 ];
 
                 $res['instances']['days'][] = [
                     'date' => now()->subDays($day)->format('M j Y'),
                     'label_full' => $label,
                     'label' => $labelShort,
-                    'count' => Instance::whereDate('created_at', now()->subDays($day))->count()
+                    'count' => Instance::whereDate('created_at', now()->subDays($day))->count(),
                 ];
             }
 
             $res['users']['total'] = DB::table('users')->count();
             $res['users']['min'] = collect($res['users']['days'])->min('count');
             $res['users']['max'] = collect($res['users']['days'])->max('count');
-            $res['users']['change'] = collect($res['users']['days'])->sum('count');;
+            $res['users']['change'] = collect($res['users']['days'])->sum('count');
             $res['posts']['total'] = DB::table('statuses')->whereNull('uri')->count();
             $res['posts']['min'] = collect($res['posts']['days'])->min('count');
             $res['posts']['max'] = collect($res['posts']['days'])->max('count');

+ 38 - 0
app/Http/Controllers/Api/ApiController.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+
+class ApiController extends Controller {
+  public function json($res, $headers = [], $code = 200) {
+    return response()->json($res, $code, $this->filterHeaders($headers), JSON_UNESCAPED_SLASHES);
+  }
+
+  public function linksForCollection($paginator) {
+    $link = null;
+
+    if ($paginator->onFirstPage()) {
+      if ($paginator->hasMorePages()) {
+          $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+      }
+    } else {
+      if ($paginator->previousPageUrl()) {
+          $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+      }
+
+      if ($paginator->hasMorePages()) {
+          $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+      }
+    }
+
+    return $link;
+  }
+
+  private function filterHeaders($headers) {
+    return array_filter($headers, function($v, $k) {
+      return $v != null;
+    }, ARRAY_FILTER_USE_BOTH);
+  }
+}

+ 4290 - 3746
app/Http/Controllers/Api/ApiV1Controller.php

@@ -2,3805 +2,4349 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
+use App\Avatar;
+use App\Bookmark;
+use App\Collection;
+use App\CollectionItem;
+use App\DirectMessage;
+use App\Follower;
+use App\FollowRequest;
+use App\Hashtag;
+use App\Http\Controllers\AccountController;
 use App\Http\Controllers\Controller;
-use Illuminate\Support\Str;
-use App\Util\ActivityPub\Helpers;
-use App\Util\Media\Filter;
-use Laravel\Passport\Passport;
-use Auth, Cache, DB, Storage, URL;
-use Illuminate\Support\Facades\Redis;
-use App\{
-	Avatar,
-	Bookmark,
-	Collection,
-	CollectionItem,
-	DirectMessage,
-	Follower,
-	FollowRequest,
-	Hashtag,
-	HashtagFollow,
-	Instance,
-	Like,
-	Media,
-	Notification,
-	Profile,
-	Status,
-	StatusHashtag,
-	User,
-	UserSetting,
-	UserFilter,
-};
-use League\Fractal;
-use App\Transformer\Api\Mastodon\v1\{
-	AccountTransformer,
-	MediaTransformer,
-	NotificationTransformer,
-	StatusTransformer,
-};
-use App\Transformer\Api\{
-	RelationshipTransformer,
-};
 use App\Http\Controllers\FollowerController;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Http\Controllers\AccountController;
 use App\Http\Controllers\StatusController;
-
+use App\Instance;
 use App\Jobs\AvatarPipeline\AvatarOptimize;
 use App\Jobs\CommentPipeline\CommentPipeline;
+use App\Jobs\FollowPipeline\FollowAcceptPipeline;
+use App\Jobs\FollowPipeline\FollowPipeline;
+use App\Jobs\FollowPipeline\FollowRejectPipeline;
+use App\Jobs\FollowPipeline\UnfollowPipeline;
+use App\Jobs\HomeFeedPipeline\FeedWarmCachePipeline;
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\MediaPipeline\MediaDeletePipeline;
+use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
 use App\Jobs\SharePipeline\SharePipeline;
 use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
 use App\Jobs\StatusPipeline\StatusDelete;
-use App\Jobs\FollowPipeline\FollowPipeline;
-use App\Jobs\FollowPipeline\UnfollowPipeline;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\VideoPipeline\{
-	VideoOptimize,
-	VideoPostProcess,
-	VideoThumbnail
-};
-
-use App\Services\{
-	AccountService,
-	BookmarkService,
-	BouncerService,
-	CollectionService,
-	FollowerService,
-	HashtagService,
-	InstanceService,
-	LikeService,
-	NetworkTimelineService,
-	NotificationService,
-	MediaService,
-	MediaPathService,
-    ProfileStatusService,
-	PublicTimelineService,
-	ReblogService,
-	RelationshipService,
-	SearchApiV2Service,
-	StatusService,
-	MediaBlocklistService,
-	SnowflakeService,
-	UserFilterService
-};
+use App\Jobs\VideoPipeline\VideoThumbnail;
+use App\Like;
+use App\Media;
+use App\Models\Conversation;
+use App\Notification;
+use App\Profile;
+use App\Services\AccountService;
+use App\Services\AdminShadowFilterService;
+use App\Services\BookmarkService;
+use App\Services\BouncerService;
+use App\Services\CollectionService;
+use App\Services\CustomEmojiService;
+use App\Services\DiscoverService;
+use App\Services\FollowerService;
+use App\Services\HomeTimelineService;
+use App\Services\InstanceService;
+use App\Services\LikeService;
+use App\Services\MarkerService;
+use App\Services\MediaBlocklistService;
+use App\Services\MediaPathService;
+use App\Services\MediaService;
+use App\Services\NetworkTimelineService;
+use App\Services\NotificationService;
+use App\Services\PublicTimelineService;
+use App\Services\ReblogService;
+use App\Services\RelationshipService;
+use App\Services\SnowflakeService;
+use App\Services\StatusService;
+use App\Services\UserFilterService;
+use App\Services\UserRoleService;
+use App\Services\UserStorageService;
+use App\Status;
+use App\StatusHashtag;
+use App\Transformer\Api\Mastodon\v1\AccountTransformer;
+use App\Transformer\Api\Mastodon\v1\MediaTransformer;
+use App\Transformer\Api\Mastodon\v1\NotificationTransformer;
+use App\Transformer\Api\Mastodon\v1\StatusTransformer;
+use App\Transformer\Api\RelationshipTransformer;
+use App\User;
+use App\UserFilter;
+use App\UserSetting;
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\PrettyNumber;
 use App\Util\Localization\Localization;
+use App\Util\Media\Filter;
 use App\Util\Media\License;
-use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
-use App\Services\DiscoverService;
-use App\Services\CustomEmojiService;
-use App\Services\MarkerService;
-use App\Models\Conversation;
-use App\Jobs\FollowPipeline\FollowAcceptPipeline;
-use App\Jobs\FollowPipeline\FollowRejectPipeline;
+use Cache;
+use Carbon\Carbon;
+use DB;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Support\Str;
+use Laravel\Passport\Passport;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 use Purify;
-use Carbon\Carbon;
-use App\Http\Resources\MastoApi\FollowedTagResource;
+use Storage;
 
 class ApiV1Controller extends Controller
 {
-	protected $fractal;
-	const PF_API_ENTITY_KEY = "_pe";
-
-	public function __construct()
-	{
-		$this->fractal = new Fractal\Manager();
-		$this->fractal->setSerializer(new ArraySerializer());
-	}
-
-	public function json($res, $code = 200, $headers = [])
-	{
-		return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
-	}
-
-	public function getApp(Request $request)
-	{
-		if(!$request->user()) {
-			return response('', 403);
-		}
-
-		$client = $request->user()->token()->client;
-		$res = [
-			'name' => $client->name,
-			'website' => null,
-			'vapid_key' => null
-		];
-
-		return $this->json($res);
-	}
-
-	public function apps(Request $request)
-	{
-		abort_if(!config_cache('pixelfed.oauth_enabled'), 404);
-
-		$this->validate($request, [
-			'client_name' 		=> 'required',
-			'redirect_uris' 	=> 'required'
-		]);
-
-		$uris = implode(',', explode('\n', $request->redirect_uris));
-
-		$client = Passport::client()->forceFill([
-			'user_id' => null,
-			'name' => e($request->client_name),
-			'secret' => Str::random(40),
-			'redirect' => $uris,
-			'personal_access_client' => false,
-			'password_client' => false,
-			'revoked' => false,
-		]);
-
-		$client->save();
-
-		$res = [
-			'id' => (string) $client->id,
-			'name' => $client->name,
-			'website' => null,
-			'redirect_uri' => $client->redirect,
-			'client_id' => (string) $client->id,
-			'client_secret' => $client->secret,
-			'vapid_key' => null
-		];
-
-		return $this->json($res, 200, [
-			'Access-Control-Allow-Origin' => '*'
-		]);
-	}
-
-	/**
-	 * GET /api/v1/accounts/verify_credentials
-	 *
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function verifyCredentials(Request $request)
-	{
-		$user = $request->user();
-
-		abort_if(!$user, 403);
-		abort_if($user->status != null, 403);
-
-		$res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($user->profile_id) : AccountService::getMastodon($user->profile_id);
-
-		$res['source'] = [
-			'privacy' => $res['locked'] ? 'private' : 'public',
-			'sensitive' => false,
-			'language' => $user->language ?? 'en',
-			'note' => strip_tags($res['note']),
-			'fields' => []
-		];
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/accounts/{id}
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountById(Request $request, $id)
-	{
-		$res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($id, true) : AccountService::getMastodon($id, true);
-		if(!$res) {
-			return response()->json(['error' => 'Record not found'], 404);
-		}
-		return $this->json($res);
-	}
-
-	/**
-	 * PATCH /api/v1/accounts/update_credentials
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountUpdateCredentials(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
-			abort_if(BouncerService::checkIp($request->ip()), 404);
-		}
-
-		$this->validate($request, [
-			'avatar'			=> 'sometimes|mimetypes:image/jpeg,image/png|max:' . config('pixelfed.max_avatar_size'),
-			'display_name'      => 'nullable|string|max:30',
-			'note'              => 'nullable|string|max:200',
-			'locked'            => 'nullable',
-			'website'			=> 'nullable|string|max:120',
-			// 'source.privacy'    => 'nullable|in:unlisted,public,private',
-			// 'source.sensitive'  => 'nullable|boolean'
-		], [
-			'required' => 'The :attribute field is required.',
-			'avatar.mimetypes' => 'The file must be in jpeg or png format',
-			'avatar.max' => 'The :attribute exceeds the file size limit of ' . PrettyNumber::size(config('pixelfed.max_avatar_size'), true, false),
-		]);
-
-		$user = $request->user();
-		$profile = $user->profile;
-		$settings = $user->settings;
-
-		$changes = false;
-		$other = array_merge(AccountService::defaultSettings()['other'], $settings->other ?? []);
-		$syncLicenses = false;
-		$licenseChanged = false;
-		$composeSettings = array_merge(AccountService::defaultSettings()['compose_settings'], $settings->compose_settings ?? []);
-
-		if($request->has('avatar')) {
-			$av = Avatar::whereProfileId($profile->id)->first();
-			if($av) {
-				$currentAvatar = storage_path('app/'.$av->media_path);
-				$file = $request->file('avatar');
-				$path = "public/avatars/{$profile->id}";
-				$name = strtolower(str_random(6)). '.' . $file->guessExtension();
-				$request->file('avatar')->storePubliclyAs($path, $name);
-				$av->media_path = "{$path}/{$name}";
-				$av->save();
-				Cache::forget("avatar:{$profile->id}");
-				Cache::forget('user:account:id:'.$user->id);
-				AvatarOptimize::dispatch($user->profile, $currentAvatar);
-			}
-			$changes = true;
-		}
-
-		if($request->has('source[language]')) {
-			$lang = $request->input('source[language]');
-			if(in_array($lang, Localization::languages())) {
-				$user->language = $lang;
-				$changes = true;
-				$other['language'] = $lang;
-			}
-		}
-
-		if($request->has('website')) {
-			$website = $request->input('website');
-			if($website != $profile->website) {
-				if($website) {
-					if(!strpos($website, '.')) {
-						$website = null;
-					}
-
-					if($website && !strpos($website, '://')) {
-						$website = 'https://' . $website;
-					}
-
-					$host = parse_url($website, PHP_URL_HOST);
-
-					$bannedInstances = InstanceService::getBannedDomains();
-					if(in_array($host, $bannedInstances)) {
-						$website = null;
-					}
-				}
-				$profile->website = $website ? $website : null;
-				$changes = true;
-			}
-		}
-
-		if($request->has('display_name')) {
-			$displayName = $request->input('display_name');
-			if($displayName !== $user->name) {
-				$user->name = $displayName;
-				$profile->name = $displayName;
-				$changes = true;
-			}
-		}
-
-		if($request->has('note')) {
-			$note = $request->input('note');
-			if($note !== strip_tags($profile->bio)) {
-				$profile->bio = Autolink::create()->autolink(strip_tags($note));
-				$changes = true;
-			}
-		}
-
-		if($request->has('locked')) {
-			$locked = $request->input('locked') == 'true';
-			if($profile->is_private != $locked) {
-				$profile->is_private = $locked;
-				$changes = true;
-			}
-		}
-
-		if($request->has('reduce_motion')) {
-			$reduced = $request->input('reduce_motion');
-			if($settings->reduce_motion != $reduced) {
-				$settings->reduce_motion = $reduced;
-				$changes = true;
-			}
-		}
-
-		if($request->has('high_contrast_mode')) {
-			$contrast = $request->input('high_contrast_mode');
-			if($settings->high_contrast_mode != $contrast) {
-				$settings->high_contrast_mode = $contrast;
-				$changes = true;
-			}
-		}
-
-		if($request->has('video_autoplay')) {
-			$autoplay = $request->input('video_autoplay');
-			if($settings->video_autoplay != $autoplay) {
-				$settings->video_autoplay = $autoplay;
-				$changes = true;
-			}
-		}
-
-		if($request->has('license')) {
-			$license = $request->input('license');
-			abort_if(!in_array($license, License::keys()), 422, 'Invalid media license id');
-			$syncLicenses = $request->input('sync_licenses') == true;
-			abort_if($syncLicenses && Cache::get('pf:settings:mls_recently:'.$user->id) == 2, 422, 'You can only sync licenses twice per 24 hours');
-			if($composeSettings['default_license'] != $license) {
-				$composeSettings['default_license'] = $license;
-				$licenseChanged = true;
-				$changes = true;
-			}
-		}
-
-		if($request->has('media_descriptions')) {
-			$md = $request->input('media_descriptions') == true;
-			if($composeSettings['media_descriptions'] != $md) {
-				$composeSettings['media_descriptions'] = $md;
-				$changes = true;
-			}
-		}
-
-		if($request->has('crawlable')) {
-			$crawlable = $request->input('crawlable');
-			if($settings->crawlable != $crawlable) {
-				$settings->crawlable = $crawlable;
-				$changes = true;
-			}
-		}
-
-		if($request->has('show_profile_follower_count')) {
-			$show_profile_follower_count = $request->input('show_profile_follower_count');
-			if($settings->show_profile_follower_count != $show_profile_follower_count) {
-				$settings->show_profile_follower_count = $show_profile_follower_count;
-				$changes = true;
-			}
-		}
-
-		if($request->has('show_profile_following_count')) {
-			$show_profile_following_count = $request->input('show_profile_following_count');
-			if($settings->show_profile_following_count != $show_profile_following_count) {
-				$settings->show_profile_following_count = $show_profile_following_count;
-				$changes = true;
-			}
-		}
-
-		if($request->has('public_dm')) {
-			$public_dm = $request->input('public_dm');
-			if($settings->public_dm != $public_dm) {
-				$settings->public_dm = $public_dm;
-				$changes = true;
-			}
-		}
-
-		if($request->has('source[privacy]')) {
-			$scope = $request->input('source[privacy]');
-			if(in_array($scope, ['public', 'private', 'unlisted'])) {
-				if($composeSettings['default_scope'] != $scope) {
-					$composeSettings['default_scope'] = $profile->is_private ? 'private' : $scope;
-					$changes = true;
-				}
-			}
-		}
-
-		if($request->has('disable_embeds')) {
-			$disabledEmbeds = $request->input('disable_embeds');
-			if($other['disable_embeds'] != $disabledEmbeds) {
-				$other['disable_embeds'] = $disabledEmbeds;
-				$changes = true;
-			}
-		}
-
-		if($changes) {
-			$settings->other = $other;
-			$settings->compose_settings = $composeSettings;
-			$settings->save();
-			$user->save();
-			$profile->save();
-			Cache::forget('profile:settings:' . $profile->id);
-			Cache::forget('user:account:id:' . $profile->user_id);
-			Cache::forget('profile:follower_count:' . $profile->id);
-			Cache::forget('profile:following_count:' . $profile->id);
-			Cache::forget('profile:embed:' . $profile->id);
-			Cache::forget('profile:compose:settings:' . $user->id);
-			Cache::forget('profile:view:'.$user->username);
-			AccountService::del($user->profile_id);
-		}
-
-		if($syncLicenses && $licenseChanged) {
-			$key = 'pf:settings:mls_recently:'.$user->id;
-			$val = Cache::has($key) ? 2 : 1;
-			Cache::put($key, $val, 86400);
-			MediaSyncLicensePipeline::dispatch($user->id, $request->input('license'));
-		}
-
-        if($request->has(self::PF_API_ENTITY_KEY)) {
+    protected $fractal;
+
+    const PF_API_ENTITY_KEY = '_pe';
+
+    public function __construct()
+    {
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
+    }
+
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
+    /**
+     * GET /api/v1/apps/verify_credentials
+     */
+    public function getApp(Request $request)
+    {
+        // FIXME: /api/v1/apps/verify_credentials should be accessible with any
+        // valid Access Token, not just a user's access token (i.e., client
+        // credentails grant flow access tokens)
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+
+        $client = $request->user()->token()->client;
+        $res = [
+            'name' => $client->name,
+            'website' => null,
+            'vapid_key' => null,
+        ];
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/apps
+     */
+    public function apps(Request $request)
+    {
+        abort_if(! (bool) config_cache('pixelfed.oauth_enabled'), 404);
+
+        $this->validate($request, [
+            'client_name' => 'required',
+            'redirect_uris' => 'required',
+        ]);
+
+        $uris = implode(',', explode('\n', $request->redirect_uris));
+
+        $client = Passport::client()->forceFill([
+            'user_id' => null,
+            'name' => e($request->client_name),
+            'secret' => Str::random(40),
+            'redirect' => $uris,
+            'personal_access_client' => false,
+            'password_client' => false,
+            'revoked' => false,
+        ]);
+
+        $client->save();
+
+        $res = [
+            'id' => (string) $client->id,
+            'name' => $client->name,
+            'website' => null,
+            'redirect_uri' => $client->redirect,
+            'client_id' => (string) $client->id,
+            'client_secret' => $client->secret,
+            'vapid_key' => null,
+        ];
+
+        return $this->json($res, 200, [
+            'Access-Control-Allow-Origin' => '*',
+        ]);
+    }
+
+    /**
+     * GET /api/v1/accounts/verify_credentials
+     *
+     *
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function verifyCredentials(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $user = $request->user();
+
+        abort_if($user->status != null, 403);
+        AccountService::setLastActive($user->id);
+
+        $res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($user->profile_id) : AccountService::getMastodon($user->profile_id);
+
+        $res['source'] = [
+            'privacy' => $res['locked'] ? 'private' : 'public',
+            'sensitive' => false,
+            'language' => $user->language ?? 'en',
+            'note' => strip_tags($res['note']),
+            'fields' => [],
+        ];
+
+        if ($request->has(self::PF_API_ENTITY_KEY)) {
+            $res['settings'] = AccountService::getAccountSettings($user->profile_id);
+        }
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/accounts/{id}
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $withInstanceMeta = $request->has('_wim');
+        $res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($id, true) : AccountService::getMastodon($id, true);
+        if (! $res) {
+            return response()->json(['error' => 'Record not found'], 404);
+        }
+        if ($res && strpos($res['acct'], '@') != -1) {
+            $domain = parse_url($res['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        return $this->json($res);
+    }
+
+    /**
+     * PATCH /api/v1/accounts/update_credentials
+     *
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountUpdateCredentials(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        if (config('pixelfed.bouncer.cloud_ips.ban_api')) {
+            abort_if(BouncerService::checkIp($request->ip()), 404);
+        }
+
+        $this->validate($request, [
+            'avatar' => 'sometimes|mimetypes:image/jpeg,image/png|max:'.config('pixelfed.max_avatar_size'),
+            'display_name' => 'nullable|string|max:30',
+            'note' => 'nullable|string|max:200',
+            'locked' => 'nullable',
+            'website' => 'nullable|string|max:120',
+            // 'source.privacy'    => 'nullable|in:unlisted,public,private',
+            // 'source.sensitive'  => 'nullable|boolean'
+        ], [
+            'required' => 'The :attribute field is required.',
+            'avatar.mimetypes' => 'The file must be in jpeg or png format',
+            'avatar.max' => 'The :attribute exceeds the file size limit of '.PrettyNumber::size(config('pixelfed.max_avatar_size'), true, false),
+        ]);
+
+        $user = $request->user();
+        AccountService::setLastActive($user->id);
+        $profile = $user->profile;
+        $settings = $user->settings;
+
+        $changes = false;
+        $other = array_merge(AccountService::defaultSettings()['other'], $settings->other ?? []);
+        $syncLicenses = false;
+        $licenseChanged = false;
+        $composeSettings = array_merge(AccountService::defaultSettings()['compose_settings'], $settings->compose_settings ?? []);
+
+        if ($request->has('avatar')) {
+            $av = Avatar::whereProfileId($profile->id)->first();
+            if ($av) {
+                $currentAvatar = storage_path('app/'.$av->media_path);
+                $file = $request->file('avatar');
+                $path = "public/avatars/{$profile->id}";
+                $name = strtolower(str_random(6)).'.'.$file->guessExtension();
+                $request->file('avatar')->storePubliclyAs($path, $name);
+                $av->media_path = "{$path}/{$name}";
+                $av->save();
+                Cache::forget("avatar:{$profile->id}");
+                Cache::forget('user:account:id:'.$user->id);
+                AvatarOptimize::dispatch($user->profile, $currentAvatar);
+            }
+            $changes = true;
+        }
+
+        if ($request->has('source[language]')) {
+            $lang = $request->input('source[language]');
+            if (in_array($lang, Localization::languages())) {
+                $user->language = $lang;
+                $changes = true;
+                $other['language'] = $lang;
+            }
+        }
+
+        if ($request->has('website')) {
+            $website = $request->input('website');
+            if ($website != $profile->website) {
+                if ($website) {
+                    if (! strpos($website, '.')) {
+                        $website = null;
+                    }
+
+                    if ($website && ! strpos($website, '://')) {
+                        $website = 'https://'.$website;
+                    }
+
+                    $host = parse_url($website, PHP_URL_HOST);
+
+                    $bannedInstances = InstanceService::getBannedDomains();
+                    if (in_array($host, $bannedInstances)) {
+                        $website = null;
+                    }
+                }
+                $profile->website = $website ? $website : null;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('display_name')) {
+            $displayName = $request->input('display_name');
+            if ($displayName !== $user->name) {
+                $user->name = $displayName;
+                $profile->name = $displayName;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('note')) {
+            $note = $request->input('note');
+            if ($note !== strip_tags($profile->bio)) {
+                $profile->bio = Autolink::create()->autolink(strip_tags($note));
+                $changes = true;
+            }
+        }
+
+        if ($request->has('locked')) {
+            $locked = $request->boolean('locked');
+            if ($profile->is_private != $locked) {
+                $profile->is_private = $locked;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('reduce_motion')) {
+            $reduced = $request->boolean('reduce_motion');
+            if ($settings->reduce_motion != $reduced) {
+                $settings->reduce_motion = $reduced;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('high_contrast_mode')) {
+            $contrast = $request->boolean('high_contrast_mode');
+            if ($settings->high_contrast_mode != $contrast) {
+                $settings->high_contrast_mode = $contrast;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('video_autoplay')) {
+            $autoplay = $request->boolean('video_autoplay');
+            if ($settings->video_autoplay != $autoplay) {
+                $settings->video_autoplay = $autoplay;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('license')) {
+            $license = $request->input('license');
+            abort_if(! in_array($license, License::keys()), 422, 'Invalid media license id');
+            $syncLicenses = $request->input('sync_licenses') == true;
+            abort_if($syncLicenses && Cache::get('pf:settings:mls_recently:'.$user->id) == 2, 422, 'You can only sync licenses twice per 24 hours');
+            if ($composeSettings['default_license'] != $license) {
+                $composeSettings['default_license'] = $license;
+                $licenseChanged = true;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('media_descriptions')) {
+            $md = $request->boolean('media_descriptions');
+            if ($composeSettings['media_descriptions'] != $md) {
+                $composeSettings['media_descriptions'] = $md;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('crawlable')) {
+            $crawlable = $request->boolean('crawlable');
+            if ($settings->crawlable != $crawlable) {
+                $settings->crawlable = $crawlable;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('show_profile_follower_count')) {
+            $show_profile_follower_count = $request->boolean('show_profile_follower_count');
+            if ($settings->show_profile_follower_count != $show_profile_follower_count) {
+                $settings->show_profile_follower_count = $show_profile_follower_count;
+                $changes = true;
+                Cache::forget('pf:acct-trans:hideFollowers:'.$profile->id);
+            }
+        }
+
+        if ($request->has('show_profile_following_count')) {
+            $show_profile_following_count = $request->boolean('show_profile_following_count');
+            if ($settings->show_profile_following_count != $show_profile_following_count) {
+                $settings->show_profile_following_count = $show_profile_following_count;
+                $changes = true;
+                Cache::forget('pf:acct-trans:hideFollowing:'.$profile->id);
+            }
+        }
+
+        if ($request->has('public_dm')) {
+            $public_dm = $request->boolean('public_dm');
+            if ($settings->public_dm != $public_dm) {
+                $settings->public_dm = $public_dm;
+                $changes = true;
+            }
+        }
+
+        if ($request->has('source[privacy]')) {
+            $scope = $request->input('source[privacy]');
+            if (in_array($scope, ['public', 'private', 'unlisted'])) {
+                if ($composeSettings['default_scope'] != $scope) {
+                    $composeSettings['default_scope'] = $profile->is_private ? 'private' : $scope;
+                    $changes = true;
+                }
+            }
+        }
+
+        if ($request->has('disable_embeds')) {
+            $disabledEmbeds = $request->boolean('disable_embeds');
+            if ($other['disable_embeds'] != $disabledEmbeds) {
+                $other['disable_embeds'] = $disabledEmbeds;
+                $changes = true;
+            }
+        }
+
+        if ($changes) {
+            $settings->other = $other;
+            $settings->compose_settings = $composeSettings;
+            $settings->save();
+            $user->save();
+            $profile->save();
+            Cache::forget('profile:settings:'.$profile->id);
+            Cache::forget('user:account:id:'.$profile->user_id);
+            Cache::forget('profile:follower_count:'.$profile->id);
+            Cache::forget('profile:following_count:'.$profile->id);
+            Cache::forget('profile:embed:'.$profile->id);
+            Cache::forget('profile:compose:settings:'.$user->id);
+            Cache::forget('profile:view:'.$profile->username);
+            Cache::forget('profile:atom:enabled:'.$profile->id);
+            Cache::forget('pfc:cached-user:wt:'.strtolower($profile->username));
+            Cache::forget('pfc:cached-user:wot:'.strtolower($profile->username));
+            Cache::forget('pf:acct:settings:hidden-followers:'.$profile->id);
+            Cache::forget('pf:acct:settings:hidden-following:'.$profile->id);
+            Cache::forget('pf:acct-trans:hideFollowing:'.$profile->id);
+            Cache::forget('pf:acct-trans:hideFollowers:'.$profile->id);
+            AccountService::del($user->profile_id);
+            AccountService::forgetAccountSettings($profile->id);
+        }
+
+        if ($syncLicenses && $licenseChanged) {
+            $key = 'pf:settings:mls_recently:'.$user->id;
+            $val = Cache::has($key) ? 2 : 1;
+            Cache::put($key, $val, 86400);
+            MediaSyncLicensePipeline::dispatch($user->id, $request->input('license'));
+        }
+
+        if ($request->has(self::PF_API_ENTITY_KEY)) {
             $res = AccountService::get($user->profile_id, true);
         } else {
-           $res = AccountService::getMastodon($user->profile_id, true);
-           $res['bio'] = strip_tags($res['note']);
-           $res = array_merge($res, $other);
-       }
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/accounts/{id}/followers
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountFollowersById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$account = AccountService::get($id);
-		abort_if(!$account, 404);
-		$pid = $request->user()->profile_id;
-		$this->validate($request, [
-			'limit' => 'sometimes|integer|min:1|max:80'
-		]);
-		$limit = $request->input('limit', 10);
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-
-		if(intval($pid) !== intval($account['id'])) {
-			if($account['locked']) {
-				if(!FollowerService::follows($pid, $account['id'])) {
-					return [];
-				}
-			}
-
-			if(AccountService::hiddenFollowers($id)) {
-				return [];
-			}
-
-			if($request->has('page') && $request->user()->is_admin == false) {
-				$page = (int) $request->input('page');
-				if(($page * $limit) >= 100) {
-					return [];
-				}
-			}
-		}
-		if($request->has('page')) {
-			$res = DB::table('followers')
-				->select('id', 'profile_id', 'following_id')
-				->whereFollowingId($account['id'])
-				->orderByDesc('id')
-				->simplePaginate($limit)
-				->map(function($follower) use($napi) {
-					return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
-				})
-				->filter(function($account) {
-					return $account && isset($account['id']);
-				})
-				->values()
-				->toArray();
-
-			return $this->json($res);
-		}
-
-		$paginator = DB::table('followers')
-			->select('id', 'profile_id', 'following_id')
-			->whereFollowingId($account['id'])
-			->orderByDesc('id')
-			->cursorPaginate($limit)
-			->withQueryString();
-
-		$link = null;
-
-		if($paginator->onFirstPage()) {
-			if($paginator->hasMorePages()) {
-				$link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
-			}
-		} else {
-			if($paginator->previousPageUrl()) {
-				$link = '<'.$paginator->previousPageUrl().'>; rel="next"';
-			}
-
-			if($paginator->hasMorePages()) {
-				$link .= ($link ? ', ' : '') . '<'.$paginator->nextPageUrl().'>; rel="prev"';
-			}
-		}
-
-		$res = $paginator->map(function($follower) use($napi) {
-				return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
-			})
-			->filter(function($account) {
-				return $account && isset($account['id']);
-			})
-			->values()
-			->toArray();
-
-		$headers = isset($link) ? ['Link' => $link] : [];
-		return $this->json($res, 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/accounts/{id}/following
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountFollowingById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$account = AccountService::get($id);
-		abort_if(!$account, 404);
-		$pid = $request->user()->profile_id;
-		$this->validate($request, [
-			'limit' => 'sometimes|integer|min:1|max:80'
-		]);
-		$limit = $request->input('limit', 10);
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-
-		if(intval($pid) !== intval($account['id'])) {
-			if($account['locked']) {
-				if(!FollowerService::follows($pid, $account['id'])) {
-					return [];
-				}
-			}
-
-			if(AccountService::hiddenFollowing($id)) {
-				return [];
-			}
-
-			if($request->has('page') && $request->user()->is_admin == false) {
-				$page = (int) $request->input('page');
-				if(($page * $limit) >= 100) {
-					return [];
-				}
-			}
-		}
-
-		if($request->has('page')) {
-			$res = DB::table('followers')
-				->select('id', 'profile_id', 'following_id')
-				->whereProfileId($account['id'])
-				->orderByDesc('id')
-				->simplePaginate($limit)
-				->map(function($follower) use($napi) {
-					return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
-				})
-				->filter(function($account) {
-					return $account && isset($account['id']);
-				})
-				->values()
-				->toArray();
-			return $this->json($res);
-		}
-
-		$paginator = DB::table('followers')
-			->select('id', 'profile_id', 'following_id')
-			->whereProfileId($account['id'])
-			->orderByDesc('id')
-			->cursorPaginate($limit)
-			->withQueryString();
-
-		$link = null;
-
-		if($paginator->onFirstPage()) {
-			if($paginator->hasMorePages()) {
-				$link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
-			}
-		} else {
-			if($paginator->previousPageUrl()) {
-				$link = '<'.$paginator->previousPageUrl().'>; rel="next"';
-			}
-
-			if($paginator->hasMorePages()) {
-				$link .= ($link ? ', ' : '') . '<'.$paginator->nextPageUrl().'>; rel="prev"';
-			}
-		}
-
-		$res = $paginator->map(function($follower) use($napi) {
-				return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
-			})
-			->filter(function($account) {
-				return $account && isset($account['id']);
-			})
-			->values()
-			->toArray();
-
-		$headers = isset($link) ? ['Link' => $link] : [];
-		return $this->json($res, 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/accounts/{id}/statuses
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\StatusTransformer
-	 */
-	public function accountStatusesById(Request $request, $id)
-	{
-		$user = $request->user();
-
-		$this->validate($request, [
-			'only_media' => 'nullable',
-			'media_type' => 'sometimes|string|in:photo,video',
-			'pinned' => 'nullable',
-			'exclude_replies' => 'nullable',
-			'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'limit' => 'nullable|integer|min:1|max:100'
-		]);
-
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-		$profile = $napi ? AccountService::get($id, true) : AccountService::getMastodon($id, true);
-
-        if(!$profile || !isset($profile['id']) || !$user) {
-        	return $this->json(['error' => 'Account not found'], 404);
-        }
-
-		$limit = $request->limit ?? 20;
-		$max_id = $request->max_id;
-		$min_id = $request->min_id;
-
-		if(!$max_id && !$min_id) {
-			$min_id = 1;
-		}
-
-		$pid = $request->user()->profile_id;
-		$scope = $request->only_media == true ?
-			['photo', 'photo:album', 'video', 'video:album'] :
-			['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
-
-		if($request->only_media && $request->has('media_type')) {
-			$mt = $request->input('media_type');
-			if($mt == 'video') {
-				$scope = ['video', 'video:album'];
-			}
-		}
-
-		if(intval($pid) === intval($profile['id'])) {
-			$visibility = ['public', 'unlisted', 'private'];
-		} else if($profile['locked']) {
-			$following = FollowerService::follows($pid, $profile['id']);
-			if(!$following) {
-				return response('', 403);
-			}
-			$visibility = ['public', 'unlisted', 'private'];
-		} else {
-			$following = FollowerService::follows($pid, $profile['id']);
-			$visibility = $following ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
-		}
-
-		$dir = $min_id ? '>' : '<';
-		$id = $min_id ?? $max_id;
-		$res = Status::whereProfileId($profile['id'])
-		->whereNull('in_reply_to_id')
-		->whereNull('reblog_of_id')
-		->whereIn('type', $scope)
-		->where('id', $dir, $id)
-		->whereIn('scope', $visibility)
-		->limit($limit)
-		->orderByDesc('id')
-		->get()
-		->map(function($s) use($user, $napi, $profile) {
-            try {
-                $status = $napi ? StatusService::get($s->id, false) : StatusService::getMastodon($s->id, false);
-            } catch (\Exception $e) {
-                return false;
+            $res = AccountService::getMastodon($user->profile_id, true);
+            $res['bio'] = strip_tags($res['note']);
+            $res = array_merge($res, $other);
+        }
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/accounts/{id}/followers
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountFollowersById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $account = AccountService::get($id);
+        abort_if(! $account, 404);
+        abort_if(isset($account['moved'], $account['moved']['id']), 404, 'Account moved');
+        $pid = $request->user()->profile_id;
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+        ]);
+        $limit = $request->input('limit', 10);
+        if ($limit > 80) {
+            $limit = 80;
+        }
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+
+        if ($account && strpos($account['acct'], '@') != -1) {
+            $domain = parse_url($account['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        if (intval($pid) !== intval($account['id'])) {
+            if ($account['locked']) {
+                if (! FollowerService::follows($pid, $account['id'])) {
+                    return [];
+                }
             }
 
-			if($profile) {
-				$status['account'] = $profile;
-			}
-
-			if($user && $status) {
-				$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
-                $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
-                $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
-			}
-			return $status;
-		})
-		->filter(function($s) {
-			return $s;
-		})
-		->values();
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/follow
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\RelationshipTransformer
-	 */
-	public function accountFollowById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-
-		$target = Profile::where('id', '!=', $user->profile_id)
-			->whereNull('status')
-			->findOrFail($id);
-
-		$private = (bool) $target->is_private;
-		$remote = (bool) $target->domain;
-		$blocked = UserFilter::whereUserId($target->id)
-				->whereFilterType('block')
-				->whereFilterableId($user->profile_id)
-				->whereFilterableType('App\Profile')
-				->exists();
-
-		if($blocked == true) {
-			abort(400, 'You cannot follow this user.');
-		}
-
-		$isFollowing = Follower::whereProfileId($user->profile_id)
-			->whereFollowingId($target->id)
-			->exists();
-
-		// Following already, return empty relationship
-		if($isFollowing == true) {
-			$res = RelationshipService::get($user->profile_id, $target->id) ?? [];
-			return $this->json($res);
-		}
-
-		// Rate limits, max 7500 followers per account
-		if($user->profile->following_count && $user->profile->following_count >= Follower::MAX_FOLLOWING) {
-			abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
-		}
-
-		if($private == true) {
-			$follow = FollowRequest::firstOrCreate([
-				'follower_id' => $user->profile_id,
-				'following_id' => $target->id
-			]);
-			if($remote == true && config('federation.activitypub.remoteFollow') == true) {
-				(new FollowerController())->sendFollow($user->profile, $target);
-			}
-		} else {
-			$follower = Follower::firstOrCreate([
-				'profile_id' => $user->profile_id,
-				'following_id' => $target->id
-			]);
-
-			if($remote == true && config('federation.activitypub.remoteFollow') == true) {
-				(new FollowerController())->sendFollow($user->profile, $target);
-			}
-			FollowPipeline::dispatch($follower)->onQueue('high');
-		}
-
-		RelationshipService::refresh($user->profile_id, $target->id);
-		Cache::forget('profile:following:'.$target->id);
-		Cache::forget('profile:followers:'.$target->id);
-		Cache::forget('profile:following:'.$user->profile_id);
-		Cache::forget('profile:followers:'.$user->profile_id);
-		Cache::forget('api:local:exp:rec:'.$user->profile_id);
-		Cache::forget('user:account:id:'.$target->user_id);
-		Cache::forget('user:account:id:'.$user->id);
-		Cache::forget('profile:follower_count:'.$target->id);
-		Cache::forget('profile:follower_count:'.$user->profile_id);
-		Cache::forget('profile:following_count:'.$target->id);
-		Cache::forget('profile:following_count:'.$user->profile_id);
-		AccountService::del($user->profile_id);
-		AccountService::del($target->id);
-
-		$res = RelationshipService::get($user->profile_id, $target->id);
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/unfollow
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\RelationshipTransformer
-	 */
-	public function accountUnfollowById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-
-		$target = Profile::where('id', '!=', $user->profile_id)
-			->whereNull('status')
-			->findOrFail($id);
-
-		$private = (bool) $target->is_private;
-		$remote = (bool) $target->domain;
-
-		$isFollowing = Follower::whereProfileId($user->profile_id)
-			->whereFollowingId($target->id)
-			->exists();
-
-		if($isFollowing == false) {
-			$followRequest = FollowRequest::whereFollowerId($user->profile_id)
-				->whereFollowingId($target->id)
-				->first();
-			if($followRequest) {
-				$followRequest->delete();
-				RelationshipService::refresh($target->id, $user->profile_id);
-			}
-			$resource = new Fractal\Resource\Item($target, new RelationshipTransformer());
-			$res = $this->fractal->createData($resource)->toArray();
-
-			return $this->json($res);
-		}
-
-		Follower::whereProfileId($user->profile_id)
-			->whereFollowingId($target->id)
-			->delete();
-
-		UnfollowPipeline::dispatch($user->profile_id, $target->id)->onQueue('high');
-
-		if($remote == true && config('federation.activitypub.remoteFollow') == true) {
-			(new FollowerController())->sendUndoFollow($user->profile, $target);
-		}
-
-		RelationshipService::refresh($user->profile_id, $target->id);
-		Cache::forget('profile:following:'.$target->id);
-		Cache::forget('profile:followers:'.$target->id);
-		Cache::forget('profile:following:'.$user->profile_id);
-		Cache::forget('profile:followers:'.$user->profile_id);
-		Cache::forget('api:local:exp:rec:'.$user->profile_id);
-		Cache::forget('user:account:id:'.$target->user_id);
-		Cache::forget('user:account:id:'.$user->id);
-		Cache::forget('profile:follower_count:'.$target->id);
-		Cache::forget('profile:follower_count:'.$user->profile_id);
-		Cache::forget('profile:following_count:'.$target->id);
-		Cache::forget('profile:following_count:'.$user->profile_id);
-		AccountService::del($user->profile_id);
-		AccountService::del($target->id);
-
-		$res = RelationshipService::get($user->profile_id, $target->id);
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/accounts/relationships
-	 *
-	 * @param  array|integer  $id
-	 *
-	 * @return \App\Services\RelationshipService
-	 */
-	public function accountRelationshipsById(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'id'    => 'required|array|min:1|max:20',
-			'id.*'  => 'required|integer|min:1|max:' . PHP_INT_MAX
-		]);
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-		$pid = $request->user()->profile_id ?? $request->user()->profile->id;
-		$res = collect($request->input('id'))
-			->filter(function($id) use($pid) {
-				return intval($id) !== intval($pid);
-			})
-			->map(function($id) use($pid, $napi) {
-				return $napi ?
-				 RelationshipService::getWithDate($pid, $id) :
-				 RelationshipService::get($pid, $id);
-		});
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/accounts/search
-	 *
-	 *
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountSearch(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q'         => 'required|string|min:1|max:255',
-			'limit'     => 'nullable|integer|min:1|max:40',
-			'resolve'   => 'nullable'
-		]);
-
-		$user = $request->user();
-		$query = $request->input('q');
-		$limit = $request->input('limit') ?? 20;
-		$resolve = (bool) $request->input('resolve', false);
-		$q = '%' . $query . '%';
-
-		$profiles = Cache::remember('api:v1:accounts:search:' . sha1($query) . ':limit:' . $limit, 86400, function() use($q, $limit) {
-            return Profile::whereNull('status')
-    			->where('username', 'like', $q)
-    			->orWhere('name', 'like', $q)
-    			->limit($limit)
-    			->pluck('id')
-                ->map(function($id) {
-                    return AccountService::getMastodon($id);
+            if (AccountService::hiddenFollowers($id)) {
+                return [];
+            }
+
+            if ($request->has('page') && $request->user()->is_admin == false) {
+                $page = (int) $request->input('page');
+                if (($page * $limit) >= 100) {
+                    return [];
+                }
+            }
+        }
+        if ($request->has('page')) {
+            $res = DB::table('followers')
+                ->select('id', 'profile_id', 'following_id')
+                ->whereFollowingId($account['id'])
+                ->orderByDesc('id')
+                ->simplePaginate($limit)
+                ->map(function ($follower) use ($napi) {
+                    return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
                 })
-                ->filter(function($account) {
+                ->filter(function ($account) {
                     return $account && isset($account['id']);
-                });
-        });
+                })
+                ->values()
+                ->toArray();
 
-		return $this->json($profiles);
-	}
-
-	/**
-	 * GET /api/v1/blocks
-	 *
-	 *
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountBlocks(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit'     => 'nullable|integer|min:1|max:40',
-			'page'      => 'nullable|integer|min:1|max:10'
-		]);
-
-		$user = $request->user();
-		$limit = $request->input('limit') ?? 40;
-
-		$blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id')
-			->whereUserId($user->profile_id)
-			->whereFilterableType('App\Profile')
-			->whereFilterType('block')
-			->orderByDesc('id')
-			->simplePaginate($limit)
-			->pluck('filterable_id')
-			->map(function($id) {
-				return AccountService::get($id, true);
-			})
-			->filter(function($account) {
-				return $account && isset($account['id']);
-			})
-			->values();
-
-		return $this->json($blocked);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/block
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\RelationshipTransformer
-	 */
-	public function accountBlockById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$pid = $user->profile_id ?? $user->profile->id;
-
-		if(intval($id) === intval($pid)) {
-			abort(400, 'You cannot block yourself');
-		}
-
-		$profile = Profile::findOrFail($id);
-
-		if($profile->user && $profile->user->is_admin == true) {
-			abort(400, 'You cannot block an admin');
-		}
-
-		$count = UserFilterService::blockCount($pid);
-		$maxLimit = intval(config('instance.user_filters.max_user_blocks'));
-		if($count == 0) {
-			$filterCount = UserFilter::whereUserId($pid)
-				->whereFilterType('block')
-				->get()
-				->map(function($rec) {
-					return AccountService::get($rec->filterable_id, true);
-				})
-				->filter(function($account) {
-					return $account && isset($account['id']);
-				})
-				->values()
-				->count();
-			abort_if($filterCount >= $maxLimit, 422, AccountController::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
-		} else {
-			abort_if($count >= $maxLimit, 422, AccountController::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
-		}
-
-		$followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first();
-		if($followed) {
-			$followed->delete();
-			$profile->following_count = Follower::whereProfileId($profile->id)->count();
-			$profile->save();
-			$selfProfile = $user->profile;
-			$selfProfile->followers_count = Follower::whereFollowingId($pid)->count();
-			$selfProfile->save();
-			FollowerService::remove($profile->id, $pid);
-			AccountService::del($pid);
-			AccountService::del($profile->id);
-		}
-
-		$following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first();
-		if($following) {
-			$following->delete();
-			$profile->followers_count = Follower::whereFollowingId($profile->id)->count();
-			$profile->save();
-			$selfProfile = $user->profile;
-			$selfProfile->following_count = Follower::whereProfileId($pid)->count();
-			$selfProfile->save();
-			FollowerService::remove($pid, $profile->pid);
-			AccountService::del($pid);
-			AccountService::del($profile->id);
-		}
-
-		Notification::whereProfileId($pid)
-			->whereActorId($profile->id)
-			->get()
-			->map(function($n) use($pid) {
-				NotificationService::del($pid, $n['id']);
-				$n->forceDelete();
-		});
-
-		$filter = UserFilter::firstOrCreate([
-			'user_id'         => $pid,
-			'filterable_id'   => $profile->id,
-			'filterable_type' => 'App\Profile',
-			'filter_type'     => 'block',
-		]);
-
-		UserFilterService::block($pid, $id);
-		RelationshipService::refresh($pid, $id);
-		$resource = new Fractal\Resource\Item($profile, new RelationshipTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/unblock
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\RelationshipTransformer
-	 */
-	public function accountUnblockById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$pid = $user->profile_id ?? $user->profile->id;
-
-		if(intval($id) === intval($pid)) {
-			abort(400, 'You cannot unblock yourself');
-		}
-
-		$profile = Profile::findOrFail($id);
-
-		$filter = UserFilter::whereUserId($pid)
-			->whereFilterableId($profile->id)
-			->whereFilterableType('App\Profile')
-			->whereFilterType('block')
-			->first();
-
-		if($filter) {
-			$filter->delete();
-			UserFilterService::unblock($pid, $profile->id);
-			RelationshipService::refresh($pid, $id);
-		}
-
-		$resource = new Fractal\Resource\Item($profile, new RelationshipTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/custom_emojis
-	 *
-	 * Return custom emoji
-	 *
-	 * @return array
-	 */
-	public function customEmojis()
-	{
-		return response(CustomEmojiService::all())->header('Content-Type', 'application/json');
-	}
-
-	/**
-	 * GET /api/v1/domain_blocks
-	 *
-	 * Return empty array
-	 *
-	 * @return array
-	 */
-	public function accountDomainBlocks(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		return response()->json([]);
-	}
-
-	/**
-	 * GET /api/v1/endorsements
-	 *
-	 * Return empty array
-	 *
-	 * @return array
-	 */
-	public function accountEndorsements(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		return response()->json([]);
-	}
-
-	/**
-	 * GET /api/v1/favourites
-	 *
-	 * Returns collection of liked statuses
-	 *
-	 * @return \App\Transformer\Api\StatusTransformer
-	 */
-	public function accountFavourites(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		$this->validate($request, [
-			'limit' => 'sometimes|integer|min:1|max:20'
-		]);
-
-		$user = $request->user();
-		$maxId = $request->input('max_id');
-		$minId = $request->input('min_id');
-		$limit = $request->input('limit') ?? 10;
-
-		$res = Like::whereProfileId($user->profile_id)
-			->when($maxId, function($q, $maxId) {
-				return $q->where('id', '<', $maxId);
-			})
-			->when($minId, function($q, $minId) {
-				return $q->where('id', '>', $minId);
-			})
-			->orderByDesc('id')
-			->limit($limit)
-			->get()
-			->map(function($like) {
-				$status =  StatusService::getMastodon($like['status_id'], false);
-				$status['favourited'] = true;
-				$status['like_id'] = $like->id;
-				$status['liked_at'] = str_replace('+00:00', 'Z', $like->created_at->format(DATE_RFC3339_EXTENDED));
-				return $status;
-			})
-			->filter(function($status) {
-				return $status && isset($status['id'], $status['like_id']);
-			})
-			->values();
-
-		if($res->count()) {
-			$ids = $res->map(function($status) {
-				return $status['like_id'];
-			});
-			$max = $ids->max();
-			$min = $ids->min();
-
-			$baseUrl = config('app.url') . '/api/v1/favourites?limit=' . $limit . '&';
-			$link = '<'.$baseUrl.'max_id='.$max.'>; rel="next",<'.$baseUrl.'min_id='.$min.'>; rel="prev"';
-			return $this->json($res, 200, ['Link' => $link]);
-		} else {
-			return $this->json($res);
-		}
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/favourite
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\StatusTransformer
-	 */
-	public function statusFavouriteById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-
-		$status = StatusService::getMastodon($id, false);
-
-		abort_unless($status, 400);
-
-		$spid = $status['account']['id'];
-
-		if(intval($spid) !== intval($user->profile_id)) {
-			if($status['visibility'] == 'private') {
-				abort_if(!FollowerService::follows($user->profile_id, $spid), 403);
-			} else {
-				abort_if(!in_array($status['visibility'], ['public','unlisted']), 403);
-			}
-		}
-
-		abort_if(
-			Like::whereProfileId($user->profile_id)
-				->where('created_at', '>', now()->subDay())
-				->count() >= Like::MAX_PER_DAY,
-			429
-		);
-
-		$blocks = UserFilterService::blocks($spid);
-		if($blocks && in_array($user->profile_id, $blocks)) {
-			abort(422);
-		}
-
-		$like = Like::firstOrCreate([
-			'profile_id' => $user->profile_id,
-			'status_id' => $status['id']
-		]);
-
-		if($like->wasRecentlyCreated == true) {
-			$like->status_profile_id = $spid;
-			$like->is_comment = !empty($status['in_reply_to_id']);
-			$like->save();
-			Status::findOrFail($status['id'])->update([
-				'likes_count' => ($status['favourites_count'] ?? 0) + 1
-			]);
-			LikePipeline::dispatch($like)->onQueue('feed');
-		}
-
-		$status['favourited'] = true;
-		$status['favourites_count'] = $status['favourites_count'] + 1;
-		return $this->json($status);
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/unfavourite
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return \App\Transformer\Api\StatusTransformer
-	 */
-	public function statusUnfavouriteById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-
-		$status = Status::findOrFail($id);
-
-		if(intval($status->profile_id) !== intval($user->profile_id)) {
-			if($status->scope == 'private') {
-				abort_if(!$status->profile->followedBy($user->profile), 403);
-			} else {
-				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
-			}
-		}
-
-		$like = Like::whereProfileId($user->profile_id)
-			->whereStatusId($status->id)
-			->first();
-
-		if($like) {
-			$like->forceDelete();
-			$status->likes_count = $status->likes_count > 1 ? $status->likes_count - 1 : 0;
-			$status->save();
-		}
-
-		StatusService::del($status->id);
-
-		$res = StatusService::getMastodon($status->id, false);
-		$res['favourited'] = false;
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/filters
-	 *
-	 *  Return empty response since we filter server side
-	 *
-	 * @return array
-	 */
-	public function accountFilters(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		return response()->json([]);
-	}
-
-	/**
-	 * GET /api/v1/follow_requests
-	 *
-	 *  Return array of Accounts that have sent follow requests
-	 *
-	 * @return \App\Transformer\Api\AccountTransformer
-	 */
-	public function accountFollowRequests(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		$this->validate($request, [
-			'limit' => 'sometimes|integer|min:1|max:100'
-		]);
-
-		$user = $request->user();
-
-		$res = FollowRequest::whereFollowingId($user->profile->id)
-			->limit($request->input('limit', 40))
-			->pluck('follower_id')
-			->map(function($id) {
-				return AccountService::getMastodon($id, true);
-			})
-			->filter(function($acct) {
-				return $acct && isset($acct['id']);
-			})
-			->values();
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/follow_requests/{id}/authorize
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return null
-	 */
-	public function accountFollowRequestAccept(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-		$pid = $request->user()->profile_id;
-		$target = AccountService::getMastodon($id);
-
-		if(!$target) {
-			return response()->json(['error' => 'Record not found'], 404);
-		}
-
-		$followRequest = FollowRequest::whereFollowingId($pid)->whereFollowerId($id)->first();
-
-		if(!$followRequest) {
-			return response()->json(['error' => 'Record not found'], 404);
-		}
-
-		$follower = $followRequest->follower;
-		$follow = new Follower();
-		$follow->profile_id = $follower->id;
-		$follow->following_id = $pid;
-		$follow->save();
-
-		$profile = Profile::findOrFail($pid);
-		$profile->followers_count++;
-		$profile->save();
-		AccountService::del($profile->id);
-
-		$profile = Profile::findOrFail($follower->id);
-		$profile->following_count++;
-		$profile->save();
-		AccountService::del($profile->id);
-
-		if($follower->domain != null && $follower->private_key === null) {
-			FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow');
-		} else {
-			FollowPipeline::dispatch($follow);
-			$followRequest->delete();
-		}
-
-		RelationshipService::refresh($pid, $id);
-		$res = RelationshipService::get($pid, $id);
-		$res['followed_by'] = true;
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/follow_requests/{id}/reject
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return null
-	 */
-	public function accountFollowRequestReject(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-		$pid = $request->user()->profile_id;
-		$target = AccountService::getMastodon($id);
-
-		if(!$target) {
-			return response()->json(['error' => 'Record not found'], 404);
-		}
-
-		$followRequest = FollowRequest::whereFollowingId($pid)->whereFollowerId($id)->first();
-
-		if(!$followRequest) {
-			return response()->json(['error' => 'Record not found'], 404);
-		}
-
-		$follower = $followRequest->follower;
-
-		if($follower->domain != null && $follower->private_key === null) {
-			FollowRejectPipeline::dispatch($followRequest)->onQueue('follow');
-		} else {
-			$followRequest->delete();
-		}
-
-		RelationshipService::refresh($pid, $id);
-		$res = RelationshipService::get($pid, $id);
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/suggestions
-	 *
-	 *   Return empty array as we don't support suggestions
-	 *
-	 * @return null
-	 */
-	public function accountSuggestions(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		// todo
-
-		return response()->json([]);
-	}
-
-	/**
-	 * GET /api/v1/instance
-	 *
-	 *   Information about the server.
-	 *
-	 * @return Instance
-	 */
-	public function instance(Request $request)
-	{
-		$res = Cache::remember('api:v1:instance-data-response-v1', 1800, function () {
-			$contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
-				if(config_cache('instance.admin.pid')) {
-					return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
-				}
-				$admin = User::whereIsAdmin(true)->first();
-				return $admin && isset($admin->profile_id) ?
-					AccountService::getMastodon($admin->profile_id, true) :
-					null;
-			});
-
-			$stats = Cache::remember('api:v1:instance-data:stats', 43200, function () {
-				return [
-					'user_count' => User::count(),
-					'status_count' => Status::whereNull('uri')->count(),
-					'domain_count' => Instance::count(),
-				];
-			});
-
-			$rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
-				return config_cache('app.rules') ?
-					collect(json_decode(config_cache('app.rules'), true))
-					->map(function($rule, $key) {
-						$id = $key + 1;
-						return [
-							'id' => "{$id}",
-							'text' => $rule
-						];
-					})
-					->toArray() : [];
-			});
-
-			return [
-				'uri' => config('pixelfed.domain.app'),
-				'title' => config('app.name'),
-				'short_description' => config_cache('app.short_description'),
-				'description' => config_cache('app.description'),
-				'email' => config('instance.email'),
-				'version' => '2.7.2 (compatible; Pixelfed ' . config('pixelfed.version') .')',
-				'urls' => [
-					'streaming_api' => 'wss://' . config('pixelfed.domain.app')
-				],
-				'stats' => $stats,
-				'thumbnail' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
-				'languages' => [config('app.locale')],
-				'registrations' => (bool) config_cache('pixelfed.open_registration'),
-				'approval_required' => false,
-				'contact_account' => $contact,
-				'rules' => $rules,
-				'configuration' => [
-					'media_attachments' => [
-						'image_matrix_limit' => 16777216,
-						'image_size_limit' => config('pixelfed.max_photo_size') * 1024,
-						'supported_mime_types' => explode(',', config('pixelfed.media_types')),
-						'video_frame_rate_limit' => 120,
-						'video_matrix_limit' => 2304000,
-						'video_size_limit' => config('pixelfed.max_photo_size') * 1024,
-					],
-					'polls' => [
-						'max_characters_per_option' => 50,
-						'max_expiration' => 2629746,
-						'max_options' => 4,
-						'min_expiration' => 300
-					],
-					'statuses' => [
-						'characters_reserved_per_url' => 23,
-						'max_characters' => (int) config('pixelfed.max_caption_length'),
-						'max_media_attachments' => (int) config('pixelfed.max_album_length')
-					]
-				]
-			];
-		});
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/lists
-	 *
-	 *   Return empty array as we don't support lists
-	 *
-	 * @return null
-	 */
-	public function accountLists(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		return response()->json([]);
-	}
-
-	/**
-	 * GET /api/v1/accounts/{id}/lists
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return null
-	 */
-	public function accountListsById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		return response()->json([]);
-	}
-
-	/**
-	 * POST /api/v1/media
-	 *
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaUpload(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'file.*' => [
-				'required_without:file',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-			'file' => [
-				'required_without:file.*',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-		  'filter_name' => 'nullable|string|max:24',
-		  'filter_class' => 'nullable|alpha_dash|max:24',
-		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
-		]);
-
-		$user = $request->user();
-
-		if($user->last_active_at == null) {
-			return [];
-		}
-
-		if(empty($request->file('file'))) {
-			return response('', 422);
-		}
-
-		$limitKey = 'compose:rate-limit:media-upload:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
-
-			return $dailyLimit >= 1250;
-		});
-		abort_if($limitReached == true, 429);
-
-		$profile = $user->profile;
-
-		if(config_cache('pixelfed.enforce_account_limit') == true) {
-			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
-				return Media::whereUserId($user->id)->sum('size') / 1000;
-			});
-			$limit = (int) config_cache('pixelfed.max_account_size');
-			if ($size >= $limit) {
-			   abort(403, 'Account size limit reached.');
-			}
-		}
-
-		$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
-		$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
-
-		$photo = $request->file('file');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), $mimes) == false) {
-			abort(403, 'Invalid or unsupported mime type.');
-		}
-
-		$storagePath = MediaPathService::get($user, 2);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-		$license = null;
-		$mime = $photo->getMimeType();
-
-		// if($photo->getMimeType() == 'image/heic') {
-		// 	abort_if(config('image.driver') !== 'imagick', 422, 'Invalid media type');
-		// 	abort_if(!in_array('HEIC', \Imagick::queryformats()), 422, 'Unsupported media type');
-		// 	$oldPath = $path;
-		// 	$path = str_replace('.heic', '.jpg', $path);
-		// 	$mime = 'image/jpeg';
-		// 	\Image::make($photo)->save(storage_path("app/{$path}"));
-		// 	@unlink(storage_path("app/{$oldPath}"));
-		// }
-
-		$settings = UserSetting::whereUserId($user->id)->first();
-
-		if($settings && !empty($settings->compose_settings)) {
-			$compose = $settings->compose_settings;
-
-			if(isset($compose['default_license']) && $compose['default_license'] != 1) {
-				$license = $compose['default_license'];
-			}
-		}
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		$media = new Media();
-		$media->status_id = null;
-		$media->profile_id = $profile->id;
-		$media->user_id = $user->id;
-		$media->media_path = $path;
-		$media->original_sha256 = $hash;
-		$media->size = $photo->getSize();
-		$media->mime = $mime;
-		$media->caption = $request->input('description');
-		$media->filter_class = $filterClass;
-		$media->filter_name = $filterName;
-		if($license) {
-			$media->license = $license;
-		}
-		$media->save();
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-				ImageOptimize::dispatch($media)->onQueue('mmo');
-				break;
-
-			case 'video/mp4':
-				VideoThumbnail::dispatch($media)->onQueue('mmo');
-				$preview_url = '/storage/no-preview.png';
-				$url = '/storage/no-preview.png';
-				break;
-		}
-
-		Cache::forget($limitKey);
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		$res['preview_url'] = $media->url(). '?v=' . time();
-		$res['url'] = $media->url(). '?v=' . time();
-		return $this->json($res);
-	}
-
-	/**
-	 * PUT /api/v1/media/{id}
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaUpdate(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
-		]);
-
-		$user = $request->user();
-
-		$media = Media::whereUserId($user->id)
-			->whereProfileId($user->profile_id)
-			->findOrFail($id);
-
-		$executed = RateLimiter::attempt(
-			'media:update:'.$user->id,
-			10,
-			function() use($media, $request) {
-				$caption = Purify::clean($request->input('description'));
-
-				if($caption != $media->caption) {
-					$media->caption = $caption;
-					$media->save();
-
-					if($media->status_id) {
-						MediaService::del($media->status_id);
-						StatusService::del($media->status_id);
-					}
-				}
-		});
-
-		if(!$executed) {
-			return response()->json([
-				'error' => 'Too many attempts. Try again in a few minutes.'
-			], 429);
-		};
-
-		$fractal = new Fractal\Manager();
-		$fractal->setSerializer(new ArraySerializer());
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		return $this->json($fractal->createData($resource)->toArray());
-	}
-
-	/**
-	 * GET /api/v1/media/{id}
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaGet(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-
-		$media = Media::whereUserId($user->id)
-			->whereNull('status_id')
-			->findOrFail($id);
-
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v2/media
-	 *
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaUploadV2(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-		  	'file.*' => [
-				'required_without:file',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-			'file' => [
-				'required_without:file.*',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-		  'filter_name' => 'nullable|string|max:24',
-		  'filter_class' => 'nullable|alpha_dash|max:24',
-		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'),
-		  'replace_id' => 'sometimes'
-		]);
-
-		$user = $request->user();
-
-		if($user->last_active_at == null) {
-			return [];
-		}
-
-		if(empty($request->file('file'))) {
-			return response('', 422);
-		}
-
-		$limitKey = 'compose:rate-limit:media-upload:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
-
-			return $dailyLimit >= 1250;
-		});
-		abort_if($limitReached == true, 429);
-
-		$profile = $user->profile;
-
-		if(config_cache('pixelfed.enforce_account_limit') == true) {
-			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
-				return Media::whereUserId($user->id)->sum('size') / 1000;
-			});
-			$limit = (int) config_cache('pixelfed.max_account_size');
-			if ($size >= $limit) {
-			   abort(403, 'Account size limit reached.');
-			}
-		}
-
-		$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
-		$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
-
-		$photo = $request->file('file');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), $mimes) == false) {
-			abort(403, 'Invalid or unsupported mime type.');
-		}
-
-		$storagePath = MediaPathService::get($user, 2);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-		$license = null;
-		$mime = $photo->getMimeType();
-
-		$settings = UserSetting::whereUserId($user->id)->first();
-
-		if($settings && !empty($settings->compose_settings)) {
-			$compose = $settings->compose_settings;
-
-			if(isset($compose['default_license']) && $compose['default_license'] != 1) {
-				$license = $compose['default_license'];
-			}
-		}
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		if($request->has('replace_id')) {
-			$rpid = $request->input('replace_id');
-			$removeMedia = Media::whereNull('status_id')
-				->whereUserId($user->id)
-				->whereProfileId($profile->id)
-				->where('created_at', '>', now()->subHours(2))
-				->find($rpid);
-			if($removeMedia) {
-				$dateTime = Carbon::now();
-				MediaDeletePipeline::dispatch($removeMedia)
-					->onQueue('mmo')
-					->delay($dateTime->addMinutes(15));
-			}
-		}
-
-		$media = new Media();
-		$media->status_id = null;
-		$media->profile_id = $profile->id;
-		$media->user_id = $user->id;
-		$media->media_path = $path;
-		$media->original_sha256 = $hash;
-		$media->size = $photo->getSize();
-		$media->mime = $mime;
-		$media->caption = $request->input('description');
-		$media->filter_class = $filterClass;
-		$media->filter_name = $filterName;
-		if($license) {
-			$media->license = $license;
-		}
-		$media->save();
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-				ImageOptimize::dispatch($media)->onQueue('mmo');
-				break;
-
-			case 'video/mp4':
-				VideoThumbnail::dispatch($media)->onQueue('mmo');
-				$preview_url = '/storage/no-preview.png';
-				$url = '/storage/no-preview.png';
-				break;
-		}
-
-		Cache::forget($limitKey);
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		$res['preview_url'] = $media->url(). '?v=' . time();
-		$res['url'] = null;
-		return $this->json($res, 202);
-	}
-
-	/**
-	 * GET /api/v1/mutes
-	 *
-	 *
-	 * @return AccountTransformer
-	 */
-	public function accountMutes(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'nullable|integer|min:1|max:40'
-		]);
-
-		$user = $request->user();
-		$limit = $request->input('limit', 40);
-
-		$mutes = UserFilter::whereUserId($user->profile_id)
-			->whereFilterableType('App\Profile')
-			->whereFilterType('mute')
-			->orderByDesc('id')
-			->simplePaginate($limit)
-			->pluck('filterable_id')
-			->map(function($id) {
-				return AccountService::get($id, true);
-			})
-			->filter(function($account) {
-				return $account && isset($account['id']);
-			})
-			->values();
-
-		return $this->json($mutes);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/mute
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return RelationshipTransformer
-	 */
-	public function accountMuteById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$pid = $user->profile_id;
-
-        if(intval($pid) === intval($id)) {
-            return $this->json(['error' => 'You cannot mute yourself'], 500);
+            return $this->json($res);
         }
 
-		$account = Profile::findOrFail($id);
-
-		$count = UserFilterService::muteCount($pid);
-		$maxLimit = intval(config('instance.user_filters.max_user_mutes'));
-		if($count == 0) {
-			$filterCount = UserFilter::whereUserId($pid)
-				->whereFilterType('mute')
-				->get()
-				->map(function($rec) {
-					return AccountService::get($rec->filterable_id, true);
-				})
-				->filter(function($account) {
-					return $account && isset($account['id']);
-				})
-				->values()
-				->count();
-			abort_if($filterCount >= $maxLimit, 422, AccountController::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
-		} else {
-			abort_if($count >= $maxLimit, 422, AccountController::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
-		}
-
-		$filter = UserFilter::firstOrCreate([
-			'user_id'         => $pid,
-			'filterable_id'   => $account->id,
-			'filterable_type' => 'App\Profile',
-			'filter_type'     => 'mute',
-		]);
-
-		RelationshipService::refresh($pid, $id);
-
-		$resource = new Fractal\Resource\Item($account, new RelationshipTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/accounts/{id}/unmute
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return RelationshipTransformer
-	 */
-	public function accountUnmuteById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$pid = $user->profile_id;
-
-        if(intval($pid) === intval($id)) {
-            return $this->json(['error' => 'You cannot unmute yourself'], 500);
+        $paginator = DB::table('followers')
+            ->select('id', 'profile_id', 'following_id')
+            ->whereFollowingId($account['id'])
+            ->orderByDesc('id')
+            ->cursorPaginate($limit)
+            ->withQueryString();
+
+        $link = null;
+
+        if ($paginator->onFirstPage()) {
+            if ($paginator->hasMorePages()) {
+                $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        } else {
+            if ($paginator->previousPageUrl()) {
+                $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+            }
+
+            if ($paginator->hasMorePages()) {
+                $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
         }
 
-		$profile = Profile::findOrFail($id);
-
-		$filter = UserFilter::whereUserId($pid)
-			->whereFilterableId($profile->id)
-			->whereFilterableType('App\Profile')
-			->whereFilterType('mute')
-			->first();
-
-		if($filter) {
-			$filter->delete();
-			UserFilterService::unmute($pid, $profile->id);
-			RelationshipService::refresh($pid, $id);
-		}
-
-		$resource = new Fractal\Resource\Item($profile, new RelationshipTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/notifications
-	 *
-	 *
-	 * @return NotificationTransformer
-	 */
-	public function accountNotifications(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'nullable|integer|min:1|max:100',
-			'min_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
-			'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
-			'since_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
-		]);
-
-		$pid = $request->user()->profile_id;
-		$limit = $request->input('limit', 20);
-
-		$since = $request->input('since_id');
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-
-		if(!$since && !$min && !$max) {
-			$min = 1;
-		}
-
-		$maxId = null;
-		$minId = null;
-
-		if($max) {
-			$res = NotificationService::getMaxMastodon($pid, $max, $limit);
-			$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		} else {
-			$res = NotificationService::getMinMastodon($pid, $min ?? $since, $limit);
-			$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		}
-
-		if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
-			Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-			NotificationService::warmCache($pid, 400, true);
-		}
-
-		$baseUrl = config('app.url') . '/api/v1/notifications?limit=' . $limit . '&';
-
-		if($minId == $maxId) {
-			$minId = null;
-		}
-
-		if($maxId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
-		}
-
-		if($minId) {
-			$link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		if($maxId && $minId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		$headers = isset($link) ? ['Link' => $link] : [];
-		return $this->json($res, 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/timelines/home
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function timelineHome(Request $request)
-	{
-		$this->validate($request,[
-		  'page'        => 'sometimes|integer|max:40',
-		  'min_id'      => 'sometimes|integer|min:0|max:' . PHP_INT_MAX,
-		  'max_id'      => 'sometimes|integer|min:0|max:' . PHP_INT_MAX,
-		  'limit'       => 'sometimes|integer|min:1|max:100',
-          'include_reblogs' => 'sometimes',
-		]);
-
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-		$page = $request->input('page');
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-		$limit = $request->input('limit') ?? 20;
-		$pid = $request->user()->profile_id;
-        $includeReblogs = $request->filled('include_reblogs');
-        $nullFields = $includeReblogs ?
-            ['in_reply_to_id'] :
-            ['in_reply_to_id', 'reblog_of_id'];
-        $inTypes = $includeReblogs ?
-            ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album', 'share'] :
-            ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
-
-		$following = Cache::remember('profile:following:'.$pid, 1209600, function() use($pid) {
-			$following = Follower::whereProfileId($pid)->pluck('following_id');
-			return $following->push($pid)->toArray();
-		});
-
-		if($min || $max) {
-			$dir = $min ? '>' : '<';
-			$id = $min ?? $max;
-			$res = Status::select(
-				'id',
-				'profile_id',
-				'type',
-				'visibility',
-				'in_reply_to_id',
-				'reblog_of_id'
-			)
-			->where('id', $dir, $id)
-			->whereNull($nullFields)
-			->whereIntegerInRaw('profile_id', $following)
-			->whereIn('type', $inTypes)
-			->whereIn('visibility',['public', 'unlisted', 'private'])
-			->orderByDesc('id')
-			->take(($limit * 2))
-			->get()
-			->map(function($s) use($pid, $napi) {
-				try {
-					$account = $napi ? AccountService::get($s['profile_id'], true) : AccountService::getMastodon($s['profile_id'], true);
-					if(!$account) {
-						return false;
-					}
-					$status = $napi ? StatusService::get($s['id'], false) : StatusService::getMastodon($s['id'], false);
-					if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
-						return false;
-					}
-				} catch(\Exception $e) {
-					return false;
-				}
-
-				$status['account'] = $account;
-
-				if($pid) {
-					$status['favourited'] = (bool) LikeService::liked($pid, $s['id']);
-					$status['reblogged'] = (bool) ReblogService::get($pid, $status['id']);
-                    $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
-				}
-				return $status;
-			})
-			->filter(function($status) {
-				return $status && isset($status['account']);
-			})
-            ->map(function($status) use($pid) {
-                if(!empty($status['reblog'])) {
-                    $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']);
-                    $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']);
-                    $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+        $res = $paginator->map(function ($follower) use ($napi) {
+            return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
+        })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values()
+            ->toArray();
+
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/accounts/{id}/following
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountFollowingById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $account = AccountService::get($id);
+        abort_if(! $account, 404);
+        abort_if(isset($account['moved'], $account['moved']['id']), 404, 'Account moved');
+        $pid = $request->user()->profile_id;
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+        ]);
+        $limit = $request->input('limit', 10);
+        if ($limit > 80) {
+            $limit = 80;
+        }
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+
+        if ($account && strpos($account['acct'], '@') != -1) {
+            $domain = parse_url($account['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        if (intval($pid) !== intval($account['id'])) {
+            if ($account['locked']) {
+                if (! FollowerService::follows($pid, $account['id'])) {
+                    return [];
                 }
+            }
 
-                return $status;
+            if (AccountService::hiddenFollowing($id)) {
+                return [];
+            }
+
+            if ($request->has('page') && $request->user()->is_admin == false) {
+                $page = (int) $request->input('page');
+                if (($page * $limit) >= 100) {
+                    return [];
+                }
+            }
+        }
+
+        if ($request->has('page')) {
+            $res = DB::table('followers')
+                ->select('id', 'profile_id', 'following_id')
+                ->whereProfileId($account['id'])
+                ->orderByDesc('id')
+                ->simplePaginate($limit)
+                ->map(function ($follower) use ($napi) {
+                    return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
+                })
+                ->filter(function ($account) {
+                    return $account && isset($account['id']);
+                })
+                ->values()
+                ->toArray();
+
+            return $this->json($res);
+        }
+
+        $paginator = DB::table('followers')
+            ->select('id', 'profile_id', 'following_id')
+            ->whereProfileId($account['id'])
+            ->orderByDesc('id')
+            ->cursorPaginate($limit)
+            ->withQueryString();
+
+        $link = null;
+
+        if ($paginator->onFirstPage()) {
+            if ($paginator->hasMorePages()) {
+                $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        } else {
+            if ($paginator->previousPageUrl()) {
+                $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+            }
+
+            if ($paginator->hasMorePages()) {
+                $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        }
+
+        $res = $paginator->map(function ($follower) use ($napi) {
+            return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
+        })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
             })
-			->take($limit)
-			->values();
-		} else {
-			$res = Status::select(
-				'id',
-				'profile_id',
-				'type',
-				'visibility',
-				'in_reply_to_id',
-				'reblog_of_id',
-			)
-			->whereNull($nullFields)
-			->whereIntegerInRaw('profile_id', $following)
-			->whereIn('type', $inTypes)
-			->whereIn('visibility',['public', 'unlisted', 'private'])
-			->orderByDesc('id')
-			->take(($limit * 2))
-			->get()
-			->map(function($s) use($pid, $napi) {
-				try {
-					$account = $napi ? AccountService::get($s['profile_id'], true) : AccountService::getMastodon($s['profile_id'], true);
-					if(!$account) {
-						return false;
-					}
-					$status = $napi ? StatusService::get($s['id'], false) : StatusService::getMastodon($s['id'], false);
-					if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
-						return false;
-					}
-				} catch(\Exception $e) {
-					return false;
-				}
-
-				$status['account'] = $account;
-
-				if($pid) {
-					$status['favourited'] = (bool) LikeService::liked($pid, $s['id']);
-					$status['reblogged'] = (bool) ReblogService::get($pid, $status['id']);
-                    $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
-				}
-				return $status;
-			})
-			->filter(function($status) {
-				return $status && isset($status['account']);
-			})
-            ->map(function($status) use($pid) {
-                if(!empty($status['reblog'])) {
-                    $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']);
-                    $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']);
-                    $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+            ->values()
+            ->toArray();
+
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/accounts/{id}/statuses
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\StatusTransformer
+     */
+    public function accountStatusesById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $user = $request->user();
+
+        $this->validate($request, [
+            'only_media' => 'nullable',
+            'media_type' => 'sometimes|string|in:photo,video',
+            'pinned' => 'nullable',
+            'exclude_replies' => 'nullable',
+            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'limit' => 'nullable|integer|min:1',
+        ]);
+
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $profile = $napi ? AccountService::get($id, true) : AccountService::getMastodon($id, true);
+
+        if (! $profile || ! isset($profile['id']) || ! $user) {
+            return $this->json(['error' => 'Account not found'], 404);
+        }
+
+        if ($profile && strpos($profile['acct'], '@') != -1) {
+            $domain = parse_url($profile['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $limit = $request->input('limit') ?? 20;
+        if ($limit > 40) {
+            $limit = 40;
+        }
+        $max_id = $request->max_id;
+        $min_id = $request->min_id;
+
+        if (! $max_id && ! $min_id) {
+            $min_id = 1;
+        }
+
+        $pid = $request->user()->profile_id;
+        $scope = $request->only_media == true ?
+            ['photo', 'photo:album', 'video', 'video:album'] :
+            ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
+
+        if ($request->only_media && $request->has('media_type')) {
+            $mt = $request->input('media_type');
+            if ($mt == 'video') {
+                $scope = ['video', 'video:album'];
+            }
+        }
+
+        if (intval($pid) === intval($profile['id'])) {
+            $visibility = ['public', 'unlisted', 'private'];
+        } elseif ($profile['locked']) {
+            $following = FollowerService::follows($pid, $profile['id']);
+            if (! $following) {
+                return response('', 403);
+            }
+            $visibility = ['public', 'unlisted', 'private'];
+        } else {
+            $following = FollowerService::follows($pid, $profile['id']);
+            $visibility = $following ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+        }
+
+        $dir = $min_id ? '>' : '<';
+        $id = $min_id ?? $max_id;
+        $res = Status::select(
+            'profile_id',
+            'in_reply_to_id',
+            'reblog_of_id',
+            'type',
+            'id',
+            'scope'
+        )
+            ->whereProfileId($profile['id'])
+            ->whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->whereIn('type', $scope)
+            ->where('id', $dir, $id)
+            ->whereIn('scope', $visibility)
+            ->limit($limit)
+            ->orderByDesc('id')
+            ->get()
+            ->map(function ($s) use ($user, $napi, $profile) {
+                try {
+                    $status = $napi ? StatusService::get($s->id, false) : StatusService::getMastodon($s->id, false);
+                } catch (\Exception $e) {
+                    return false;
+                }
+
+                if ($profile) {
+                    $status['account'] = $profile;
+                }
+
+                if ($user && $status) {
+                    $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+                    $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
+                    $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
                 }
 
                 return $status;
             })
-			->take($limit)
-			->values();
-		}
-
-		$baseUrl = config('app.url') . '/api/v1/timelines/home?limit=' . $limit . '&';
-		$minId = $res->map(function($s) {
-			return ['id' => $s['id']];
-		})->min('id');
-		$maxId = $res->map(function($s) {
-			return ['id' => $s['id']];
-		})->max('id');
-
-		if($minId == $maxId) {
-			$minId = null;
-		}
-
-		if($maxId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
-		}
-
-		if($minId) {
-			$link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		if($maxId && $minId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		$headers = isset($link) ? ['Link' => $link] : [];
-		return $this->json($res->toArray(), 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/timelines/public
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function timelinePublic(Request $request)
-	{
-		$this->validate($request,[
-		  'min_id'      => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-		  'max_id'      => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-		  'limit'       => 'nullable|integer|max:100',
-		  'remote'		=> 'sometimes',
-		  'local'		=> 'sometimes'
-		]);
-
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-		$limit = $request->input('limit') ?? 20;
-		$user = $request->user();
-		$remote = ($request->has('remote') && $request->input('remote') == true) || ($request->filled('local') && $request->input('local') != true);
-        $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
+            ->filter(function ($s) {
+                return $s;
+            })
+            ->values();
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/follow
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\RelationshipTransformer
+     */
+    public function accountFollowById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('follow'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-follow', $user->id), 403, 'Invalid permissions for this action');
+
+        AccountService::setLastActive($user->id);
+
+        $target = Profile::where('id', '!=', $user->profile_id)
+            ->whereNull('status')
+            ->findOrFail($id);
+
+        abort_if($target && $target->moved_to_profile_id, 400, 'Cannot follow an account that has moved!');
 
-        if((!$request->has('local') || $remote) && config('instance.timeline.network.cached')) {
-			Cache::remember('api:v1:timelines:network:cache_check', 10368000, function() {
-				if(NetworkTimelineService::count() == 0) {
-					NetworkTimelineService::warmCache(true, config('instance.timeline.network.cache_dropoff'));
-				}
-			});
-
-			if ($max) {
-				$feed = NetworkTimelineService::getRankedMaxId($max, $limit + 5);
-			} else if ($min) {
-				$feed = NetworkTimelineService::getRankedMinId($min, $limit + 5);
-			} else {
-				$feed = NetworkTimelineService::get(0, $limit + 5);
-			}
+        if ($target && $target->domain) {
+            $domain = $target->domain;
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $private = (bool) $target->is_private;
+        $remote = (bool) $target->domain;
+        $blocked = UserFilter::whereUserId($target->id)
+            ->whereFilterType('block')
+            ->whereFilterableId($user->profile_id)
+            ->whereFilterableType('App\Profile')
+            ->exists();
+
+        if ($blocked == true) {
+            abort(400, 'You cannot follow this user.');
+        }
+
+        $isFollowing = Follower::whereProfileId($user->profile_id)
+            ->whereFollowingId($target->id)
+            ->exists();
+
+        // Following already, return empty relationship
+        if ($isFollowing == true) {
+            $res = RelationshipService::get($user->profile_id, $target->id) ?? [];
+
+            return $this->json($res);
+        }
+
+        // Rate limits, max 7500 followers per account
+        if ($user->profile->following_count && $user->profile->following_count >= Follower::MAX_FOLLOWING) {
+            abort(400, 'You cannot follow more than '.Follower::MAX_FOLLOWING.' accounts');
+        }
+
+        if ($private == true) {
+            $follow = FollowRequest::firstOrCreate([
+                'follower_id' => $user->profile_id,
+                'following_id' => $target->id,
+            ]);
+            if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
+                (new FollowerController)->sendFollow($user->profile, $target);
+            }
         } else {
-			Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() {
-				if(PublicTimelineService::count() == 0) {
-					PublicTimelineService::warmCache(true, 400);
-				}
-			});
-
-			if ($max) {
-				$feed = PublicTimelineService::getRankedMaxId($max, $limit + 5);
-			} else if ($min) {
-				$feed = PublicTimelineService::getRankedMinId($min, $limit + 5);
-			} else {
-				$feed = PublicTimelineService::get(0, $limit + 5);
-			}
-        }
-
-		$res = collect($feed)
-		->filter(function($k) use($min, $max) {
-			if(!$min && !$max) {
-				return true;
-			}
-
-			if($min) {
-				return $min != $k;
-			}
-
-			if($max) {
-				return $max != $k;
-			}
-		})
-		->map(function($k) use($user, $napi) {
-			try {
-				$status = $napi ? StatusService::get($k) : StatusService::getMastodon($k);
-				if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
-					return false;
-				}
-			} catch(\Exception $e) {
-				return false;
-			}
-
-			$account = $napi ? AccountService::get($status['account']['id'], true) : AccountService::getMastodon($status['account']['id'], true);
-			if(!$account) {
-				return false;
-			}
-
-			$status['account'] = $account;
-
-			if($user) {
-				$status['favourited'] = (bool) LikeService::liked($user->profile_id, $k);
-				$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $status['id']);
-                $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status['id']);
-			}
-			return $status;
-		})
-		->filter(function($s) use($filtered) {
-			return $s && isset($s['account']) && in_array($s['account']['id'], $filtered) == false;
-		})
-		->take($limit)
-		->values();
-
-		$baseUrl = config('app.url') . '/api/v1/timelines/public?limit=' . $limit . '&';
-		if($remote) {
-			$baseUrl .= 'remote=1&';
-		}
-		$minId = $res->map(function($s) {
-			return ['id' => $s['id']];
-		})->min('id');
-		$maxId = $res->map(function($s) {
-			return ['id' => $s['id']];
-		})->max('id');
-
-		if($minId == $maxId) {
-			$minId = null;
-		}
-
-		if($maxId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
-		}
-
-		if($minId) {
-			$link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		if($maxId && $minId) {
-			$link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
-		}
-
-		$headers = isset($link) ? ['Link' => $link] : [];
-		return $this->json($res->toArray(), 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/conversations
-	 *
-	 *   Not implemented
-	 *
-	 * @return array
-	 */
-	public function conversations(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		$this->validate($request, [
-			'limit' => 'min:1|max:40',
-			'scope' => 'nullable|in:inbox,sent,requests'
-		]);
-
-		$limit = $request->input('limit', 20);
-		$scope = $request->input('scope', 'inbox');
-		$pid = $request->user()->profile_id;
-
-		if(config('database.default') == 'pgsql') {
-			$dms = DirectMessage::when($scope === 'inbox', function($q, $scope) use($pid) {
-					return $q->whereIsHidden(false)->where('to_id', $pid)->orWhere('from_id', $pid);
-				})
-				->when($scope === 'sent', function($q, $scope) use($pid) {
-					return $q->whereFromId($pid)->groupBy(['to_id', 'id']);
-				})
-				->when($scope === 'requests', function($q, $scope) use($pid) {
-					return $q->whereToId($pid)->whereIsHidden(true);
-				});
-		} else {
-			$dms = Conversation::when($scope === 'inbox', function($q, $scope) use($pid) {
-				return $q->whereIsHidden(false)
-					->where('to_id', $pid)
-					->orWhere('from_id', $pid)
-					->orderByDesc('status_id')
-					->groupBy(['to_id', 'from_id']);
-			})
-			->when($scope === 'sent', function($q, $scope) use($pid) {
-				return $q->whereFromId($pid)->groupBy('to_id');
-			})
-			->when($scope === 'requests', function($q, $scope) use($pid) {
-				return $q->whereToId($pid)->whereIsHidden(true);
-			});
-		}
-
-		$dms = $dms->orderByDesc('status_id')
-			->simplePaginate($limit)
-			->map(function($dm) use($pid) {
-				$from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id;
-				$res = [
-					'id' => $dm->id,
-					'unread' => false,
-					'accounts' => [
-						AccountService::getMastodon($from, true)
-					],
-					'last_status' => StatusService::getDirectMessage($dm->status_id)
-				];
-				return $res;
-			})
-			->filter(function($dm) {
-				if(!$dm || empty($dm['last_status']) || !isset($dm['accounts']) || !count($dm['accounts']) || !isset($dm['accounts'][0]) || !isset($dm['accounts'][0]['id'])) {
-					return false;
-				}
-				return true;
-			})
-			->unique(function($item, $key) {
-				return $item['accounts'][0]['id'];
-			})
-			->values();
-
-		return $this->json($dms);
-	}
-
-	/**
-	 * GET /api/v1/statuses/{id}
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusById(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-
-		$res = $request->has(self::PF_API_ENTITY_KEY) ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
-		if(!$res || !isset($res['visibility'])) {
-			abort(404);
-		}
-
-		$scope = $res['visibility'];
-		if(!in_array($scope, ['public', 'unlisted'])) {
-			if($scope === 'private') {
-				if(intval($res['account']['id']) !== intval($pid)) {
-					abort_unless(FollowerService::follows($pid, $res['account']['id']), 403);
-				}
-			} else {
-				abort(400, 'Invalid request');
-			}
-		}
-
-        if(!empty($res['reblog']) && isset($res['reblog']['id'])) {
-            $res['reblog']['favourited'] = (bool) LikeService::liked($pid, $res['reblog']['id']);
-            $res['reblog']['reblogged'] = (bool) ReblogService::get($pid, $res['reblog']['id']);
-            $res['reblog']['bookmarked'] = BookmarkService::get($pid, $res['reblog']['id']);
+            $follower = Follower::firstOrCreate([
+                'profile_id' => $user->profile_id,
+                'following_id' => $target->id,
+            ]);
+
+            if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
+                (new FollowerController)->sendFollow($user->profile, $target);
+            }
+            FollowPipeline::dispatch($follower)->onQueue('high');
         }
 
-		$res['favourited'] = LikeService::liked($pid, $res['id']);
-		$res['reblogged'] = ReblogService::get($pid, $res['id']);
-		$res['bookmarked'] = BookmarkService::get($pid, $res['id']);
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/statuses/{id}/context
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusContext(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$pid = $user->profile_id;
-		$status = StatusService::getMastodon($id, false);
-
-		if(!$status || !isset($status['account'])) {
-			return response('', 404);
-		}
-
-		if(intval($status['account']['id']) !== intval($user->profile_id)) {
-			if($status['visibility'] == 'private') {
-				if(!FollowerService::follows($user->profile_id, $status['account']['id'])) {
-					return response('', 404);
-				}
-			} else {
-				if(!in_array($status['visibility'], ['public','unlisted'])) {
-					return response('', 404);
-				}
-			}
-		}
-
-		$ancestors = [];
-		$descendants = [];
-
-		if($status['in_reply_to_id']) {
-			$ancestors[] = StatusService::getMastodon($status['in_reply_to_id'], false);
-		}
-
-		if($status['replies_count']) {
-			$filters = UserFilterService::filters($pid);
-
-			$descendants = DB::table('statuses')
-				->where('in_reply_to_id', $id)
-				->limit(20)
-				->pluck('id')
-				->map(function($sid) {
-					return StatusService::getMastodon($sid, false);
-				})
-				->filter(function($post) use($filters) {
-					return $post && isset($post['account'], $post['account']['id']) && !in_array($post['account']['id'], $filters);
-				})
-				->map(function($status) use($pid) {
-					$status['favourited'] = LikeService::liked($pid, $status['id']);
-					$status['reblogged'] = ReblogService::get($pid, $status['id']);
-					return $status;
-				})
-				->values();
-		}
-
-		$res = [
-			'ancestors' => $ancestors,
-			'descendants' => $descendants
-		];
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/statuses/{id}/card
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusCard(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-		$res = [];
-		return response()->json($res);
-	}
-
-	/**
-	 * GET /api/v1/statuses/{id}/reblogged_by
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return AccountTransformer
-	 */
-	public function statusRebloggedBy(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'sometimes|integer|min:1|max:80'
-		]);
-
-		$limit = $request->input('limit', 10);
-		$user = $request->user();
-		$pid = $user->profile_id;
-		$status = Status::findOrFail($id);
-		$account = AccountService::get($status->profile_id, true);
-		abort_if(!$account, 404);
-		$author = intval($status->profile_id) === intval($pid) || $user->is_admin;
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-
-		abort_if(
-			!$status->type ||
-			!in_array($status->type, ['photo','photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
-			404,
-		);
-
-		if(!$author) {
-			if($status->scope == 'private') {
-				abort_if(!FollowerService::follows($pid, $status->profile_id), 403);
-			} else {
-				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
-			}
-
-			if($request->has('cursor')) {
-				return $this->json([]);
-			}
-		}
-
-		$res = Status::where('reblog_of_id', $status->id)
-		->orderByDesc('id')
-		->cursorPaginate($limit)
-		->withQueryString();
-
-		if(!$res) {
-			return $this->json([]);
-		}
-
-		$headers = [];
-		if($author && $res->hasPages()) {
-			$links = '';
-			if($res->onFirstPage()) {
-				if($res->nextPageUrl()) {
-					$links = '<' . $res->nextPageUrl() .'>; rel="prev"';
-				}
-			} else {
-				if($res->previousPageUrl()) {
-					$links = '<' . $res->previousPageUrl() .'>; rel="next"';
-				}
-
-				if($res->nextPageUrl()) {
-					if(!empty($links)) {
-						$links .= ', ';
-					}
-					$links .= '<' . $res->nextPageUrl() .'>; rel="prev"';
-				}
-			}
-
-			$headers = ['Link' => $links];
-		}
-
-		$res = $res->map(function($status) use($pid, $napi) {
-			$account = $napi ? AccountService::get($status->profile_id, true) : AccountService::getMastodon($status->profile_id, true);
-			if(!$account) {
-				return false;
-			}
-			if($napi) {
-				$account['follows'] = $status->profile_id == $pid ? null : FollowerService::follows($pid, $status->profile_id);
-			}
-			return $account;
-		})
-		->filter(function($account) {
-			return $account && isset($account['id']);
-		})
-		->values();
-
-		return $this->json($res, 200, $headers);
-	}
-
-	/**
-	 * GET /api/v1/statuses/{id}/favourited_by
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return AccountTransformer
-	 */
-	public function statusFavouritedBy(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'nullable|integer|min:1|max:80'
-		]);
-
-		$limit = $request->input('limit', 10);
-		$user = $request->user();
-		$pid = $user->profile_id;
-		$status = Status::findOrFail($id);
-		$account = AccountService::get($status->profile_id, true);
-		abort_if(!$account, 404);
-		$author = intval($status->profile_id) === intval($pid) || $user->is_admin;
-		$napi = $request->has(self::PF_API_ENTITY_KEY);
-
-		abort_if(
-			!$status->type ||
-			!in_array($status->type, ['photo','photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
-			404,
-		);
-
-		if(!$author) {
-			if($status->scope == 'private') {
-				abort_if(!FollowerService::follows($pid, $status->profile_id), 403);
-			} else {
-				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
-			}
-
-			if($request->has('cursor')) {
-				return $this->json([]);
-			}
-		}
-
-		$res = Like::where('status_id', $status->id)
-		->orderByDesc('id')
-		->cursorPaginate($limit)
-		->withQueryString();
-
-		if(!$res) {
-			return $this->json([]);
-		}
-
-		$headers = [];
-		if($author && $res->hasPages()) {
-			$links = '';
-
-			if($res->onFirstPage()) {
-				if($res->nextPageUrl()) {
-					$links = '<' . $res->nextPageUrl() .'>; rel="prev"';
-				}
-			} else {
-				if($res->previousPageUrl()) {
-					$links = '<' . $res->previousPageUrl() .'>; rel="next"';
-				}
-
-				if($res->nextPageUrl()) {
-					if(!empty($links)) {
-						$links .= ', ';
-					}
-					$links .= '<' . $res->nextPageUrl() .'>; rel="prev"';
-				}
-			}
-
-			$headers = ['Link' => $links];
-		}
-
-		$res = $res->map(function($like) use($pid, $napi) {
-			$account = $napi ? AccountService::get($like->profile_id, true) : AccountService::getMastodon($like->profile_id, true);
-			if(!$account) {
-				return false;
-			}
-
-			if($napi) {
-				$account['follows'] = $like->profile_id == $pid ? null : FollowerService::follows($pid, $like->profile_id);
-			}
-			return $account;
-		})
-		->filter(function($account) {
-			return $account && isset($account['id']);
-		})
-		->values();
-
-		return $this->json($res, 200, $headers);
-	}
-
-	/**
-	 * POST /api/v1/statuses
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusCreate(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'status' => 'nullable|string',
-			'in_reply_to_id' => 'nullable',
-			'media_ids' => 'sometimes|array|max:' . config_cache('pixelfed.max_album_length'),
-			'sensitive' => 'nullable',
-			'visibility' => 'string|in:private,unlisted,public',
-			'spoiler_text' => 'sometimes|max:140',
-			'place_id' => 'sometimes|integer|min:1|max:128769',
-			'collection_ids' => 'sometimes|array|max:3',
-			'comments_disabled' => 'sometimes|boolean',
-		]);
-
-		if($request->hasHeader('idempotency-key')) {
-			$key = 'pf:api:v1:status:idempotency-key:' . $request->user()->id . ':' . hash('sha1', $request->header('idempotency-key'));
-			$exists = Cache::has($key);
-			abort_if($exists, 400, 'Duplicate idempotency key.');
-			Cache::put($key, 1, 3600);
-		}
-
-		if(config('costar.enabled') == true) {
-			$blockedKeywords = config('costar.keyword.block');
-			if($blockedKeywords !== null && $request->status) {
-				$keywords = config('costar.keyword.block');
-				foreach($keywords as $kw) {
-					if(Str::contains($request->status, $kw) == true) {
-						abort(400, 'Invalid object. Contains banned keyword.');
-					}
-				}
-			}
-		}
-
-		if(!$request->filled('media_ids') && !$request->filled('in_reply_to_id')) {
-			abort(403, 'Empty statuses are not allowed');
-		}
-
-		$ids = $request->input('media_ids');
-		$in_reply_to_id = $request->input('in_reply_to_id');
-
-		$user = $request->user();
-		$profile = $user->profile;
-
-		$limitKey = 'compose:rate-limit:store:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Status::whereProfileId($user->profile_id)
-				->whereNull('in_reply_to_id')
-				->whereNull('reblog_of_id')
-				->where('created_at', '>', now()->subDays(1))
-				->count();
-
-			return $dailyLimit >= 1000;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		$visibility = $profile->is_private ? 'private' : (
-			$profile->unlisted == true &&
-			$request->input('visibility', 'public') == 'public' ?
-			'unlisted' :
-			$request->input('visibility', 'public'));
-
-		if($user->last_active_at == null) {
-			return [];
-		}
-
-		$content = strip_tags($request->input('status'));
-		$rendered = Autolink::create()->autolink($content);
-		$cw = $user->profile->cw == true ? true : $request->input('sensitive', false);
-		$spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null;
-
-		if($in_reply_to_id) {
-			$parent = Status::findOrFail($in_reply_to_id);
-			if($parent->comments_disabled) {
-				return $this->json("Comments have been disabled on this post", 422);
-			}
-			$blocks = UserFilterService::blocks($parent->profile_id);
-			abort_if(in_array($profile->id, $blocks), 422, 'Cannot reply to this post at this time.');
-
-			$status = new Status;
-			$status->caption = $content;
-			$status->rendered = $rendered;
-			$status->scope = $visibility;
-			$status->visibility = $visibility;
-			$status->profile_id = $user->profile_id;
-			$status->is_nsfw = $cw;
-			$status->cw_summary = $spoilerText;
-			$status->in_reply_to_id = $parent->id;
-			$status->in_reply_to_profile_id = $parent->profile_id;
-			$status->save();
-			StatusService::del($parent->id);
-			Cache::forget('status:replies:all:' . $parent->id);
-		}
-
-		if($ids) {
-			if(Media::whereUserId($user->id)
-				->whereNull('status_id')
-				->find($ids)
-				->count() == 0
-			) {
-				abort(400, 'Invalid media_ids');
-			}
-
-			if(!$in_reply_to_id) {
-				$status = new Status;
-				$status->caption = $content;
-				$status->rendered = $rendered;
-				$status->profile_id = $user->profile_id;
-				$status->is_nsfw = $cw;
-				$status->cw_summary = $spoilerText;
-				$status->scope = 'draft';
-				$status->visibility = 'draft';
-				if($request->has('place_id')) {
-					$status->place_id = $request->input('place_id');
-				}
-				$status->save();
-			}
-
-			$mimes = [];
-
-			foreach($ids as $k => $v) {
-				if($k + 1 > config_cache('pixelfed.max_album_length')) {
-					continue;
-				}
-				$m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v);
-				if($m->profile_id !== $user->profile_id || $m->status_id) {
-					abort(403, 'Invalid media id');
-				}
-				$m->order = $k + 1;
-				$m->status_id = $status->id;
-				$m->save();
-				array_push($mimes, $m->mime);
-			}
-
-			if(empty($mimes)) {
-				$status->delete();
-				abort(400, 'Invalid media ids');
-			}
-
-			if($request->has('comments_disabled') && $request->input('comments_disabled')) {
-				$status->comments_disabled = true;
-			}
-
-			$status->scope = $visibility;
-			$status->visibility = $visibility;
-			$status->type = StatusController::mimeTypeCheck($mimes);
-			$status->save();
-		}
-
-		if(!$status) {
-			abort(500, 'An error occured.');
-		}
-
-		NewStatusPipeline::dispatch($status);
-		if($status->in_reply_to_id) {
-        	CommentPipeline::dispatch($parent, $status);
-		}
-		Cache::forget('user:account:id:'.$user->id);
-		Cache::forget('_api:statuses:recent_9:'.$user->profile_id);
-		Cache::forget('profile:status_count:'.$user->profile_id);
-		Cache::forget($user->storageUsedKey());
-		Cache::forget('profile:embed:' . $status->profile_id);
-		Cache::forget($limitKey);
-
-		if($request->has('collection_ids') && $ids) {
-			$collections = Collection::whereProfileId($user->profile_id)
-				->find($request->input('collection_ids'))
-				->each(function($collection) use($status) {
-					$count = $collection->items()->count();
-			        $item = CollectionItem::firstOrCreate([
-			            'collection_id' => $collection->id,
-			            'object_type'   => 'App\Status',
-			            'object_id'     => $status->id
-			        ],[
-			            'order'         => $count,
-			        ]);
-
-			        CollectionService::addItem(
-			        	$collection->id,
-			        	$status->id,
-			        	$count
-			        );
-                    $collection->updated_at = now();
-                    $collection->save();
-                    CollectionService::setCollection($collection->id, $collection);
-				});
-		}
-
-		$res = StatusService::getMastodon($status->id, false);
-		$res['favourited'] = false;
-		$res['language'] = 'en';
-		$res['bookmarked'] = false;
-		$res['card'] = null;
-		return $this->json($res);
-	}
-
-	/**
-	 * DELETE /api/v1/statuses
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return null
-	 */
-	public function statusDelete(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$status = Status::whereProfileId($request->user()->profile->id)
-		->findOrFail($id);
-
-		$resource = new Fractal\Resource\Item($status, new StatusTransformer());
-
-		Cache::forget('profile:status_count:'.$status->profile_id);
-		StatusDelete::dispatch($status);
-
-		$res = $this->fractal->createData($resource)->toArray();
-		$res['text'] = $res['content'];
-		unset($res['content']);
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/reblog
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusShare(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$status = Status::whereScope('public')->findOrFail($id);
-
-		if(intval($status->profile_id) !== intval($user->profile_id)) {
-			if($status->scope == 'private') {
-				abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403);
-			} else {
-				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
-			}
-
-			$blocks = UserFilterService::blocks($status->profile_id);
-			if($blocks && in_array($user->profile_id, $blocks)) {
-				abort(422);
-			}
-		}
-
-		$share = Status::firstOrCreate([
-			'profile_id' => $user->profile_id,
-			'reblog_of_id' => $status->id,
-			'type' => 'share',
-			'in_reply_to_profile_id' => $status->profile_id,
-			'scope' => 'public',
-			'visibility' => 'public'
-		]);
-
-		SharePipeline::dispatch($share)->onQueue('low');
-
-		StatusService::del($status->id);
-		ReblogService::add($user->profile_id, $status->id);
-		$res = StatusService::getMastodon($status->id);
-		$res['reblogged'] = true;
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/unreblog
-	 *
-	 * @param  integer  $id
-	 *
-	 * @return StatusTransformer
-	 */
-	public function statusUnshare(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$user = $request->user();
-		$status = Status::whereScope('public')->findOrFail($id);
-
-		if(intval($status->profile_id) !== intval($user->profile_id)) {
-			if($status->scope == 'private') {
-				abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403);
-			} else {
-				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
-			}
-		}
-
-		$reblog = Status::whereProfileId($user->profile_id)
-		  ->whereReblogOfId($status->id)
-		  ->first();
-
-		if(!$reblog) {
-			$res = StatusService::getMastodon($status->id);
-			$res['reblogged'] = false;
-			return $this->json($res);
-		}
-
-		UndoSharePipeline::dispatch($reblog)->onQueue('low');
-		ReblogService::del($user->profile_id, $status->id);
-
-		$res = StatusService::getMastodon($status->id);
-		$res['reblogged'] = false;
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/timelines/tag/{hashtag}
-	 *
-	 * @param  string  $hashtag
-	 *
-	 * @return StatusTransformer
-	 */
-	public function timelineHashtag(Request $request, $hashtag)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request,[
-		  'page'        => 'nullable|integer|max:40',
-		  'min_id'      => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-		  'max_id'      => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-		  'limit'       => 'nullable|integer|max:100',
-		  'only_media'  => 'sometimes|boolean',
-		  '_pe'			=> 'sometimes'
-		]);
-
-		if(config('database.default') === 'pgsql') {
-			$tag = Hashtag::where('name', 'ilike', $hashtag)
-				->orWhere('slug', 'ilike', $hashtag)
-				->first();
-		} else {
-			$tag = Hashtag::whereName($hashtag)
-			  ->orWhere('slug', $hashtag)
-			  ->first();
-		}
-
-		if(!$tag) {
-			return response()->json([]);
-		}
-
-		if($tag->is_banned == true) {
-			return $this->json([]);
-		}
-
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-		$limit = $request->input('limit', 20);
-		$onlyMedia = $request->input('only_media', true);
-		$pe = $request->has(self::PF_API_ENTITY_KEY);
-
-		if($min || $max) {
-			$minMax = SnowflakeService::byDate(now()->subMonths(6));
-			if($min && intval($min) < $minMax) {
-				return [];
-			}
-			if($max && intval($max) < $minMax) {
-				return [];
-			}
-		}
-
-		$filters = UserFilterService::filters($request->user()->profile_id);
-
-		if(!$min && !$max) {
-			$id = 1;
-			$dir = '>';
-		} else {
-			$dir = $min ? '>' : '<';
-			$id = $min ?? $max;
-		}
-
-		$res = StatusHashtag::whereHashtagId($tag->id)
-			->whereStatusVisibility('public')
-			->where('status_id', $dir, $id)
-			->orderBy('status_id', 'desc')
-			->limit($limit)
-			->pluck('status_id')
-			->map(function ($i) use($pe) {
-				return $pe ? StatusService::get($i) : StatusService::getMastodon($i);
-			})
-			->filter(function($i) use($onlyMedia) {
-				if(!$i) {
-					return false;
-				}
-				if($onlyMedia && !isset($i['media_attachments']) || !count($i['media_attachments'])) {
-					return false;
-				}
-				return $i && isset($i['account']);
-			})
-			->filter(function($i) use($filters) {
-				return !in_array($i['account']['id'], $filters);
-			})
-			->values()
-			->toArray();
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/bookmarks
-	 *
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function bookmarks(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'nullable|integer|min:1|max:40',
-			'max_id' => 'nullable|integer|min:0',
-			'since_id' => 'nullable|integer|min:0',
-			'min_id' => 'nullable|integer|min:0'
-		]);
-
-		$pe = $request->has('_pe');
-		$pid = $request->user()->profile_id;
-		$limit = $request->input('limit') ?? 20;
-		$max_id = $request->input('max_id');
-		$since_id = $request->input('since_id');
-		$min_id = $request->input('min_id');
-
-		$dir = $min_id ? '>' : '<';
-		$id = $min_id ?? $max_id;
-
-		$bookmarkQuery = Bookmark::whereProfileId($pid)
+        RelationshipService::refresh($user->profile_id, $target->id);
+        Cache::forget('profile:following:'.$target->id);
+        Cache::forget('profile:followers:'.$target->id);
+        Cache::forget('profile:following:'.$user->profile_id);
+        Cache::forget('profile:followers:'.$user->profile_id);
+        Cache::forget('api:local:exp:rec:'.$user->profile_id);
+        Cache::forget('user:account:id:'.$target->user_id);
+        Cache::forget('user:account:id:'.$user->id);
+        Cache::forget('profile:follower_count:'.$target->id);
+        Cache::forget('profile:follower_count:'.$user->profile_id);
+        Cache::forget('profile:following_count:'.$target->id);
+        Cache::forget('profile:following_count:'.$user->profile_id);
+        AccountService::del($user->profile_id);
+        AccountService::del($target->id);
+
+        $res = RelationshipService::get($user->profile_id, $target->id);
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/unfollow
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\RelationshipTransformer
+     */
+    public function accountUnfollowById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('follow'), 403);
+
+        $user = $request->user();
+
+        AccountService::setLastActive($user->id);
+
+        $target = Profile::where('id', '!=', $user->profile_id)
+            ->whereNull('status')
+            ->findOrFail($id);
+
+        $private = (bool) $target->is_private;
+        $remote = (bool) $target->domain;
+
+        $isFollowing = Follower::whereProfileId($user->profile_id)
+            ->whereFollowingId($target->id)
+            ->exists();
+
+        if ($isFollowing == false) {
+            $followRequest = FollowRequest::whereFollowerId($user->profile_id)
+                ->whereFollowingId($target->id)
+                ->first();
+            if ($followRequest) {
+                $followRequest->delete();
+                RelationshipService::refresh($target->id, $user->profile_id);
+            }
+            $resource = new Fractal\Resource\Item($target, new RelationshipTransformer);
+            $res = $this->fractal->createData($resource)->toArray();
+
+            return $this->json($res);
+        }
+
+        Follower::whereProfileId($user->profile_id)
+            ->whereFollowingId($target->id)
+            ->delete();
+
+        UnfollowPipeline::dispatch($user->profile_id, $target->id)->onQueue('high');
+
+        if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
+            (new FollowerController)->sendUndoFollow($user->profile, $target);
+        }
+
+        RelationshipService::refresh($user->profile_id, $target->id);
+        Cache::forget('profile:following:'.$target->id);
+        Cache::forget('profile:followers:'.$target->id);
+        Cache::forget('profile:following:'.$user->profile_id);
+        Cache::forget('profile:followers:'.$user->profile_id);
+        Cache::forget('api:local:exp:rec:'.$user->profile_id);
+        Cache::forget('user:account:id:'.$target->user_id);
+        Cache::forget('user:account:id:'.$user->id);
+        Cache::forget('profile:follower_count:'.$target->id);
+        Cache::forget('profile:follower_count:'.$user->profile_id);
+        Cache::forget('profile:following_count:'.$target->id);
+        Cache::forget('profile:following_count:'.$user->profile_id);
+        AccountService::del($user->profile_id);
+        AccountService::del($target->id);
+
+        $res = RelationshipService::get($user->profile_id, $target->id);
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/accounts/relationships
+     *
+     * @param  array|int  $id
+     * @return \App\Services\RelationshipService
+     */
+    public function accountRelationshipsById(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'id' => 'required|array|min:1',
+            'id.*' => 'required|integer|min:1|max:'.PHP_INT_MAX,
+        ]);
+        $ids = $request->input('id');
+        if (count($ids) > 20) {
+            $ids = collect($ids)->take(20)->toArray();
+        }
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $pid = $request->user()->profile_id ?? $request->user()->profile->id;
+        $res = collect($ids)
+            ->map(function ($id) use ($pid, $napi) {
+                if (intval($id) === intval($pid)) {
+                    return [
+                        'id' => $id,
+                        'following' => false,
+                        'followed_by' => false,
+                        'blocking' => false,
+                        'muting' => false,
+                        'muting_notifications' => false,
+                        'requested' => false,
+                        'domain_blocking' => false,
+                        'showing_reblogs' => false,
+                        'endorsed' => false,
+                    ];
+                }
+
+                return $napi ?
+                 RelationshipService::getWithDate($pid, $id) :
+                 RelationshipService::get($pid, $id);
+            });
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/accounts/search
+     *
+     *
+     *
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountSearch(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:1|max:30',
+            'limit' => 'nullable|integer|min:1',
+            'resolve' => 'nullable',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-view-discover', $user->id), 403, 'Invalid permissions for this action');
+
+        AccountService::setLastActive($user->id);
+        $query = $request->input('q');
+        $limit = $request->input('limit') ?? 20;
+        if ($limit > 20) {
+            $limit = 20;
+        }
+        $resolve = $request->boolean('resolve', false);
+        $q = $query.'%';
+
+        $profiles = Profile::where('username', 'like', $q)
+            ->orderByDesc('followers_count')
+            ->limit($limit)
+            ->pluck('id')
+            ->map(function ($id) {
+                return AccountService::getMastodon($id);
+            })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values();
+
+        return $this->json($profiles);
+    }
+
+    /**
+     * GET /api/v1/blocks
+     *
+     *
+     *
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountBlocks(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+            'page' => 'sometimes|integer|min:1',
+        ]);
+
+        $user = $request->user();
+        $limit = $request->input('limit') ?? 40;
+        if ($limit > 80) {
+            $limit = 80;
+        }
+
+        $blocks = UserFilter::select('filterable_id', 'filterable_type', 'filter_type', 'user_id')
+            ->whereUserId($user->profile_id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('block')
             ->orderByDesc('id')
-            ->cursorPaginate($limit);
+            ->simplePaginate($limit)
+            ->withQueryString();
 
-        $bookmarks = $bookmarkQuery->map(function($bookmark) use($pid, $pe) {
-				$status = $pe ? StatusService::get($bookmark->status_id, false) : StatusService::getMastodon($bookmark->status_id, false);
+        $res = $blocks->pluck('filterable_id')
+            ->map(function ($id) {
+                return AccountService::get($id, true);
+            })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values();
 
-				if($status) {
-					$status['bookmarked'] = true;
-					$status['favourited'] = LikeService::liked($pid, $status['id']);
-					$status['reblogged'] = ReblogService::get($pid, $status['id']);
-				}
-				return $status;
-			})
-			->filter()
-			->values()
-			->toArray();
+        $baseUrl = config('app.url').'/api/v1/blocks?limit='.$limit.'&';
+        $next = $blocks->nextPageUrl();
+        $prev = $blocks->previousPageUrl();
 
-        $links = null;
-        $headers = [];
+        if ($next && ! $prev) {
+            $link = '<'.$next.'>; rel="next"';
+        }
 
-        if($bookmarkQuery->nextCursor()) {
-            $links .= '<'.$bookmarkQuery->nextPageUrl().'&limit='.$limit.'>; rel="next"';
+        if (! $next && $prev) {
+            $link = '<'.$prev.'>; rel="prev"';
         }
 
-        if($bookmarkQuery->previousCursor()) {
-            if($links != null) {
-                $links .= ', ';
-            }
-            $links .= '<'.$bookmarkQuery->previousPageUrl().'&limit='.$limit.'>; rel="prev"';
+        if ($next && $prev) {
+            $link = '<'.$next.'>; rel="next",<'.$prev.'>; rel="prev"';
+        }
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/block
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\RelationshipTransformer
+     */
+    public function accountBlockById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        $pid = $user->profile_id ?? $user->profile->id;
+        AccountService::setLastActive($user->id);
+
+        if (intval($id) === intval($pid)) {
+            abort(400, 'You cannot block yourself');
         }
 
-        if($links) {
-            $headers = ['Link' => $links];
+        $profile = Profile::findOrFail($id);
+
+        abort_if($profile->moved_to_profile_id, 422, 'Cannot block an account that has migrated!');
+
+        if ($profile->user && $profile->user->is_admin == true) {
+            abort(400, 'You cannot block an admin');
         }
 
-		return $this->json($bookmarks, 200, $headers);
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/bookmark
-	 *
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function bookmarkStatus(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$status = Status::findOrFail($id);
-		$pid = $request->user()->profile_id;
-
-		abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
-		abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
-		abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404);
-
-		if($status->scope == 'private') {
-			abort_if(
-				$pid !== $status->profile_id && !FollowerService::follows($pid, $status->profile_id),
-				404,
-				'Error: You cannot bookmark private posts from accounts you do not follow.'
-			);
-		}
-
-		Bookmark::firstOrCreate([
-			'status_id' => $status->id,
-			'profile_id' => $pid
-		]);
-
-		BookmarkService::add($pid, $status->id);
-
-		$res = StatusService::getMastodon($status->id, false);
-		$res['bookmarked'] = true;
-
-		return $this->json($res);
-	}
-
-	/**
-	 * POST /api/v1/statuses/{id}/unbookmark
-	 *
-	 *
-	 *
-	 * @return StatusTransformer
-	 */
-	public function unbookmarkStatus(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$status = Status::findOrFail($id);
-		$pid = $request->user()->profile_id;
-
-		abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
-		abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
-		abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404);
-
-		$bookmark = Bookmark::whereStatusId($status->id)
-			->whereProfileId($pid)
-			->first();
-
-		if($bookmark) {
-			BookmarkService::del($pid, $status->id);
-			$bookmark->delete();
-		}
-		$res = StatusService::getMastodon($status->id, false);
-		$res['bookmarked'] = false;
-
-		return $this->json($res);
-	}
-
-	/**
-	 * GET /api/v1/discover/posts
-	 *
-	 *
-	 * @return array
-	 */
-	public function discoverPosts(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'integer|min:1|max:40'
-		]);
-
-		$limit = $request->input('limit', 40);
-		$pid = $request->user()->profile_id;
-		$filters = UserFilterService::filters($pid);
-		$forYou = DiscoverService::getForYou();
-		$posts = $forYou->take(50)->map(function($post) {
-			return StatusService::getMastodon($post);
-		})
-		->filter(function($post) use($filters) {
-			return $post &&
-				isset($post['account']) &&
-				isset($post['account']['id']) &&
-				!in_array($post['account']['id'], $filters);
-		})
-		->take(12)
-		->values();
-		return $this->json(compact('posts'));
-	}
-
-	/**
-	* GET /api/v2/statuses/{id}/replies
-	*
-	*
-	* @return array
-	*/
-	public function statusReplies(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'limit' => 'int|min:1|max:10',
-			'sort' => 'in:all,newest,popular'
-		]);
-
-		$limit = $request->input('limit', 3);
-		$pid = $request->user()->profile_id;
-		$status = StatusService::getMastodon($id, false);
-
-		abort_if(!$status, 404);
-
-		if($status['visibility'] == 'private') {
-			if($pid != $status['account']['id']) {
-				abort_unless(FollowerService::follows($pid, $status['account']['id']), 404);
-			}
-		}
-
-		$sortBy = $request->input('sort', 'all');
-
-		if($sortBy == 'all' && isset($status['replies_count']) && $status['replies_count'] && $request->has('refresh_cache')) {
-			if(!Cache::has('status:replies:all-rc:' . $id)) {
-				Cache::forget('status:replies:all:' . $id);
-				Cache::put('status:replies:all-rc:' . $id, true, 300);
-			}
-		}
-
-		if($sortBy == 'all' && !$request->has('cursor')) {
-			$ids = Cache::remember('status:replies:all:' . $id, 3600, function() use($id) {
-				return DB::table('statuses')
-					->where('in_reply_to_id', $id)
-					->orderBy('id')
-					->cursorPaginate(3);
-			});
-		} else {
-			$ids = DB::table('statuses')
-				->where('in_reply_to_id', $id)
-				->when($sortBy, function($q, $sortBy) {
-					if($sortBy === 'all') {
-						return $q->orderBy('id');
-					}
-
-					if($sortBy === 'newest') {
-						return $q->orderByDesc('created_at');
-					}
-
-					if($sortBy === 'popular') {
-						return $q->orderByDesc('likes_count');
-					}
-				})
-				->cursorPaginate($limit);
-		}
-
-		$filters = UserFilterService::filters($pid);
-		$data = $ids->filter(function($post) use($filters) {
-			return !in_array($post->profile_id, $filters);
-		})
-		->map(function($post) use($pid) {
-			$status = StatusService::get($post->id, false);
-
-			if(!$status || !isset($status['id'])) {
-				return false;
-			}
-
-			$status['favourited'] = LikeService::liked($pid, $post->id);
-			return $status;
-		})
-		->map(function($post) {
-			if(isset($post['account']) && isset($post['account']['id'])) {
-				$account = AccountService::get($post['account']['id'], true);
-				$post['account'] = $account;
-			}
-			return $post;
-		})
-		->filter(function($post) {
-			return $post && isset($post['id']) && isset($post['account']) && isset($post['account']['id']);
-		})
-		->values();
-
-		$res = [
-			'data' => $data,
-			'next' => $ids->nextPageUrl()
-		];
-
-		return $this->json($res);
-	}
-
-	/**
-	* GET /api/v2/statuses/{id}/state
-	*
-	*
-	* @return array
-	*/
-	public function statusState(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$status = Status::findOrFail($id);
-		$pid = $request->user()->profile_id;
-		abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
-
-		return $this->json(StatusService::getState($status->id, $pid));
-	}
-
-	/**
-	* GET /api/v1.1/discover/accounts/popular
-	*
-	*
-	* @return array
-	*/
-	public function discoverAccountsPopular(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-
-		$ids = Cache::remember('api:v1.1:discover:accounts:popular', 86400, function() {
-			return DB::table('profiles')
-			->where('is_private', false)
-			->whereNull('status')
-			->orderByDesc('profiles.followers_count')
-			->limit(20)
-			->get();
-		});
-
-		$ids = $ids->map(function($profile) {
-			return AccountService::get($profile->id, true);
-		})
-		->filter(function($profile) use($pid) {
-			return $profile && isset($profile['id']);
-		})
-		->filter(function($profile) use($pid) {
-			return $profile['id'] != $pid;
-		})
-        ->map(function($profile) {
-            $ids = collect(ProfileStatusService::get($profile['id'], 0, 9))
-                ->map(function($id) {
-                    return StatusService::get($id, true);
+        $count = UserFilterService::blockCount($pid);
+        $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks');
+        if ($count == 0) {
+            $filterCount = UserFilter::whereUserId($pid)
+                ->whereFilterType('block')
+                ->get()
+                ->map(function ($rec) {
+                    return AccountService::get($rec->filterable_id, true);
                 })
-                ->filter(function($post) {
-                    return $post && isset($post['id']);
+                ->filter(function ($account) {
+                    return $account && isset($account['id']);
                 })
-                ->take(3)
-                ->values();
-            $profile['recent_posts'] = $ids;
-            return $profile;
-        })
-		->take(6)
-		->values();
-
-		return $this->json($ids);
-	}
-
-	/**
-	* GET /api/v1/preferences
-	*
-	*
-	* @return array
-	*/
-	public function getPreferences(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$account = AccountService::get($pid);
-
-		return $this->json([
-			'posting:default:visibility'		=>  $account['locked'] ? 'private' : 'public',
-			'posting:default:sensitive'			=>  false,
-			'posting:default:language'			=>  null,
-			'reading:expand:media'				=>  'default',
-			'reading:expand:spoilers'			=>  false
-		]);
-	}
-
-	/**
-	* GET /api/v1/trends
-	*
-	*
-	* @return array
-	*/
-	public function getTrends(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		return $this->json([]);
-	}
-
-	/**
-	* GET /api/v1/announcements
-	*
-	*
-	* @return array
-	*/
-	public function getAnnouncements(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		return $this->json([]);
-	}
-
-	/**
-	* GET /api/v1/markers
-	*
-	*
-	* @return array
-	*/
-	public function getMarkers(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$type = $request->input('timeline');
-		if(is_array($type)) {
-			$type = $type[0];
-		}
-		if(!$type || !in_array($type, ['home', 'notifications'])) {
-			return $this->json([]);
-		}
-		$pid = $request->user()->profile_id;
-		return $this->json(MarkerService::get($pid, $type));
-	}
-
-	/**
-	* POST /api/v1/markers
-	*
-	*
-	* @return array
-	*/
-	public function setMarkers(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$home = $request->input('home[last_read_id]');
-		$notifications = $request->input('notifications[last_read_id]');
-
-		if($home) {
-			return $this->json(MarkerService::set($pid, 'home', $home));
-		}
-
-		if($notifications) {
-			return $this->json(MarkerService::set($pid, 'notifications', $notifications));
-		}
-
-		return $this->json([]);
-	}
-
-	/**
-	* GET /api/v1/followed_tags
-	*
-	*
-	* @return array
-	*/
-	public function getFollowedTags(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$account = AccountService::get($request->user()->profile_id);
-
-		$this->validate($request, [
-			'cursor' => 'sometimes',
-			'limit' => 'sometimes|integer|min:1|max:200'
-		]);
-		$limit = $request->input('limit', 100);
-
-		$res = HashtagFollow::whereProfileId($account['id'])
-			->orderByDesc('id')
-			->cursorPaginate($limit)->withQueryString();
-
-		$pagination = false;
-		$prevPage = $res->nextPageUrl();
-		$nextPage = $res->previousPageUrl();
-		if($nextPage && $prevPage) {
-			$pagination = '<' . $nextPage . '>; rel="next", <' . $prevPage . '>; rel="prev"';
-		} else if($nextPage && !$prevPage) {
-			$pagination = '<' . $nextPage . '>; rel="next"';
-		} else if(!$nextPage && $prevPage) {
-			$pagination = '<' . $prevPage . '>; rel="prev"';
-		}
-
-		if($pagination) {
-			return response()->json(FollowedTagResource::collection($res)->collection)
-				->header('Link', $pagination);
-		}
-		return response()->json(FollowedTagResource::collection($res)->collection);
-	}
-
-	/**
-	* POST /api/v1/tags/:id/follow
-	*
-	*
-	* @return object
-	*/
-	public function followHashtag(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$account = AccountService::get($pid);
-
-		$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
-		$tag = Hashtag::where('name', $operator, $id)
-			->orWhere('slug', $operator, $id)
-			->first();
-
-		abort_if(!$tag, 422, 'Unknown hashtag');
-
-		abort_if(
-			HashtagFollow::whereProfileId($pid)->count() >= HashtagFollow::MAX_LIMIT,
-			422,
-			'You cannot follow more than ' . HashtagFollow::MAX_LIMIT . ' hashtags.'
-		);
-
-		$follows = HashtagFollow::updateOrCreate(
-			[
-				'profile_id' => $account['id'],
-				'hashtag_id' => $tag->id
-			],
-			[
-				'user_id' => $request->user()->id
-			]
-		);
-
-		HashtagService::follow($pid, $tag->id);
-
-		return response()->json(FollowedTagResource::make($follows)->toArray($request));
-	}
-
-	/**
-	* POST /api/v1/tags/:id/unfollow
-	*
-	*
-	* @return object
-	*/
-	public function unfollowHashtag(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$account = AccountService::get($pid);
-
-		$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
-		$tag = Hashtag::where('name', $operator, $id)
-			->orWhere('slug', $operator, $id)
-			->first();
-
-		abort_if(!$tag, 422, 'Unknown hashtag');
-
-		$follows = HashtagFollow::whereProfileId($pid)
-			->whereHashtagId($tag->id)
-			->first();
-
-		if(!$follows) {
-			return [
-				'name' => $tag->name,
-				'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
-				'history' => [],
-				'following' => false
-			];
-		}
-
-		if($follows) {
-			HashtagService::unfollow($pid, $tag->id);
-			$follows->delete();
-		}
-
-		$res = FollowedTagResource::make($follows)->toArray($request);
-		$res['following'] = false;
-		return response()->json($res);
-	}
-
-	/**
-	* GET /api/v1/tags/:id
-	*
-	*
-	* @return object
-	*/
-	public function getHashtag(Request $request, $id)
-	{
-		abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$account = AccountService::get($pid);
-		$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
-		$tag = Hashtag::where('name', $operator, $id)
-			->orWhere('slug', $operator, $id)
-			->first();
-
-		if(!$tag) {
-			return [
-				'name' => $id,
-				'url' => config('app.url') . '/i/web/hashtag/' . $id,
-				'history' => [],
-				'following' => false
-			];
-		}
-
-		$res = [
-			'name' => $tag->name,
-			'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
-			'history' => [],
-			'following' => HashtagService::isFollowing($pid, $tag->id)
-		];
-
-		if($request->has(self::PF_API_ENTITY_KEY)) {
-			$res['count'] = HashtagService::count($tag->id);
-		}
-
-		return $this->json($res);
-	}
+                ->values()
+                ->count();
+            abort_if($filterCount >= $maxLimit, 422, AccountController::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts');
+        } else {
+            abort_if($count >= $maxLimit, 422, AccountController::FILTER_LIMIT_BLOCK_TEXT.$maxLimit.' accounts');
+        }
+
+        $followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first();
+        if ($followed) {
+            $followed->delete();
+            $profile->following_count = Follower::whereProfileId($profile->id)->count();
+            $profile->save();
+            $selfProfile = $user->profile;
+            $selfProfile->followers_count = Follower::whereFollowingId($pid)->count();
+            $selfProfile->save();
+            FollowerService::remove($profile->id, $pid);
+            AccountService::del($pid);
+            AccountService::del($profile->id);
+        }
+
+        $following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first();
+        if ($following) {
+            $following->delete();
+            $profile->followers_count = Follower::whereFollowingId($profile->id)->count();
+            $profile->save();
+            $selfProfile = $user->profile;
+            $selfProfile->following_count = Follower::whereProfileId($pid)->count();
+            $selfProfile->save();
+            FollowerService::remove($pid, $profile->pid);
+            AccountService::del($pid);
+            AccountService::del($profile->id);
+        }
+
+        Notification::whereProfileId($pid)
+            ->whereActorId($profile->id)
+            ->get()
+            ->map(function ($n) use ($pid) {
+                NotificationService::del($pid, $n['id']);
+                $n->forceDelete();
+            });
+
+        $filter = UserFilter::firstOrCreate([
+            'user_id' => $pid,
+            'filterable_id' => $profile->id,
+            'filterable_type' => 'App\Profile',
+            'filter_type' => 'block',
+        ]);
+
+        UserFilterService::block($pid, $id);
+        RelationshipService::refresh($pid, $id);
+        $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/unblock
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\RelationshipTransformer
+     */
+    public function accountUnblockById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        $pid = $user->profile_id ?? $user->profile->id;
+        AccountService::setLastActive($user->id);
+
+        if (intval($id) === intval($pid)) {
+            abort(400, 'You cannot unblock yourself');
+        }
+
+        $profile = Profile::findOrFail($id);
+
+        abort_if($profile->moved_to_profile_id, 422, 'Cannot unblock an account that has migrated!');
+
+        $filter = UserFilter::whereUserId($pid)
+            ->whereFilterableId($profile->id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('block')
+            ->first();
+
+        if ($filter) {
+            $filter->delete();
+            UserFilterService::unblock($pid, $profile->id);
+        }
+        RelationshipService::refresh($pid, $id);
+
+        $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/custom_emojis
+     *
+     * Return custom emoji
+     *
+     * @return array
+     */
+    public function customEmojis()
+    {
+        return response(CustomEmojiService::all())->header('Content-Type', 'application/json');
+    }
+
+    /**
+     * GET /api/v1/domain_blocks
+     *
+     * Return empty array
+     *
+     * @return array
+     */
+    public function accountDomainBlocks(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return response()->json([]);
+    }
+
+    /**
+     * GET /api/v1/endorsements
+     *
+     * Return empty array
+     *
+     * @return array
+     */
+    public function accountEndorsements(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return response()->json([]);
+    }
+
+    /**
+     * GET /api/v1/favourites
+     *
+     * Returns collection of liked statuses
+     *
+     * @return \App\Transformer\Api\StatusTransformer
+     */
+    public function accountFavourites(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+        ]);
+
+        $user = $request->user();
+        $maxId = $request->input('max_id');
+        $minId = $request->input('min_id');
+        $limit = $request->input('limit') ?? 10;
+        if ($limit > 40) {
+            $limit = 40;
+        }
+
+        $res = Like::whereProfileId($user->profile_id)
+            ->when($maxId, function ($q, $maxId) {
+                return $q->where('id', '<', $maxId);
+            })
+            ->when($minId, function ($q, $minId) {
+                return $q->where('id', '>', $minId);
+            })
+            ->orderByDesc('id')
+            ->limit($limit)
+            ->get()
+            ->map(function ($like) {
+                $status = StatusService::getMastodon($like['status_id'], false);
+                $status['favourited'] = true;
+                $status['like_id'] = $like->id;
+                $status['liked_at'] = str_replace('+00:00', 'Z', $like->created_at->format(DATE_RFC3339_EXTENDED));
+
+                return $status;
+            })
+            ->filter(function ($status) {
+                return $status && isset($status['id'], $status['like_id']);
+            })
+            ->values();
+
+        if ($res->count()) {
+            $ids = $res->map(function ($status) {
+                return $status['like_id'];
+            })->filter();
+
+            $max = $ids->min() - 1;
+            $min = $ids->max();
+
+            $baseUrl = config('app.url').'/api/v1/favourites?limit='.$limit.'&';
+            if ($maxId) {
+                $link = '<'.$baseUrl.'max_id='.$max.'>; rel="next",<'.$baseUrl.'min_id='.$min.'>; rel="prev"';
+            } else {
+                $link = '<'.$baseUrl.'max_id='.$max.'>; rel="next"';
+            }
+
+            return $this->json($res, 200, ['Link' => $link]);
+        } else {
+            return $this->json($res);
+        }
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/favourite
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\StatusTransformer
+     */
+    public function statusFavouriteById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action');
+
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
+
+        abort_unless($status, 404);
+
+        abort_if(isset($status['moved'], $status['moved']['id']), 422, 'Cannot like a post from an account that has migrated');
+
+        if ($status && isset($status['account'], $status['account']['acct']) && strpos($status['account']['acct'], '@') != -1) {
+            $domain = parse_url($status['account']['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $spid = $status['account']['id'];
+
+        AccountService::setLastActive($user->id);
+
+        if (intval($spid) !== intval($user->profile_id)) {
+            if ($status['visibility'] == 'private') {
+                abort_if(! FollowerService::follows($user->profile_id, $spid), 403);
+            } else {
+                abort_if(! in_array($status['visibility'], ['public', 'unlisted']), 403);
+            }
+        }
+
+        abort_if(
+            Like::whereProfileId($user->profile_id)
+                ->where('created_at', '>', now()->subDay())
+                ->count() >= Like::MAX_PER_DAY,
+            429
+        );
+
+        $blocks = UserFilterService::blocks($spid);
+        if ($blocks && in_array($user->profile_id, $blocks)) {
+            abort(422);
+        }
+
+        $like = Like::firstOrCreate([
+            'profile_id' => $user->profile_id,
+            'status_id' => $status['id'],
+        ]);
+
+        if ($like->wasRecentlyCreated == true) {
+            $like->status_profile_id = $spid;
+            $like->is_comment = ! empty($status['in_reply_to_id']);
+            $like->save();
+            Status::findOrFail($status['id'])->update([
+                'likes_count' => ($status['favourites_count'] ?? 0) + 1,
+            ]);
+            LikePipeline::dispatch($like)->onQueue('feed');
+        }
+
+        $status['favourited'] = true;
+        $status['favourites_count'] = $status['favourites_count'] + 1;
+        $status['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']);
+        $status['reblogged'] = ReblogService::get($user->profile_id, $status['id']);
+
+        return $this->json($status);
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/unfavourite
+     *
+     * @param  int  $id
+     * @return \App\Transformer\Api\StatusTransformer
+     */
+    public function statusUnfavouriteById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action');
+
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
+
+        abort_unless($status && isset($status['account']), 404);
+        abort_if(isset($status['moved'], $status['moved']['id']), 422, 'Cannot unlike a post from an account that has migrated');
+
+        if ($status && isset($status['account'], $status['account']['acct']) && strpos($status['account']['acct'], '@') != -1) {
+            $domain = parse_url($status['account']['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $spid = $status['account']['id'];
+
+        AccountService::setLastActive($user->id);
+
+        if (intval($spid) !== intval($user->profile_id)) {
+            if ($status['visibility'] == 'private') {
+                abort_if(! FollowerService::follows($user->profile_id, $spid), 403);
+            } else {
+                abort_if(! in_array($status['visibility'], ['public', 'unlisted']), 403);
+            }
+        }
+
+        $like = Like::whereProfileId($user->profile_id)
+            ->whereStatusId($status['id'])
+            ->first();
+
+        if ($like) {
+            $like->forceDelete();
+            $ogStatus = Status::find($status['id']);
+            if ($ogStatus) {
+                $ogStatus->likes_count = $ogStatus->likes_count > 1 ? $ogStatus->likes_count - 1 : 0;
+                $ogStatus->save();
+            }
+        }
+
+        StatusService::del($status['id']);
+
+        $status['favourited'] = false;
+        $status['favourites_count'] = isset($ogStatus) ? $ogStatus->likes_count : $status['favourites_count'] - 1;
+        $status['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']);
+        $status['reblogged'] = ReblogService::get($user->profile_id, $status['id']);
+
+        return $this->json($status);
+    }
+
+    /**
+     * GET /api/v1/filters
+     *
+     *  Return empty response since we filter server side
+     *
+     * @return array
+     */
+    public function accountFilters(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return response()->json([]);
+    }
+
+    /**
+     * GET /api/v1/follow_requests
+     *
+     *  Return array of Accounts that have sent follow requests
+     *
+     * @return \App\Transformer\Api\AccountTransformer
+     */
+    public function accountFollowRequests(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1|max:100',
+        ]);
+
+        $user = $request->user();
+
+        $res = FollowRequest::whereFollowingId($user->profile->id)
+            ->limit($request->input('limit', 40))
+            ->pluck('follower_id')
+            ->map(function ($id) {
+                return AccountService::getMastodon($id, true);
+            })
+            ->filter(function ($acct) {
+                return $acct && isset($acct['id']);
+            })
+            ->values();
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/follow_requests/{id}/authorize
+     *
+     * @param  int  $id
+     * @return null
+     */
+    public function accountFollowRequestAccept(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('follow'), 403);
+
+        $pid = $request->user()->profile_id;
+        $target = AccountService::getMastodon($id);
+
+        abort_if(isset($target['moved'], $target['moved']['id']), 422, 'Cannot accept a request from an account that has migrated!');
+
+        if (! $target) {
+            return response()->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($target && strpos($target['acct'], '@') != -1) {
+            $domain = parse_url($target['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $followRequest = FollowRequest::whereFollowingId($pid)->whereFollowerId($id)->first();
+
+        if (! $followRequest) {
+            return response()->json(['error' => 'Record not found'], 404);
+        }
+
+        $follower = $followRequest->follower;
+        $follow = new Follower;
+        $follow->profile_id = $follower->id;
+        $follow->following_id = $pid;
+        $follow->save();
+
+        $profile = Profile::findOrFail($pid);
+        $profile->followers_count++;
+        $profile->save();
+        AccountService::del($profile->id);
+
+        $profile = Profile::findOrFail($follower->id);
+        $profile->following_count++;
+        $profile->save();
+        AccountService::del($profile->id);
+
+        if ($follower->domain != null && $follower->private_key === null) {
+            FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow');
+        } else {
+            FollowPipeline::dispatch($follow);
+            $followRequest->delete();
+        }
+
+        RelationshipService::refresh($pid, $id);
+        $res = RelationshipService::get($pid, $id);
+        $res['followed_by'] = true;
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/follow_requests/{id}/reject
+     *
+     * @param  int  $id
+     * @return null
+     */
+    public function accountFollowRequestReject(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('follow'), 403);
+
+        $pid = $request->user()->profile_id;
+        $target = AccountService::getMastodon($id);
+
+        if (! $target) {
+            return response()->json(['error' => 'Record not found'], 404);
+        }
+
+        abort_if(isset($target['moved'], $target['moved']['id']), 422, 'Cannot reject a request from an account that has migrated!');
+
+        $followRequest = FollowRequest::whereFollowingId($pid)->whereFollowerId($id)->first();
+
+        if (! $followRequest) {
+            return response()->json(['error' => 'Record not found'], 404);
+        }
+
+        $follower = $followRequest->follower;
+
+        if ($follower->domain != null && $follower->private_key === null) {
+            FollowRejectPipeline::dispatch($followRequest)->onQueue('follow');
+        } else {
+            $followRequest->delete();
+        }
+
+        RelationshipService::refresh($pid, $id);
+        $res = RelationshipService::get($pid, $id);
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/suggestions
+     *
+     *   Return empty array as we don't support suggestions
+     *
+     * @return null
+     */
+    public function accountSuggestions(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        // todo
+
+        return response()->json([]);
+    }
+
+    /**
+     * GET /api/v1/instance
+     *
+     *   Information about the server.
+     *
+     * @return Instance
+     */
+    public function instance(Request $request)
+    {
+        $res = Cache::remember('api:v1:instance-data-response-v1', 1800, function () {
+            $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
+                if (config_cache('instance.admin.pid')) {
+                    return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
+                }
+                $admin = User::whereIsAdmin(true)->first();
+
+                return $admin && isset($admin->profile_id) ?
+                    AccountService::getMastodon($admin->profile_id, true) :
+                    null;
+            });
+
+            $stats = Cache::remember('api:v1:instance-data:stats:v0', 43200, function () {
+                return [
+                    'user_count' => (int) User::count(),
+                    'status_count' => (int) StatusService::totalLocalStatuses(),
+                    'domain_count' => (int) Instance::count(),
+                ];
+            });
+
+            $rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
+                return config_cache('app.rules') ?
+                    collect(json_decode(config_cache('app.rules'), true))
+                        ->map(function ($rule, $key) {
+                            $id = $key + 1;
+
+                            return [
+                                'id' => "{$id}",
+                                'text' => $rule,
+                            ];
+                        })
+                        ->toArray() : [];
+            });
+
+            return [
+                'uri' => config('pixelfed.domain.app'),
+                'title' => config_cache('app.name'),
+                'short_description' => config_cache('app.short_description'),
+                'description' => config_cache('app.description'),
+                'email' => config('instance.email'),
+                'version' => '3.5.3 (compatible; Pixelfed '.config('pixelfed.version').')',
+                'urls' => [
+                    'streaming_api' => null,
+                ],
+                'stats' => $stats,
+                'thumbnail' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
+                'languages' => [config('app.locale')],
+                'registrations' => (bool) config_cache('pixelfed.open_registration'),
+                'approval_required' => (bool) config_cache('instance.curated_registration.enabled'),
+                'contact_account' => $contact,
+                'rules' => $rules,
+                'configuration' => [
+                    'media_attachments' => [
+                        'image_matrix_limit' => 16777216,
+                        'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
+                        'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
+                        'video_frame_rate_limit' => 120,
+                        'video_matrix_limit' => 2304000,
+                        'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
+                    ],
+                    'polls' => [
+                        'max_characters_per_option' => 50,
+                        'max_expiration' => 2629746,
+                        'max_options' => 4,
+                        'min_expiration' => 300,
+                    ],
+                    'statuses' => [
+                        'characters_reserved_per_url' => 23,
+                        'max_characters' => (int) config_cache('pixelfed.max_caption_length'),
+                        'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
+                    ],
+                ],
+            ];
+        });
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/lists
+     *
+     *   Return empty array as we don't support lists
+     *
+     * @return null
+     */
+    public function accountLists(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return response()->json([]);
+    }
+
+    /**
+     * GET /api/v1/accounts/{id}/lists
+     *
+     * @param  int  $id
+     * @return null
+     */
+    public function accountListsById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return response()->json([]);
+    }
+
+    /**
+     * POST /api/v1/media
+     *
+     *
+     * @return MediaTransformer
+     */
+    public function mediaUpload(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $this->validate($request, [
+            'file.*' => [
+                'required_without:file',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'file' => [
+                'required_without:file.*',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'filter_name' => 'nullable|string|max:24',
+            'filter_class' => 'nullable|alpha_dash|max:24',
+            'description' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        AccountService::setLastActive($user->id);
+
+        if ($user->last_active_at == null) {
+            return [];
+        }
+
+        if (empty($request->file('file'))) {
+            return response('', 422);
+        }
+
+        $limitKey = 'compose:rate-limit:media-upload:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
+
+            return $dailyLimit >= 1250;
+        });
+        abort_if($limitReached == true, 429);
+
+        $profile = $user->profile;
+
+        $accountSize = UserStorageService::get($user->id);
+        abort_if($accountSize === -1, 403, 'Invalid request.');
+        $photo = $request->file('file');
+        $fileSize = $photo->getSize();
+        $sizeInKbs = (int) ceil($fileSize / 1000);
+        $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs;
+
+        if ((bool) config_cache('pixelfed.enforce_account_limit') == true) {
+            $limit = (int) config_cache('pixelfed.max_account_size');
+            if ($updatedAccountSize >= $limit) {
+                abort(403, 'Account size limit reached.');
+            }
+        }
+
+        $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
+        $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+        if (in_array($photo->getMimeType(), $mimes) == false) {
+            abort(403, 'Invalid or unsupported mime type.');
+        }
+
+        $storagePath = MediaPathService::get($user, 2);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+        $license = null;
+        $mime = $photo->getMimeType();
+
+        // if($photo->getMimeType() == 'image/heic') {
+        //  abort_if(config('image.driver') !== 'imagick', 422, 'Invalid media type');
+        //  abort_if(!in_array('HEIC', \Imagick::queryformats()), 422, 'Unsupported media type');
+        //  $oldPath = $path;
+        //  $path = str_replace('.heic', '.jpg', $path);
+        //  $mime = 'image/jpeg';
+        //  \Image::make($photo)->save(storage_path("app/{$path}"));
+        //  @unlink(storage_path("app/{$oldPath}"));
+        // }
+
+        $settings = UserSetting::whereUserId($user->id)->first();
+
+        if ($settings && ! empty($settings->compose_settings)) {
+            $compose = $settings->compose_settings;
+
+            if (isset($compose['default_license']) && $compose['default_license'] != 1) {
+                $license = $compose['default_license'];
+            }
+        }
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        $media = new Media;
+        $media->status_id = null;
+        $media->profile_id = $profile->id;
+        $media->user_id = $user->id;
+        $media->media_path = $path;
+        $media->original_sha256 = $hash;
+        $media->size = $photo->getSize();
+        $media->mime = $mime;
+        $media->caption = $request->input('description') ?? '';
+        $media->filter_class = $filterClass;
+        $media->filter_name = $filterName;
+        if ($license) {
+            $media->license = $license;
+        }
+        $media->save();
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+                ImageOptimize::dispatch($media)->onQueue('mmo');
+                break;
+
+            case 'video/mp4':
+                VideoThumbnail::dispatch($media)->onQueue('mmo');
+                $preview_url = '/storage/no-preview.png';
+                $url = '/storage/no-preview.png';
+                break;
+        }
+
+        $user->storage_used = (int) $updatedAccountSize;
+        $user->storage_used_updated_at = now();
+        $user->save();
+
+        Cache::forget($limitKey);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        $res['preview_url'] = $media->url().'?v='.time();
+        $res['url'] = $media->url().'?v='.time();
+
+        return $this->json($res);
+    }
+
+    /**
+     * PUT /api/v1/media/{id}
+     *
+     * @param  int  $id
+     * @return MediaTransformer
+     */
+    public function mediaUpdate(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $this->validate($request, [
+            'description' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        AccountService::setLastActive($user->id);
+
+        $media = Media::whereUserId($user->id)
+            ->whereProfileId($user->profile_id)
+            ->findOrFail($id);
+
+        $executed = RateLimiter::attempt(
+            'media:update:'.$user->id,
+            10,
+            function () use ($media, $request) {
+                $caption = Purify::clean($request->input('description'));
+
+                if ($caption != $media->caption) {
+                    $media->caption = $caption;
+                    $media->save();
+
+                    if ($media->status_id) {
+                        MediaService::del($media->status_id);
+                        StatusService::del($media->status_id);
+                    }
+                }
+            });
+
+        if (! $executed) {
+            return response()->json([
+                'error' => 'Too many attempts. Try again in a few minutes.',
+            ], 429);
+        }
+
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
+
+        return $this->json($fractal->createData($resource)->toArray());
+    }
+
+    /**
+     * GET /api/v1/media/{id}
+     *
+     * @param  int  $id
+     * @return MediaTransformer
+     */
+    public function mediaGet(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+        AccountService::setLastActive($user->id);
+
+        $media = Media::whereUserId($user->id)
+            ->whereNull('status_id')
+            ->findOrFail($id);
+
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v2/media
+     *
+     *
+     * @return MediaTransformer
+     */
+    public function mediaUploadV2(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $this->validate($request, [
+            'file.*' => [
+                'required_without:file',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'file' => [
+                'required_without:file.*',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'filter_name' => 'nullable|string|max:24',
+            'filter_class' => 'nullable|alpha_dash|max:24',
+            'description' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
+            'replace_id' => 'sometimes',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        if ($user->last_active_at == null) {
+            return [];
+        }
+
+        AccountService::setLastActive($user->id);
+
+        if (empty($request->file('file'))) {
+            return response('', 422);
+        }
+
+        $limitKey = 'compose:rate-limit:media-upload:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
+
+            return $dailyLimit >= 1250;
+        });
+        abort_if($limitReached == true, 429);
+
+        $profile = $user->profile;
+
+        $accountSize = UserStorageService::get($user->id);
+        abort_if($accountSize === -1, 403, 'Invalid request.');
+        $photo = $request->file('file');
+        $fileSize = $photo->getSize();
+        $sizeInKbs = (int) ceil($fileSize / 1000);
+        $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs;
+
+        if ((bool) config_cache('pixelfed.enforce_account_limit') == true) {
+            $limit = (int) config_cache('pixelfed.max_account_size');
+            if ($updatedAccountSize >= $limit) {
+                abort(403, 'Account size limit reached.');
+            }
+        }
+
+        $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
+        $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+        if (in_array($photo->getMimeType(), $mimes) == false) {
+            abort(403, 'Invalid or unsupported mime type.');
+        }
+
+        $storagePath = MediaPathService::get($user, 2);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+        $license = null;
+        $mime = $photo->getMimeType();
+
+        $settings = UserSetting::whereUserId($user->id)->first();
+
+        if ($settings && ! empty($settings->compose_settings)) {
+            $compose = $settings->compose_settings;
+
+            if (isset($compose['default_license']) && $compose['default_license'] != 1) {
+                $license = $compose['default_license'];
+            }
+        }
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        if ($request->has('replace_id')) {
+            $rpid = $request->input('replace_id');
+            $removeMedia = Media::whereNull('status_id')
+                ->whereUserId($user->id)
+                ->whereProfileId($profile->id)
+                ->where('created_at', '>', now()->subHours(2))
+                ->find($rpid);
+            if ($removeMedia) {
+                $dateTime = Carbon::now();
+                MediaDeletePipeline::dispatch($removeMedia)
+                    ->onQueue('mmo')
+                    ->delay($dateTime->addMinutes(15));
+            }
+        }
+
+        $media = new Media;
+        $media->status_id = null;
+        $media->profile_id = $profile->id;
+        $media->user_id = $user->id;
+        $media->media_path = $path;
+        $media->original_sha256 = $hash;
+        $media->size = $photo->getSize();
+        $media->mime = $mime;
+        $media->caption = $request->input('description') ?? '';
+        $media->filter_class = $filterClass;
+        $media->filter_name = $filterName;
+        if ($license) {
+            $media->license = $license;
+        }
+        $media->save();
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+                ImageOptimize::dispatch($media)->onQueue('mmo');
+                break;
+
+            case 'video/mp4':
+                VideoThumbnail::dispatch($media)->onQueue('mmo');
+                $preview_url = '/storage/no-preview.png';
+                $url = '/storage/no-preview.png';
+                break;
+        }
+
+        $user->storage_used = (int) $updatedAccountSize;
+        $user->storage_used_updated_at = now();
+        $user->save();
+
+        Cache::forget($limitKey);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        $res['preview_url'] = $media->url().'?v='.time();
+        $res['url'] = null;
+
+        return $this->json($res, 202);
+    }
+
+    /**
+     * GET /api/v1/mutes
+     *
+     *
+     * @return AccountTransformer
+     */
+    public function accountMutes(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+        ]);
+
+        $user = $request->user();
+        $limit = $request->input('limit', 40);
+        if ($limit > 80) {
+            $limit = 80;
+        }
+
+        $mutes = UserFilter::whereUserId($user->profile_id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('mute')
+            ->orderByDesc('id')
+            ->simplePaginate($limit)
+            ->withQueryString();
+
+        $res = $mutes->pluck('filterable_id')
+            ->map(function ($id) {
+                return AccountService::get($id, true);
+            })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values();
+
+        $baseUrl = config('app.url').'/api/v1/mutes?limit='.$limit.'&';
+        $next = $mutes->nextPageUrl();
+        $prev = $mutes->previousPageUrl();
+
+        if ($next && ! $prev) {
+            $link = '<'.$next.'>; rel="next"';
+        }
+
+        if (! $next && $prev) {
+            $link = '<'.$prev.'>; rel="prev"';
+        }
+
+        if ($next && $prev) {
+            $link = '<'.$next.'>; rel="next",<'.$prev.'>; rel="prev"';
+        }
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/mute
+     *
+     * @param  int  $id
+     * @return RelationshipTransformer
+     */
+    public function accountMuteById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        $pid = $user->profile_id;
+
+        if (intval($pid) === intval($id)) {
+            return $this->json(['error' => 'You cannot mute yourself'], 500);
+        }
+
+        $account = Profile::findOrFail($id);
+
+        abort_if($account->moved_to_profile_id, 422, 'Cannot mute an account that has migrated!');
+
+        if ($account && $account->domain) {
+            $domain = $account->domain;
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $count = UserFilterService::muteCount($pid);
+        $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes');
+        if ($count == 0) {
+            $filterCount = UserFilter::whereUserId($pid)
+                ->whereFilterType('mute')
+                ->get()
+                ->map(function ($rec) {
+                    return AccountService::get($rec->filterable_id, true);
+                })
+                ->filter(function ($account) {
+                    return $account && isset($account['id']);
+                })
+                ->values()
+                ->count();
+            abort_if($filterCount >= $maxLimit, 422, AccountController::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts');
+        } else {
+            abort_if($count >= $maxLimit, 422, AccountController::FILTER_LIMIT_MUTE_TEXT.$maxLimit.' accounts');
+        }
+
+        $filter = UserFilter::firstOrCreate([
+            'user_id' => $pid,
+            'filterable_id' => $account->id,
+            'filterable_type' => 'App\Profile',
+            'filter_type' => 'mute',
+        ]);
+
+        RelationshipService::refresh($pid, $id);
+
+        $resource = new Fractal\Resource\Item($account, new RelationshipTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/accounts/{id}/unmute
+     *
+     * @param  int  $id
+     * @return RelationshipTransformer
+     */
+    public function accountUnmuteById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        $pid = $user->profile_id;
+
+        if (intval($pid) === intval($id)) {
+            return $this->json(['error' => 'You cannot unmute yourself'], 500);
+        }
+
+        $profile = Profile::findOrFail($id);
+
+        abort_if($profile->moved_to_profile_id, 422, 'Cannot unmute an account that has migrated!');
+
+        $filter = UserFilter::whereUserId($pid)
+            ->whereFilterableId($profile->id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('mute')
+            ->first();
+
+        if ($filter) {
+            $filter->delete();
+            UserFilterService::unmute($pid, $profile->id);
+        }
+
+        RelationshipService::refresh($pid, $id);
+
+        $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/notifications
+     *
+     *
+     * @return NotificationTransformer
+     */
+    public function accountNotifications(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+            'min_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
+            'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
+            'since_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
+            'types[]' => 'sometimes|array',
+            'types[].*' => 'string|in:mention,reblog,follow,favourite',
+            'type' => 'sometimes|string|in:mention,reblog,follow,favourite',
+            '_pe' => 'sometimes',
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit', 20);
+        $ogLimit = $request->input('limit', 20);
+        if ($limit > 40) {
+            $limit = 40;
+            $ogLimit = 40;
+        }
+
+        $since = $request->input('since_id');
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+        $pe = $request->filled('_pe');
+
+        if (! $since && ! $min && ! $max) {
+            $min = 1;
+        }
+
+        if ($since) {
+            $min = $since + 1;
+        }
+
+        $types = $request->input('types');
+
+        if ($request->has('types')) {
+            $limit = 150;
+        }
+
+        $maxId = null;
+        $minId = null;
+        AccountService::setLastActive($request->user()->id);
+
+        $res = $max ?
+            NotificationService::getMaxMastodon($pid, $max, $limit) :
+            NotificationService::getMinMastodon($pid, $min ?? $since, $limit);
+        $ids = $max ?
+            NotificationService::getRankedMaxId($pid, $max, $limit) :
+            NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+        if (! empty($ids)) {
+            $maxId = max($ids);
+            $minId = min($ids);
+        }
+
+        if (empty($res)) {
+            if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+                Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+                NotificationService::warmCache($pid, 400, true);
+            }
+        }
+
+        if ($request->has('types')) {
+            $typesParams = collect($types)->implode('&types[]=');
+            $baseUrl = config('app.url').'/api/v1/notifications?types[]='.$typesParams.'&limit='.$ogLimit.'&';
+        } else {
+            $baseUrl = config('app.url').'/api/v1/notifications?limit='.$ogLimit.'&';
+        }
+
+        if ($minId == $maxId) {
+            $minId = null;
+        }
+
+        $res = collect($res)
+            ->map(function ($n) use ($pe) {
+                if (! $pe) {
+                    if ($n['type'] == 'comment') {
+                        $n['type'] = 'mention';
+
+                        return $n;
+                    }
+
+                    return $n;
+                }
+
+                return $n;
+            })
+            ->filter(function ($n) use ($pe) {
+                if (in_array($n['type'], ['mention', 'reblog', 'favourite'])) {
+                    return isset($n['status'], $n['status']['id']);
+                }
+
+                if (! $pe) {
+                    if (in_array($n['type'], [
+                        'tagged',
+                        'modlog',
+                        'story:react',
+                        'story:comment',
+                        'group:comment',
+                        'group:join:approved',
+                        'group:join:rejected',
+                    ])) {
+                        return false;
+                    }
+
+                    return isset($n['account'], $n['account']['id']);
+                }
+
+                return true;
+            })
+            ->filter(function ($n) use ($types) {
+                if (! $types) {
+                    return true;
+                }
+
+                return in_array($n['type'], $types);
+            })
+            ->take($ogLimit)
+            ->values();
+
+        if ($maxId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
+        }
+
+        if ($minId) {
+            $link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        if ($maxId && $minId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/timelines/home
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function timelineHome(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'page' => 'sometimes|integer|max:40',
+            'min_id' => 'sometimes|integer|min:0|max:'.PHP_INT_MAX,
+            'max_id' => 'sometimes|integer|min:0|max:'.PHP_INT_MAX,
+            'limit' => 'sometimes|integer|min:1',
+            'include_reblogs' => 'sometimes',
+        ]);
+
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $page = $request->input('page');
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+        $limit = $request->input('limit') ?? 20;
+        if ($limit > 40) {
+            $limit = 40;
+        }
+        $pid = $request->user()->profile_id;
+        $includeReblogs = $request->filled('include_reblogs') ? $request->boolean('include_reblogs') : false;
+        $nullFields = $includeReblogs ?
+        ['in_reply_to_id'] :
+        ['in_reply_to_id', 'reblog_of_id'];
+        $inTypes = $includeReblogs ?
+        ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album', 'share'] :
+        ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
+        AccountService::setLastActive($request->user()->id);
+
+        if (config('exp.cached_home_timeline')) {
+            $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
+            if ($min || $max) {
+                if ($request->has('min_id')) {
+                    $res = HomeTimelineService::getRankedMinId($pid, $min ?? 0, $paddedLimit);
+                } else {
+                    $res = HomeTimelineService::getRankedMaxId($pid, $max ?? 0, $paddedLimit);
+                }
+            } else {
+                $res = HomeTimelineService::get($pid, 0, $paddedLimit);
+            }
+
+            if (! $res) {
+                $res = Cache::has('pf:services:apiv1:home:cached:coldbootcheck:'.$pid);
+                if (! $res) {
+                    Cache::set('pf:services:apiv1:home:cached:coldbootcheck:'.$pid, 1, 86400);
+                    FeedWarmCachePipeline::dispatchSync($pid);
+
+                    return response()->json([], 206);
+                } else {
+                    Cache::set('pf:services:apiv1:home:cached:coldbootcheck:'.$pid, 1, 86400);
+
+                    return response()->json([], 206);
+                }
+            }
+
+            $res = collect($res)
+                ->map(function ($id) use ($napi) {
+                    return $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
+                })
+                ->filter(function ($res) {
+                    return $res && isset($res['account']);
+                })
+                ->filter(function ($s) use ($includeReblogs) {
+                    return $includeReblogs ? true : $s['reblog'] == null;
+                })
+                ->take($limit)
+                ->map(function ($status) use ($pid) {
+                    if ($pid) {
+                        $status['favourited'] = (bool) LikeService::liked($pid, $status['id']);
+                        $status['reblogged'] = (bool) ReblogService::get($pid, $status['id']);
+                        $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+                    }
+
+                    return $status;
+                })
+                ->values();
+
+            $baseUrl = config('app.url').'/api/v1/timelines/home?limit='.$limit.'&';
+            $minId = $res->map(function ($s) {
+                return ['id' => $s['id']];
+            })->min('id');
+            $maxId = $res->map(function ($s) {
+                return ['id' => $s['id']];
+            })->max('id');
+
+            if ($minId == $maxId) {
+                $minId = null;
+            }
+
+            if ($maxId) {
+                $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
+            }
+
+            if ($minId) {
+                $link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+            }
+
+            if ($maxId && $minId) {
+                $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+            }
+
+            $headers = isset($link) ? ['Link' => $link] : [];
+
+            return $this->json($res->toArray(), 200, $headers);
+        }
+
+        $following = Cache::remember('profile:following:'.$pid, 1209600, function () use ($pid) {
+            $following = Follower::whereProfileId($pid)->pluck('following_id');
+
+            return $following->push($pid)->toArray();
+        });
+
+        $muted = UserFilterService::mutes($pid);
+
+        if ($muted && count($muted)) {
+            $following = array_diff($following, $muted);
+        }
+
+        if ($min || $max) {
+            $dir = $min ? '>' : '<';
+            $id = $min ?? $max;
+            $res = Status::select(
+                'id',
+                'profile_id',
+                'type',
+                'visibility',
+                'in_reply_to_id',
+                'reblog_of_id'
+            )
+                ->where('id', $dir, $id)
+                ->whereNull($nullFields)
+                ->whereIntegerInRaw('profile_id', $following)
+                ->whereIn('type', $inTypes)
+                ->whereIn('visibility', ['public', 'unlisted', 'private'])
+                ->orderByDesc('id')
+                ->take(($limit * 2))
+                ->get()
+                ->map(function ($s) use ($pid, $napi) {
+                    try {
+                        $account = $napi ? AccountService::get($s['profile_id'], true) : AccountService::getMastodon($s['profile_id'], true);
+                        if (! $account) {
+                            return false;
+                        }
+                        $status = $napi ? StatusService::get($s['id'], false) : StatusService::getMastodon($s['id'], false);
+                        if (! $status || ! isset($status['account']) || ! isset($status['account']['id'])) {
+                            return false;
+                        }
+                    } catch (\Exception $e) {
+                        return false;
+                    }
+
+                    $status['account'] = $account;
+
+                    if ($pid) {
+                        $status['favourited'] = (bool) LikeService::liked($pid, $s['id']);
+                        $status['reblogged'] = (bool) ReblogService::get($pid, $status['id']);
+                        $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+                    }
+
+                    return $status;
+                })
+                ->filter(function ($status) {
+                    return $status && isset($status['account']);
+                })
+                ->map(function ($status) use ($pid) {
+                    if (! empty($status['reblog'])) {
+                        $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']);
+                        $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']);
+                        $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+                    }
+
+                    return $status;
+                })
+                ->take($limit)
+                ->values();
+        } else {
+            $res = Status::select(
+                'id',
+                'profile_id',
+                'type',
+                'visibility',
+                'in_reply_to_id',
+                'reblog_of_id',
+            )
+                ->whereNull($nullFields)
+                ->whereIntegerInRaw('profile_id', $following)
+                ->whereIn('type', $inTypes)
+                ->whereIn('visibility', ['public', 'unlisted', 'private'])
+                ->orderByDesc('id')
+                ->take(($limit * 2))
+                ->get()
+                ->map(function ($s) use ($pid, $napi) {
+                    try {
+                        $account = $napi ? AccountService::get($s['profile_id'], true) : AccountService::getMastodon($s['profile_id'], true);
+                        if (! $account) {
+                            return false;
+                        }
+                        $status = $napi ? StatusService::get($s['id'], false) : StatusService::getMastodon($s['id'], false);
+                        if (! $status || ! isset($status['account']) || ! isset($status['account']['id'])) {
+                            return false;
+                        }
+                    } catch (\Exception $e) {
+                        return false;
+                    }
+
+                    $status['account'] = $account;
+
+                    if ($pid) {
+                        $status['favourited'] = (bool) LikeService::liked($pid, $s['id']);
+                        $status['reblogged'] = (bool) ReblogService::get($pid, $status['id']);
+                        $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+                    }
+
+                    return $status;
+                })
+                ->filter(function ($status) {
+                    return $status && isset($status['account']);
+                })
+                ->map(function ($status) use ($pid) {
+                    if (! empty($status['reblog'])) {
+                        $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']);
+                        $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']);
+                        $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']);
+                    }
+
+                    return $status;
+                })
+                ->take($limit)
+                ->values();
+        }
+
+        $baseUrl = config('app.url').'/api/v1/timelines/home?limit='.$limit.'&';
+        $minId = $res->map(function ($s) {
+            return ['id' => $s['id']];
+        })->min('id');
+        $maxId = $res->map(function ($s) {
+            return ['id' => $s['id']];
+        })->max('id');
+
+        if ($minId == $maxId) {
+            $minId = null;
+        }
+
+        if ($maxId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
+        }
+
+        if ($minId) {
+            $link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        if ($maxId && $minId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res->toArray(), 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/timelines/public
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function timelinePublic(Request $request)
+    {
+        $this->validate($request, [
+            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'limit' => 'sometimes|integer|min:1',
+            'remote' => 'sometimes',
+            'local' => 'sometimes',
+        ]);
+
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+        if ($max == 0) {
+            $min = 1;
+        }
+        $minOrMax = $request->anyFilled(['max_id', 'min_id']);
+        $limit = $request->input('limit') ?? 20;
+        if ($limit > 40) {
+            $limit = 40;
+        }
+        $user = $request->user();
+
+        $remote = $request->has('remote') && $request->boolean('remote');
+        $local = $request->boolean('local');
+        $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
+        if ($user->has_roles && ! UserRoleService::can($userRoleKey, $user->id)) {
+            return [];
+        }
+        $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
+        AccountService::setLastActive($user->id);
+        $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
+        $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
+        $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
+        $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+        if ($local && $remote) {
+            $feed = Status::select(
+                'id',
+                'uri',
+                'type',
+                'scope',
+                'created_at',
+                'profile_id',
+                'in_reply_to_id',
+                'reblog_of_id'
+            )
+                ->when($minOrMax, function ($q, $minOrMax) use ($min, $max) {
+                    $dir = $min ? '>' : '<';
+                    $id = $min ?? $max;
+
+                    return $q->where('id', $dir, $id);
+                })
+                ->whereNull(['in_reply_to_id', 'reblog_of_id'])
+                ->when($hideNsfw, function ($q, $hideNsfw) {
+                    return $q->where('is_nsfw', false);
+                })
+                ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
+                ->whereScope('public')
+                ->where('id', '>', $amin)
+                ->orderByDesc('id')
+                ->limit(($limit * 2))
+                ->pluck('id')
+                ->values()
+                ->toArray();
+        } elseif ($remote && ! $local) {
+            if (config('instance.timeline.network.cached')) {
+                Cache::remember('api:v1:timelines:network:cache_check', 10368000, function () {
+                    if (NetworkTimelineService::count() == 0) {
+                        NetworkTimelineService::warmCache(true, config('instance.timeline.network.cache_dropoff'));
+                    }
+                });
+
+                if ($max) {
+                    $feed = NetworkTimelineService::getRankedMaxId($max, $limit + 5);
+                } elseif ($min) {
+                    $feed = NetworkTimelineService::getRankedMinId($min, $limit + 5);
+                } else {
+                    $feed = NetworkTimelineService::get(0, $limit + 5);
+                }
+            } else {
+                $feed = Status::select(
+                    'id',
+                    'uri',
+                    'type',
+                    'scope',
+                    'local',
+                    'created_at',
+                    'profile_id',
+                    'in_reply_to_id',
+                    'reblog_of_id'
+                )
+                    ->when($minOrMax, function ($q, $minOrMax) use ($min, $max) {
+                        $dir = $min ? '>' : '<';
+                        $id = $min ?? $max;
+
+                        return $q->where('id', $dir, $id);
+                    })
+                    ->whereNull(['in_reply_to_id', 'reblog_of_id'])
+                    ->when($hideNsfw, function ($q, $hideNsfw) {
+                        return $q->where('is_nsfw', false);
+                    })
+                    ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
+                    ->whereLocal(false)
+                    ->whereScope('public')
+                    ->where('id', '>', $amin)
+                    ->orderByDesc('id')
+                    ->limit(($limit * 2))
+                    ->pluck('id')
+                    ->values()
+                    ->toArray();
+            }
+        } else {
+            if (config('instance.timeline.local.cached')) {
+                Cache::remember('api:v1:timelines:public:cache_check', 10368000, function () {
+                    if (PublicTimelineService::count() == 0) {
+                        PublicTimelineService::warmCache(true, 400);
+                    }
+                });
+
+                if ($max) {
+                    $feed = PublicTimelineService::getRankedMaxId($max, $limit + 5);
+                } elseif ($min) {
+                    $feed = PublicTimelineService::getRankedMinId($min, $limit + 5);
+                } else {
+                    $feed = PublicTimelineService::get(0, $limit + 5);
+                }
+            } else {
+                $feed = Status::select(
+                    'id',
+                    'uri',
+                    'type',
+                    'scope',
+                    'local',
+                    'created_at',
+                    'profile_id',
+                    'in_reply_to_id',
+                    'reblog_of_id'
+                )
+                    ->when($minOrMax, function ($q, $minOrMax) use ($min, $max) {
+                        $dir = $min ? '>' : '<';
+                        $id = $min ?? $max;
+
+                        return $q->where('id', $dir, $id);
+                    })
+                    ->whereNull(['in_reply_to_id', 'reblog_of_id'])
+                    ->when($hideNsfw, function ($q, $hideNsfw) {
+                        return $q->where('is_nsfw', false);
+                    })
+                    ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
+                    ->whereLocal(true)
+                    ->whereScope('public')
+                    ->where('id', '>', $amin)
+                    ->orderByDesc('id')
+                    ->limit(($limit * 2))
+                    ->pluck('id')
+                    ->values()
+                    ->toArray();
+            }
+        }
+
+        $res = collect($feed)
+            ->filter(function ($k) use ($min, $max) {
+                if (! $min && ! $max) {
+                    return true;
+                }
+
+                if ($min) {
+                    return $min != $k;
+                }
+
+                if ($max) {
+                    return $max != $k;
+                }
+            })
+            ->map(function ($k) use ($user, $napi) {
+                try {
+                    $status = $napi ? StatusService::get($k) : StatusService::getMastodon($k);
+                    if (! $status || ! isset($status['account']) || ! isset($status['account']['id'])) {
+                        return false;
+                    }
+                } catch (\Exception $e) {
+                    return false;
+                }
+
+                $account = $napi ? AccountService::get($status['account']['id'], true) : AccountService::getMastodon($status['account']['id'], true);
+                if (! $account) {
+                    return false;
+                }
+
+                $status['account'] = $account;
+
+                if ($user) {
+                    $status['favourited'] = (bool) LikeService::liked($user->profile_id, $k);
+                    $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $status['id']);
+                    $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status['id']);
+                }
+
+                return $status;
+            })
+            ->filter(function ($s) use ($filtered) {
+                return $s && isset($s['account']) && in_array($s['account']['id'], $filtered) == false;
+            })
+            ->filter(function ($s) use ($domainBlocks) {
+                if (! $domainBlocks || ! count($domainBlocks)) {
+                    return $s;
+                }
+                $domain = strtolower(parse_url($s['url'], PHP_URL_HOST));
+
+                return ! in_array($domain, $domainBlocks);
+            })
+            ->filter(function ($s) use ($asf, $user) {
+                if (! $asf || count($asf) === 0) {
+                    return true;
+                }
+
+                if (in_array($s['account']['id'], $asf)) {
+                    if ($user->profile_id == $s['account']['id']) {
+                        return true;
+                    }
+
+                    return false;
+                }
+
+                return true;
+            })
+            ->take($limit)
+            ->values();
+
+        $baseUrl = config('app.url').'/api/v1/timelines/public?limit='.$limit.'&';
+        if ($remote) {
+            $baseUrl .= 'remote=1&';
+        }
+        if ($local) {
+            $baseUrl .= 'local=1&';
+        }
+        $minId = $res->map(function ($s) {
+            return ['id' => $s['id']];
+        })->min('id');
+        $maxId = $res->map(function ($s) {
+            return ['id' => $s['id']];
+        })->max('id');
+
+        if ($minId == $maxId) {
+            $minId = null;
+        }
+
+        if ($maxId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
+        }
+
+        if ($minId) {
+            $link = '<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        if ($maxId && $minId) {
+            $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next",<'.$baseUrl.'min_id='.$maxId.'>; rel="prev"';
+        }
+
+        $headers = isset($link) ? ['Link' => $link] : [];
+
+        return $this->json($res->toArray(), 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/conversations
+     *
+     *   Not implemented
+     *
+     * @return array
+     */
+    public function conversations(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'min:1|max:40',
+            'scope' => 'nullable|in:inbox,sent,requests',
+        ]);
+
+        $limit = $request->input('limit', 20);
+        $scope = $request->input('scope', 'inbox');
+        $user = $request->user();
+        if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
+            return [];
+        }
+        $pid = $user->profile_id;
+
+        if (config('database.default') == 'pgsql') {
+            $dms = DirectMessage::when($scope === 'inbox', function ($q, $scope) use ($pid) {
+                return $q->whereIsHidden(false)->where('to_id', $pid)->orWhere('from_id', $pid);
+            })
+                ->when($scope === 'sent', function ($q, $scope) use ($pid) {
+                    return $q->whereFromId($pid)->groupBy(['to_id', 'id']);
+                })
+                ->when($scope === 'requests', function ($q, $scope) use ($pid) {
+                    return $q->whereToId($pid)->whereIsHidden(true);
+                });
+        } else {
+            $dms = Conversation::when($scope === 'inbox', function ($q, $scope) use ($pid) {
+                return $q->whereIsHidden(false)
+                    ->where('to_id', $pid)
+                    ->orWhere('from_id', $pid)
+                    ->orderByDesc('status_id')
+                    ->groupBy(['to_id', 'from_id']);
+            })
+                ->when($scope === 'sent', function ($q, $scope) use ($pid) {
+                    return $q->whereFromId($pid)->groupBy('to_id');
+                })
+                ->when($scope === 'requests', function ($q, $scope) use ($pid) {
+                    return $q->whereToId($pid)->whereIsHidden(true);
+                });
+        }
+
+        $dms = $dms->orderByDesc('status_id')
+            ->simplePaginate($limit)
+            ->map(function ($dm) use ($pid) {
+                $from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id;
+                $res = [
+                    'id' => $dm->id,
+                    'unread' => false,
+                    'accounts' => [
+                        AccountService::getMastodon($from, true),
+                    ],
+                    'last_status' => StatusService::getDirectMessage($dm->status_id),
+                ];
+
+                return $res;
+            })
+            ->filter(function ($dm) {
+                if (! $dm || empty($dm['last_status']) || ! isset($dm['accounts']) || ! count($dm['accounts']) || ! isset($dm['accounts'][0]) || ! isset($dm['accounts'][0]['id'])) {
+                    return false;
+                }
+
+                return true;
+            })
+            ->unique(function ($item, $key) {
+                return $item['accounts'][0]['id'];
+            })
+            ->values();
+
+        return $this->json($dms);
+    }
+
+    /**
+     * GET /api/v1/statuses/{id}
+     *
+     * @param  int  $id
+     * @return StatusTransformer
+     */
+    public function statusById(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        AccountService::setLastActive($request->user()->id);
+        $pid = $request->user()->profile_id;
+
+        $res = $request->has(self::PF_API_ENTITY_KEY) ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
+        if (! $res || ! isset($res['visibility'])) {
+            abort(404);
+        }
+
+        if ($res && isset($res['account'], $res['account']['acct'], $res['account']['url']) && strpos($res['account']['acct'], '@') != -1) {
+            $domain = parse_url($res['account']['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        $scope = $res['visibility'];
+        if (! in_array($scope, ['public', 'unlisted'])) {
+            if ($scope === 'private') {
+                if (intval($res['account']['id']) !== intval($pid)) {
+                    abort_unless(FollowerService::follows($pid, $res['account']['id']), 403);
+                }
+            } else {
+                abort(400, 'Invalid request');
+            }
+        }
+
+        if (! empty($res['reblog']) && isset($res['reblog']['id'])) {
+            $res['reblog']['favourited'] = (bool) LikeService::liked($pid, $res['reblog']['id']);
+            $res['reblog']['reblogged'] = (bool) ReblogService::get($pid, $res['reblog']['id']);
+            $res['reblog']['bookmarked'] = BookmarkService::get($pid, $res['reblog']['id']);
+        }
+
+        $res['favourited'] = LikeService::liked($pid, $res['id']);
+        $res['reblogged'] = ReblogService::get($pid, $res['id']);
+        $res['bookmarked'] = BookmarkService::get($pid, $res['id']);
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/statuses/{id}/context
+     *
+     * @param  int  $id
+     * @return StatusTransformer
+     */
+    public function statusContext(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $user = $request->user();
+        $pid = $user->profile_id;
+        $status = StatusService::getMastodon($id, false);
+        $pe = $request->has(self::PF_API_ENTITY_KEY);
+
+        if (! $status || ! isset($status['account'])) {
+            return response('', 404);
+        }
+
+        if ($status && isset($status['account'], $status['account']['acct']) && strpos($status['account']['acct'], '@') != -1) {
+            $domain = parse_url($status['account']['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+
+        if (intval($status['account']['id']) !== intval($user->profile_id)) {
+            if ($status['visibility'] == 'private') {
+                if (! FollowerService::follows($user->profile_id, $status['account']['id'])) {
+                    return response('', 404);
+                }
+            } else {
+                if (! in_array($status['visibility'], ['public', 'unlisted'])) {
+                    return response('', 404);
+                }
+            }
+        }
+
+        $ancestors = [];
+        $descendants = [];
+
+        if ($status['in_reply_to_id']) {
+            $ancestors[] = $pe ?
+            StatusService::get($status['in_reply_to_id'], false) :
+            StatusService::getMastodon($status['in_reply_to_id'], false);
+        }
+
+        if ($status['replies_count']) {
+            $filters = UserFilterService::filters($pid);
+
+            $descendants = DB::table('statuses')
+                ->where('in_reply_to_id', $id)
+                ->limit(20)
+                ->pluck('id')
+                ->map(function ($sid) use ($pe) {
+                    return $pe ?
+                     StatusService::get($sid, false) :
+                     StatusService::getMastodon($sid, false);
+                })
+                ->filter(function ($post) use ($filters) {
+                    return $post && isset($post['account'], $post['account']['id']) && ! in_array($post['account']['id'], $filters);
+                })
+                ->map(function ($status) use ($pid) {
+                    $status['favourited'] = LikeService::liked($pid, $status['id']);
+                    $status['reblogged'] = ReblogService::get($pid, $status['id']);
+
+                    return $status;
+                })
+                ->values();
+        }
+
+        $res = [
+            'ancestors' => $ancestors,
+            'descendants' => $descendants,
+        ];
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/statuses/{id}/card
+     *
+     * @param  int  $id
+     * @return StatusTransformer
+     */
+    public function statusCard(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $res = [];
+
+        return response()->json($res);
+    }
+
+    /**
+     * GET /api/v1/statuses/{id}/reblogged_by
+     *
+     * @param  int  $id
+     * @return AccountTransformer
+     */
+    public function statusRebloggedBy(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1|max:80',
+        ]);
+
+        $limit = $request->input('limit', 10);
+        $user = $request->user();
+        $pid = $user->profile_id;
+        $status = Status::findOrFail($id);
+        $account = AccountService::get($status->profile_id, true);
+        abort_if(! $account, 404);
+        abort_if(isset($account['moved'], $account['moved']['id']), 404, 'Account moved');
+        if ($account && strpos($account['acct'], '@') != -1) {
+            $domain = parse_url($account['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+        $author = intval($status->profile_id) === intval($pid) || $user->is_admin;
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+
+        abort_if(
+            ! $status->type ||
+            ! in_array($status->type, ['photo', 'photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
+            404,
+        );
+
+        if (! $author) {
+            if ($status->scope == 'private') {
+                abort_if(! FollowerService::follows($pid, $status->profile_id), 403);
+            } else {
+                abort_if(! in_array($status->scope, ['public', 'unlisted']), 403);
+            }
+
+            if ($request->has('cursor')) {
+                return $this->json([]);
+            }
+        }
+
+        $res = Status::where('reblog_of_id', $status->id)
+            ->orderByDesc('id')
+            ->cursorPaginate($limit)
+            ->withQueryString();
+
+        if (! $res) {
+            return $this->json([]);
+        }
+
+        $headers = [];
+        if ($author && $res->hasPages()) {
+            $links = '';
+            if ($res->onFirstPage()) {
+                if ($res->nextPageUrl()) {
+                    $links = '<'.$res->nextPageUrl().'>; rel="prev"';
+                }
+            } else {
+                if ($res->previousPageUrl()) {
+                    $links = '<'.$res->previousPageUrl().'>; rel="next"';
+                }
+
+                if ($res->nextPageUrl()) {
+                    if (! empty($links)) {
+                        $links .= ', ';
+                    }
+                    $links .= '<'.$res->nextPageUrl().'>; rel="prev"';
+                }
+            }
+
+            $headers = ['Link' => $links];
+        }
+
+        $res = $res->map(function ($status) use ($pid, $napi) {
+            $account = $napi ? AccountService::get($status->profile_id, true) : AccountService::getMastodon($status->profile_id, true);
+            if (! $account) {
+                return false;
+            }
+            if ($napi) {
+                $account['follows'] = $status->profile_id == $pid ? null : FollowerService::follows($pid, $status->profile_id);
+            }
+
+            return $account;
+        })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values();
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * GET /api/v1/statuses/{id}/favourited_by
+     *
+     * @param  int  $id
+     * @return AccountTransformer
+     */
+    public function statusFavouritedBy(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+        ]);
+
+        $limit = $request->input('limit', 40);
+        if ($limit > 80) {
+            $limit = 80;
+        }
+        $user = $request->user();
+        $pid = $user->profile_id;
+        $status = Status::findOrFail($id);
+        $account = AccountService::get($status->profile_id, true);
+        abort_if(! $account, 404);
+        abort_if(isset($account['moved'], $account['moved']['id']), 404, 'Account moved');
+        if ($account && strpos($account['acct'], '@') != -1) {
+            $domain = parse_url($account['url'], PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+        $author = intval($status->profile_id) === intval($pid) || $user->is_admin;
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+
+        abort_if(
+            ! $status->type ||
+            ! in_array($status->type, ['photo', 'photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
+            404,
+        );
+
+        if (! $author) {
+            if ($status->scope == 'private') {
+                abort_if(! FollowerService::follows($pid, $status->profile_id), 403);
+            } else {
+                abort_if(! in_array($status->scope, ['public', 'unlisted']), 403);
+            }
+
+            if ($request->has('cursor')) {
+                return $this->json([]);
+            }
+        }
+
+        $res = Like::where('status_id', $status->id)
+            ->orderByDesc('id')
+            ->cursorPaginate($limit)
+            ->withQueryString();
+
+        if (! $res) {
+            return $this->json([]);
+        }
+
+        $headers = [];
+        if ($author && $res->hasPages()) {
+            $links = '';
+
+            if ($res->onFirstPage()) {
+                if ($res->nextPageUrl()) {
+                    $links = '<'.$res->nextPageUrl().'>; rel="prev"';
+                }
+            } else {
+                if ($res->previousPageUrl()) {
+                    $links = '<'.$res->previousPageUrl().'>; rel="next"';
+                }
+
+                if ($res->nextPageUrl()) {
+                    if (! empty($links)) {
+                        $links .= ', ';
+                    }
+                    $links .= '<'.$res->nextPageUrl().'>; rel="prev"';
+                }
+            }
+
+            $headers = ['Link' => $links];
+        }
+
+        $res = $res->map(function ($like) use ($pid, $napi) {
+            $account = $napi ? AccountService::get($like->profile_id, true) : AccountService::getMastodon($like->profile_id, true);
+            if (! $account) {
+                return false;
+            }
+
+            if ($napi) {
+                $account['follows'] = $like->profile_id == $pid ? null : FollowerService::follows($pid, $like->profile_id);
+            }
+
+            return $account;
+        })
+            ->filter(function ($account) {
+                return $account && isset($account['id']);
+            })
+            ->values();
+
+        return $this->json($res, 200, $headers);
+    }
+
+    /**
+     * POST /api/v1/statuses
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function statusCreate(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $this->validate($request, [
+            'status' => 'nullable|string|max:'.(int) config_cache('pixelfed.max_caption_length'),
+            'in_reply_to_id' => 'nullable',
+            'media_ids' => 'sometimes|array|max:'.(int) config_cache('pixelfed.max_album_length'),
+            'sensitive' => 'nullable',
+            'visibility' => 'string|in:private,unlisted,public',
+            'spoiler_text' => 'sometimes|max:140',
+            'place_id' => 'sometimes|integer|min:1|max:128769',
+            'collection_ids' => 'sometimes|array|max:3',
+            'comments_disabled' => 'sometimes|boolean',
+        ]);
+
+        if ($request->hasHeader('idempotency-key')) {
+            $key = 'pf:api:v1:status:idempotency-key:'.$request->user()->id.':'.hash('sha1', $request->header('idempotency-key'));
+            $exists = Cache::has($key);
+            abort_if($exists, 400, 'Duplicate idempotency key.');
+            Cache::put($key, 1, 3600);
+        }
+
+        if (config('costar.enabled') == true) {
+            $blockedKeywords = config('costar.keyword.block');
+            if ($blockedKeywords !== null && $request->status) {
+                $keywords = config('costar.keyword.block');
+                foreach ($keywords as $kw) {
+                    if (Str::contains($request->status, $kw) == true) {
+                        abort(400, 'Invalid object. Contains banned keyword.');
+                    }
+                }
+            }
+        }
+
+        if (! $request->filled('media_ids') && ! $request->filled('in_reply_to_id')) {
+            abort(403, 'Empty statuses are not allowed');
+        }
+
+        $ids = $request->input('media_ids');
+        $in_reply_to_id = $request->input('in_reply_to_id');
+
+        $user = $request->user();
+
+        if ($user->has_roles) {
+            if ($in_reply_to_id != null) {
+                abort_if(! UserRoleService::can('can-comment', $user->id), 403, 'Invalid permissions for this action');
+            } else {
+                abort_if(! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+            }
+        }
+
+        $profile = $user->profile;
+
+        $limitKey = 'compose:rate-limit:store:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $minId = SnowflakeService::byDate(now()->subDays(1));
+            $dailyLimit = Status::whereProfileId($user->profile_id)
+                ->where('id', '>', $minId)
+                ->count();
+
+            return $dailyLimit >= 1000;
+        });
+
+        abort_if($limitReached == true, 429);
+
+        $visibility = $profile->is_private ? 'private' : (
+            $profile->unlisted == true &&
+            $request->input('visibility', 'public') == 'public' ?
+            'unlisted' :
+            $request->input('visibility', 'public'));
+
+        if ($user->last_active_at == null) {
+            return [];
+        }
+
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $content = $request->filled('status') ? strip_tags($request->input('status')) : $defaultCaption;
+        $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false);
+        $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null;
+
+        if ($in_reply_to_id) {
+            $parent = Status::findOrFail($in_reply_to_id);
+            if ($parent->comments_disabled) {
+                return $this->json('Comments have been disabled on this post', 422);
+            }
+            $blocks = UserFilterService::blocks($parent->profile_id);
+            abort_if(in_array($profile->id, $blocks), 422, 'Cannot reply to this post at this time.');
+
+            $status = new Status;
+            $status->caption = $content;
+            $status->rendered = $defaultCaption;
+            $status->scope = $visibility;
+            $status->visibility = $visibility;
+            $status->profile_id = $user->profile_id;
+            $status->is_nsfw = $cw;
+            $status->cw_summary = $spoilerText;
+            $status->in_reply_to_id = $parent->id;
+            $status->in_reply_to_profile_id = $parent->profile_id;
+            $status->save();
+            StatusService::del($parent->id);
+            Cache::forget('status:replies:all:'.$parent->id);
+        }
+
+        if ($ids) {
+            if (Media::whereUserId($user->id)
+                ->whereNull('status_id')
+                ->find($ids)
+                ->count() == 0
+            ) {
+                abort(400, 'Invalid media_ids');
+            }
+
+            if (! $in_reply_to_id) {
+                $status = new Status;
+                $status->caption = $content;
+                $status->rendered = $defaultCaption;
+                $status->profile_id = $user->profile_id;
+                $status->is_nsfw = $cw;
+                $status->cw_summary = $spoilerText;
+                $status->scope = 'draft';
+                $status->visibility = 'draft';
+                if ($request->has('place_id')) {
+                    $status->place_id = $request->input('place_id');
+                }
+                $status->save();
+            }
+
+            $mimes = [];
+
+            foreach ($ids as $k => $v) {
+                if ($k + 1 > (int) config_cache('pixelfed.max_album_length')) {
+                    continue;
+                }
+                $m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v);
+                if ($m->profile_id !== $user->profile_id || $m->status_id) {
+                    abort(403, 'Invalid media id');
+                }
+                $m->order = $k + 1;
+                $m->status_id = $status->id;
+                $m->save();
+                array_push($mimes, $m->mime);
+            }
+
+            if (empty($mimes)) {
+                $status->delete();
+                abort(400, 'Invalid media ids');
+            }
+
+            if ($request->has('comments_disabled') && $request->input('comments_disabled')) {
+                $status->comments_disabled = true;
+            }
+
+            $status->scope = $visibility;
+            $status->visibility = $visibility;
+            $status->type = StatusController::mimeTypeCheck($mimes);
+            $status->save();
+        }
+
+        if (! $status) {
+            abort(500, 'An error occured.');
+        }
+
+        NewStatusPipeline::dispatch($status);
+        if ($status->in_reply_to_id) {
+            CommentPipeline::dispatch($parent, $status);
+        }
+        Cache::forget('user:account:id:'.$user->id);
+        Cache::forget('_api:statuses:recent_9:'.$user->profile_id);
+        Cache::forget('profile:status_count:'.$user->profile_id);
+        Cache::forget($user->storageUsedKey());
+        Cache::forget('profile:embed:'.$status->profile_id);
+        Cache::forget($limitKey);
+
+        if ($request->has('collection_ids') && $ids) {
+            $collections = Collection::whereProfileId($user->profile_id)
+                ->find($request->input('collection_ids'))
+                ->each(function ($collection) use ($status) {
+                    $count = $collection->items()->count();
+                    $item = CollectionItem::firstOrCreate([
+                        'collection_id' => $collection->id,
+                        'object_type' => 'App\Status',
+                        'object_id' => $status->id,
+                    ], [
+                        'order' => $count,
+                    ]);
+
+                    CollectionService::addItem(
+                        $collection->id,
+                        $status->id,
+                        $count
+                    );
+                    $collection->updated_at = now();
+                    $collection->save();
+                    CollectionService::setCollection($collection->id, $collection);
+                });
+        }
+
+        $res = StatusService::getMastodon($status->id, false);
+        $res['favourited'] = false;
+        $res['language'] = 'en';
+        $res['bookmarked'] = false;
+        $res['card'] = null;
+
+        return $this->json($res);
+    }
+
+    /**
+     * DELETE /api/v1/statuses
+     *
+     * @param  int  $id
+     * @return null
+     */
+    public function statusDelete(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        AccountService::setLastActive($request->user()->id);
+        $status = Status::whereProfileId($request->user()->profile->id)
+            ->findOrFail($id);
+
+        $resource = new Fractal\Resource\Item($status, new StatusTransformer);
+
+        Cache::forget('profile:status_count:'.$status->profile_id);
+        StatusDelete::dispatch($status);
+
+        $res = $this->fractal->createData($resource)->toArray();
+        $res['text'] = $res['content'];
+        unset($res['content']);
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/reblog
+     *
+     * @param  int  $id
+     * @return StatusTransformer
+     */
+    public function statusShare(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-share', $user->id), 403, 'Invalid permissions for this action');
+        AccountService::setLastActive($user->id);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $account = AccountService::get($status->profile_id);
+        abort_if(isset($account['moved'], $account['moved']['id']), 422, 'Cannot share a post from an account that has migrated');
+        if ($status && ($status->uri || $status->url || $status->object_url)) {
+            $url = $status->uri ?? $status->url ?? $status->object_url;
+            $domain = parse_url($url, PHP_URL_HOST);
+            abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
+        }
+        if (intval($status->profile_id) !== intval($user->profile_id)) {
+            if ($status->scope == 'private') {
+                abort_if(! FollowerService::follows($user->profile_id, $status->profile_id), 403);
+            } else {
+                abort_if(! in_array($status->scope, ['public', 'unlisted']), 403);
+            }
+
+            $blocks = UserFilterService::blocks($status->profile_id);
+            if ($blocks && in_array($user->profile_id, $blocks)) {
+                abort(422);
+            }
+        }
+
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $share = Status::firstOrCreate([
+            'caption' => $defaultCaption,
+            'rendered' => $defaultCaption,
+            'profile_id' => $user->profile_id,
+            'reblog_of_id' => $status->id,
+            'type' => 'share',
+            'in_reply_to_profile_id' => $status->profile_id,
+            'scope' => 'public',
+            'visibility' => 'public',
+        ]);
+
+        SharePipeline::dispatch($share)->onQueue('low');
+
+        StatusService::del($status->id);
+        ReblogService::add($user->profile_id, $status->id);
+        $res = StatusService::getMastodon($status->id);
+        $res['reblogged'] = true;
+        $res['favourited'] = LikeService::liked($user->profile_id, $status->id);
+        $res['bookmarked'] = BookmarkService::get($user->profile_id, $status->id);
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/unreblog
+     *
+     * @param  int  $id
+     * @return StatusTransformer
+     */
+    public function statusUnshare(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-share', $user->id), 403, 'Invalid permissions for this action');
+        AccountService::setLastActive($user->id);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $account = AccountService::get($status->profile_id);
+        abort_if(isset($account['moved'], $account['moved']['id']), 422, 'Cannot unshare a post from an account that has migrated');
+
+        if (intval($status->profile_id) !== intval($user->profile_id)) {
+            if ($status->scope == 'private') {
+                abort_if(! FollowerService::follows($user->profile_id, $status->profile_id), 403);
+            } else {
+                abort_if(! in_array($status->scope, ['public', 'unlisted']), 403);
+            }
+        }
+
+        $reblog = Status::whereProfileId($user->profile_id)
+            ->whereReblogOfId($status->id)
+            ->first();
+
+        if (! $reblog) {
+            $res = StatusService::getMastodon($status->id);
+            $res['reblogged'] = false;
+
+            return $this->json($res);
+        }
+
+        UndoSharePipeline::dispatch($reblog)->onQueue('low');
+        ReblogService::del($user->profile_id, $status->id);
+
+        $res = StatusService::getMastodon($status->id);
+        $res['reblogged'] = false;
+        $res['favourited'] = LikeService::liked($user->profile_id, $status->id);
+        $res['bookmarked'] = BookmarkService::get($user->profile_id, $status->id);
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/timelines/tag/{hashtag}
+     *
+     * @param  string  $hashtag
+     * @return StatusTransformer
+     */
+    public function timelineHashtag(Request $request, $hashtag)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'page' => 'nullable|integer|max:40',
+            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'limit' => 'sometimes|integer|min:1',
+            'only_media' => 'sometimes',
+            '_pe' => 'sometimes',
+        ]);
+
+        $user = $request->user();
+        abort_if(
+            $user->has_roles && ! UserRoleService::can('can-view-hashtag-feed', $user->id),
+            403,
+            'Invalid permissions for this action'
+        );
+
+        if (config('database.default') === 'pgsql') {
+            $tag = Hashtag::where('name', 'ilike', $hashtag)
+                ->orWhere('slug', 'ilike', $hashtag)
+                ->first();
+        } else {
+            $tag = Hashtag::whereName($hashtag)
+                ->orWhere('slug', $hashtag)
+                ->first();
+        }
+
+        if (! $tag) {
+            return response()->json([]);
+        }
+
+        if ($tag->is_banned == true) {
+            return $this->json([]);
+        }
+
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+        $limit = $request->input('limit', 20);
+        if ($limit > 40) {
+            $limit = 40;
+        }
+        $onlyMedia = $request->boolean('only_media', true);
+        $pe = $request->has(self::PF_API_ENTITY_KEY);
+        $pid = $request->user()->profile_id;
+
+        if ($min || $max) {
+            $minMax = SnowflakeService::byDate(now()->subMonths(6));
+            if ($min && intval($min) < $minMax) {
+                return [];
+            }
+            if ($max && intval($max) < $minMax) {
+                return [];
+            }
+        }
+
+        $filters = UserFilterService::filters($pid);
+        $domainBlocks = UserFilterService::domainBlocks($pid);
+
+        if (! $min && ! $max) {
+            $id = 1;
+            $dir = '>';
+        } else {
+            $dir = $min ? '>' : '<';
+            $id = $min ?? $max;
+        }
+
+        $res = StatusHashtag::whereHashtagId($tag->id)
+            ->where('status_id', $dir, $id)
+            ->orderBy('status_id', 'desc')
+            ->limit(100)
+            ->pluck('status_id')
+            ->map(function ($i) use ($pe) {
+                return $pe ? StatusService::get($i, false) : StatusService::getMastodon($i, false);
+            })
+            ->filter(function ($i) use ($onlyMedia, $pid) {
+                if (! $i || ! isset($i['account'], $i['account']['id'])) {
+                    return false;
+                }
+                if ($i['visibility'] === 'unlisted') {
+                    if ((int) $i['account']['id'] !== $pid) {
+                        return false;
+                    }
+                }
+                // if ($i['visibility'] === 'private') {
+                //     if ((int) $i['account']['id'] !== $pid) {
+                //         return FollowerService::follows($pid, $i['account']['id'], true);
+                //     }
+                // }
+                if ($onlyMedia == true) {
+                    if (! isset($i['media_attachments']) || ! count($i['media_attachments'])) {
+                        return false;
+                    }
+                }
+
+                return $i && isset($i['account'], $i['url']);
+            })
+            ->filter(function ($i) use ($filters, $domainBlocks) {
+                $domain = strtolower(parse_url($i['url'], PHP_URL_HOST));
+
+                return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
+            })
+            ->take($limit)
+            ->values()
+            ->toArray();
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/bookmarks
+     *
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function bookmarks(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+            'max_id' => 'nullable|integer|min:0',
+            'since_id' => 'nullable|integer|min:0',
+            'min_id' => 'nullable|integer|min:0',
+        ]);
+
+        $pe = $request->has('_pe');
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit') ?? 20;
+        if ($limit > 40) {
+            $limit = 40;
+        }
+        $max_id = $request->input('max_id');
+        $since_id = $request->input('since_id');
+        $min_id = $request->input('min_id');
+
+        $dir = $min_id ? '>' : '<';
+        $id = $min_id ?? $max_id;
+
+        $bookmarkQuery = Bookmark::whereProfileId($pid)
+            ->orderByDesc('id')
+            ->cursorPaginate($limit);
+
+        $bookmarks = $bookmarkQuery->map(function ($bookmark) use ($pid, $pe) {
+            $status = $pe ? StatusService::get($bookmark->status_id, false) : StatusService::getMastodon($bookmark->status_id, false);
+
+            if ($status) {
+                $status['bookmarked'] = true;
+                $status['favourited'] = LikeService::liked($pid, $status['id']);
+                $status['reblogged'] = ReblogService::get($pid, $status['id']);
+            }
+
+            return $status;
+        })
+            ->filter()
+            ->values()
+            ->toArray();
+
+        $links = null;
+        $headers = [];
+
+        if ($bookmarkQuery->nextCursor()) {
+            $links .= '<'.$bookmarkQuery->nextPageUrl().'&limit='.$limit.'>; rel="next"';
+        }
+
+        if ($bookmarkQuery->previousCursor()) {
+            if ($links != null) {
+                $links .= ', ';
+            }
+            $links .= '<'.$bookmarkQuery->previousPageUrl().'&limit='.$limit.'>; rel="prev"';
+        }
+
+        if ($links) {
+            $headers = ['Link' => $links];
+        }
+
+        return $this->json($bookmarks, 200, $headers);
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/bookmark
+     *
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function bookmarkStatus(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $status = Status::findOrFail($id);
+        $user = $request->user();
+        $pid = $request->user()->profile_id;
+        $account = AccountService::get($status->profile_id);
+        abort_if(isset($account['moved'], $account['moved']['id']), 422, 'Cannot bookmark a post from an account that has migrated');
+        abort_if($user->has_roles && ! UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
+        abort_if(! in_array($status->scope, ['public', 'unlisted', 'private']), 404);
+        abort_if(! in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']), 404);
+
+        if ($status->scope == 'private') {
+            abort_if(
+                $pid !== $status->profile_id && ! FollowerService::follows($pid, $status->profile_id),
+                404,
+                'Error: You cannot bookmark private posts from accounts you do not follow.'
+            );
+        }
+
+        Bookmark::firstOrCreate([
+            'status_id' => $status->id,
+            'profile_id' => $pid,
+        ]);
+
+        BookmarkService::add($pid, $status->id);
+
+        $res = StatusService::getMastodon($status->id, false);
+        $res['bookmarked'] = true;
+
+        return $this->json($res);
+    }
+
+    /**
+     * POST /api/v1/statuses/{id}/unbookmark
+     *
+     *
+     *
+     * @return StatusTransformer
+     */
+    public function unbookmarkStatus(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $status = Status::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        $user = $request->user();
+
+        abort_if($user->has_roles && ! UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
+        abort_if(! in_array($status->scope, ['public', 'unlisted', 'private']), 404);
+        abort_if(! in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']), 404);
+
+        $bookmark = Bookmark::whereStatusId($status->id)
+            ->whereProfileId($pid)
+            ->first();
+
+        if ($bookmark) {
+            BookmarkService::del($pid, $status->id);
+            $bookmark->delete();
+        }
+        $res = StatusService::getMastodon($status->id, false);
+        $res['bookmarked'] = false;
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v1/discover/posts
+     *
+     *
+     * @return array
+     */
+    public function discoverPosts(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'limit' => 'integer|min:1|max:40',
+        ]);
+
+        $limit = $request->input('limit', 40);
+        $pid = $request->user()->profile_id;
+        $filters = UserFilterService::filters($pid);
+        $forYou = DiscoverService::getForYou();
+        $posts = $forYou->take(50)->map(function ($post) {
+            return StatusService::getMastodon($post);
+        })
+            ->filter(function ($post) use ($filters) {
+                return $post &&
+                    isset($post['account']) &&
+                    isset($post['account']['id']) &&
+                    ! in_array($post['account']['id'], $filters);
+            })
+            ->take(12)
+            ->values();
+
+        return $this->json(compact('posts'));
+    }
+
+    /**
+     * GET /api/v2/statuses/{id}/replies
+     *
+     *
+     * @return array
+     */
+    public function statusReplies(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1',
+            'sort' => 'in:all,newest,popular',
+        ]);
+
+        $limit = $request->input('limit', 3);
+        if ($limit > 10) {
+            $limit = 10;
+        }
+        $pid = $request->user()->profile_id;
+        $status = StatusService::getMastodon($id, false);
+        abort_if(! $status, 404);
+        abort_if(isset($status['account'], $account['account']['moved']['id']), 404, 'Account moved');
+
+        if ($status['visibility'] == 'private') {
+            if ($pid != $status['account']['id']) {
+                abort_unless(FollowerService::follows($pid, $status['account']['id']), 404);
+            }
+        }
+
+        $sortBy = $request->input('sort', 'all');
+
+        if ($sortBy == 'all' && isset($status['replies_count']) && $status['replies_count'] && $request->has('refresh_cache')) {
+            if (! Cache::has('status:replies:all-rc:'.$id)) {
+                Cache::forget('status:replies:all:'.$id);
+                Cache::put('status:replies:all-rc:'.$id, true, 300);
+            }
+        }
+
+        if ($sortBy == 'all' && ! $request->has('cursor')) {
+            $ids = Cache::remember('status:replies:all:'.$id, 3600, function () use ($id) {
+                return DB::table('statuses')
+                    ->where('in_reply_to_id', $id)
+                    ->orderBy('id')
+                    ->cursorPaginate(3);
+            });
+        } else {
+            $ids = DB::table('statuses')
+                ->where('in_reply_to_id', $id)
+                ->when($sortBy, function ($q, $sortBy) {
+                    if ($sortBy === 'all') {
+                        return $q->orderBy('id');
+                    }
+
+                    if ($sortBy === 'newest') {
+                        return $q->orderByDesc('created_at');
+                    }
+
+                    if ($sortBy === 'popular') {
+                        return $q->orderByDesc('likes_count');
+                    }
+                })
+                ->cursorPaginate($limit);
+        }
+
+        $filters = UserFilterService::filters($pid);
+        $data = $ids->filter(function ($post) use ($filters) {
+            return ! in_array($post->profile_id, $filters);
+        })
+            ->map(function ($post) use ($pid) {
+                $status = StatusService::get($post->id, false);
+
+                if (! $status || ! isset($status['id'])) {
+                    return false;
+                }
+
+                $status['favourited'] = LikeService::liked($pid, $post->id);
+
+                return $status;
+            })
+            ->map(function ($post) {
+                if (isset($post['account']) && isset($post['account']['id'])) {
+                    $account = AccountService::get($post['account']['id'], true);
+                    $post['account'] = $account;
+                }
+
+                return $post;
+            })
+            ->filter(function ($post) {
+                return $post && isset($post['id']) && isset($post['account']) && isset($post['account']['id']);
+            })
+            ->values();
+
+        $res = [
+            'data' => $data,
+            'next' => $ids->nextPageUrl(),
+        ];
+
+        return $this->json($res);
+    }
+
+    /**
+     * GET /api/v2/statuses/{id}/state
+     *
+     *
+     * @return array
+     */
+    public function statusState(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+
+        $status = StatusService::get($id, false, true);
+        abort_if(! $status, 404);
+        abort_if(! in_array($status['visibility'], ['public', 'unlisted', 'private']), 404);
+
+        return $this->json(StatusService::getState($status['id'], $request->user()->profile_id));
+    }
+
+    /**
+     * GET /api/v1.1/discover/accounts/popular
+     *
+     *
+     * @return array
+     */
+    public function discoverAccountsPopular(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $pid = $request->user()->profile_id;
+
+        $ids = Cache::remember('api:v1.1:discover:accounts:popular', 14400, function () {
+            return DB::table('profiles')
+                ->where('is_private', false)
+                ->whereNull('status')
+                ->orderByDesc('profiles.followers_count')
+                ->limit(30)
+                ->get();
+        });
+        $filters = UserFilterService::filters($pid);
+        $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+        $ids = $ids->map(function ($profile) {
+            return AccountService::get($profile->id, true);
+        })
+            ->filter(function ($profile) {
+                return $profile && isset($profile['id'], $profile['locked']) && ! $profile['locked'];
+            })
+            ->filter(function ($profile) use ($pid) {
+                return $profile['id'] != $pid;
+            })
+            ->filter(function ($profile) use ($pid) {
+                return ! FollowerService::follows($pid, $profile['id'], true);
+            })
+            ->filter(function ($profile) use ($asf) {
+                return ! in_array($profile['id'], $asf);
+            })
+            ->filter(function ($profile) use ($filters) {
+                return ! in_array($profile['id'], $filters);
+            })
+            ->take(16)
+            ->values();
+
+        return $this->json($ids);
+    }
+
+    /**
+     * GET /api/v1/preferences
+     *
+     *
+     * @return array
+     */
+    public function getPreferences(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $pid = $request->user()->profile_id;
+        $account = AccountService::get($pid);
+
+        return $this->json([
+            'posting:default:visibility' => $account['locked'] ? 'private' : 'public',
+            'posting:default:sensitive' => false,
+            'posting:default:language' => null,
+            'reading:expand:media' => 'default',
+            'reading:expand:spoilers' => false,
+        ]);
+    }
+
+    /**
+     * GET /api/v1/trends
+     *
+     *
+     * @return array
+     */
+    public function getTrends(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return $this->json([]);
+    }
+
+    /**
+     * GET /api/v1/announcements
+     *
+     *
+     * @return array
+     */
+    public function getAnnouncements(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        return $this->json([]);
+    }
+
+    /**
+     * GET /api/v1/markers
+     *
+     *
+     * @return array
+     */
+    public function getMarkers(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $type = $request->input('timeline');
+        if (is_array($type)) {
+            $type = $type[0];
+        }
+        if (! $type || ! in_array($type, ['home', 'notifications'])) {
+            return $this->json([]);
+        }
+        $pid = $request->user()->profile_id;
+
+        return $this->json(MarkerService::get($pid, $type));
+    }
+
+    /**
+     * POST /api/v1/markers
+     *
+     *
+     * @return array
+     */
+    public function setMarkers(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $pid = $request->user()->profile_id;
+        $home = $request->input('home[last_read_id]');
+        $notifications = $request->input('notifications[last_read_id]');
+
+        if ($home) {
+            return $this->json(MarkerService::set($pid, 'home', $home));
+        }
+
+        if ($notifications) {
+            return $this->json(MarkerService::set($pid, 'notifications', $notifications));
+        }
+
+        return $this->json([]);
+    }
+
+    /**
+     * GET /api/v1/instance/peers
+     *
+     *
+     * @return array
+     */
+    public function instancePeers(Request $request)
+    {
+        if ((bool) config('instance.show_peers') == false) {
+            return $this->json([]);
+        }
+
+        return $this->json(
+            Cache::remember(InstanceService::CACHE_KEY_API_PEERS_LIST, now()->addHours(24), function () {
+                return Instance::whereNotNull('nodeinfo_last_fetched')
+                    ->whereBanned(false)
+                    ->where('nodeinfo_last_fetched', '>', now()->subDays(8))
+                    ->pluck('domain');
+            })
+        );
+    }
 }

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 894 - 856
app/Http/Controllers/Api/ApiV1Dot1Controller.php


+ 314 - 296
app/Http/Controllers/Api/ApiV2Controller.php

@@ -2,319 +2,337 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
 use App\Http\Controllers\Controller;
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
+use App\Jobs\MediaPipeline\MediaDeletePipeline;
+use App\Jobs\VideoPipeline\VideoThumbnail;
 use App\Media;
-use App\UserSetting;
-use App\User;
-use Illuminate\Support\Facades\Cache;
 use App\Services\AccountService;
-use App\Services\BouncerService;
 use App\Services\InstanceService;
 use App\Services\MediaBlocklistService;
 use App\Services\MediaPathService;
 use App\Services\SearchApiV2Service;
+use App\Services\UserRoleService;
+use App\Services\UserStorageService;
+use App\Transformer\Api\Mastodon\v1\MediaTransformer;
+use App\User;
+use App\UserSetting;
 use App\Util\Media\Filter;
-use App\Jobs\MediaPipeline\MediaDeletePipeline;
-use App\Jobs\VideoPipeline\{
-	VideoOptimize,
-	VideoPostProcess,
-	VideoThumbnail
-};
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
+use App\Util\Site\Nodeinfo;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Transformer\Api\Mastodon\v1\{
-	AccountTransformer,
-	MediaTransformer,
-	NotificationTransformer,
-	StatusTransformer,
-};
-use App\Transformer\Api\{
-	RelationshipTransformer,
-};
-use App\Util\Site\Nodeinfo;
 
 class ApiV2Controller extends Controller
 {
-	const PF_API_ENTITY_KEY = "_pe";
+    const PF_API_ENTITY_KEY = '_pe';
 
-	public function json($res, $code = 200, $headers = [])
-	{
-		return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
-	}
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
 
     public function instance(Request $request)
     {
-		$contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
-			if(config_cache('instance.admin.pid')) {
-				return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
-			}
-			$admin = User::whereIsAdmin(true)->first();
-			return $admin && isset($admin->profile_id) ?
-				AccountService::getMastodon($admin->profile_id, true) :
-				null;
-		});
-
-		$rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
-			return config_cache('app.rules') ?
-				collect(json_decode(config_cache('app.rules'), true))
-				->map(function($rule, $key) {
-					$id = $key + 1;
-					return [
-						'id' => "{$id}",
-						'text' => $rule
-					];
-				})
-				->toArray() : [];
-		});
-
-		$res = [
-			'domain' => config('pixelfed.domain.app'),
-			'title' => config_cache('app.name'),
-			'version' => config('pixelfed.version'),
-			'source_url' => 'https://github.com/pixelfed/pixelfed',
-			'description' => config_cache('app.short_description'),
-			'usage' => [
-				'users' => [
-					'active_month' => (int) Nodeinfo::activeUsersMonthly()
-				]
-			],
-			'thumbnail' => [
-				'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
-				'blurhash' => InstanceService::headerBlurhash(),
-				'versions' => [
-					'@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
-					'@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg'))
-				]
-			],
-			'languages' => [config('app.locale')],
-			'configuration' => [
-				'urls' => [
-					'streaming' => 'wss://' . config('pixelfed.domain.app'),
-					'status' => null
-				],
-				'accounts' => [
-					'max_featured_tags' => 0,
-				],
-				'statuses' => [
-					'max_characters' => (int) config('pixelfed.max_caption_length'),
-					'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
-					'characters_reserved_per_url' => 23
-				],
-				'media_attachments' => [
-					'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
-					'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-					'image_matrix_limit' => 3686400,
-					'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-					'video_frame_rate_limit' => 240,
-					'video_matrix_limit' => 3686400
-				],
-				'polls' => [
-					'max_options' => 4,
-					'max_characters_per_option' => 50,
-					'min_expiration' => 300,
-					'max_expiration' => 2629746,
-				],
-				'translation' => [
-					'enabled' => false,
-				],
-			],
-			'registrations' => [
-				'enabled' => (bool) config_cache('pixelfed.open_registration'),
-				'approval_required' => false,
-				'message' => null
-			],
-			'contact' => [
-				'email' => config('instance.email'),
-				'account' => $contact
-			],
-			'rules' => $rules
-		];
-
-    	return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
+        $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
+            if (config_cache('instance.admin.pid')) {
+                return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
+            }
+            $admin = User::whereIsAdmin(true)->first();
+
+            return $admin && isset($admin->profile_id) ?
+                AccountService::getMastodon($admin->profile_id, true) :
+                null;
+        });
+
+        $rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
+            return config_cache('app.rules') ?
+                collect(json_decode(config_cache('app.rules'), true))
+                    ->map(function ($rule, $key) {
+                        $id = $key + 1;
+
+                        return [
+                            'id' => "{$id}",
+                            'text' => $rule,
+                        ];
+                    })
+                    ->toArray() : [];
+        });
+
+        $res = Cache::remember('api:v2:instance-data-response-v2', 1800, function () use ($contact, $rules) {
+            return [
+                'domain' => config('pixelfed.domain.app'),
+                'title' => config_cache('app.name'),
+                'version' => '3.5.3 (compatible; Pixelfed '.config('pixelfed.version').')',
+                'source_url' => 'https://github.com/pixelfed/pixelfed',
+                'description' => config_cache('app.short_description'),
+                'usage' => [
+                    'users' => [
+                        'active_month' => (int) Nodeinfo::activeUsersMonthly(),
+                    ],
+                ],
+                'thumbnail' => [
+                    'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
+                    'blurhash' => InstanceService::headerBlurhash(),
+                    'versions' => [
+                        '@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
+                        '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
+                    ],
+                ],
+                'languages' => [config('app.locale')],
+                'configuration' => [
+                    'urls' => [
+                        'streaming' => null,
+                        'status' => null,
+                    ],
+                    'vapid' => [
+                        'public_key' => config('webpush.vapid.public_key'),
+                    ],
+                    'accounts' => [
+                        'max_featured_tags' => 0,
+                    ],
+                    'statuses' => [
+                        'max_characters' => (int) config_cache('pixelfed.max_caption_length'),
+                        'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
+                        'characters_reserved_per_url' => 23,
+                    ],
+                    'media_attachments' => [
+                        'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
+                        'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
+                        'image_matrix_limit' => 3686400,
+                        'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
+                        'video_frame_rate_limit' => 240,
+                        'video_matrix_limit' => 3686400,
+                    ],
+                    'polls' => [
+                        'max_options' => 0,
+                        'max_characters_per_option' => 0,
+                        'min_expiration' => 0,
+                        'max_expiration' => 0,
+                    ],
+                    'translation' => [
+                        'enabled' => false,
+                    ],
+                ],
+                'registrations' => [
+                    'enabled' => null,
+                    'approval_required' => false,
+                    'message' => null,
+                    'url' => null,
+                ],
+                'contact' => [
+                    'email' => config('instance.email'),
+                    'account' => $contact,
+                ],
+                'rules' => $rules,
+            ];
+        });
+
+        $res['registrations']['enabled'] = (bool) config_cache('pixelfed.open_registration');
+        $res['registrations']['approval_required'] = (bool) config_cache('instance.curated_registration.enabled');
+
+        return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
+    }
+
+    /**
+     * GET /api/v2/search
+     *
+     *
+     * @return array
+     */
+    public function search(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:1|max:100',
+            'account_id' => 'nullable|string',
+            'max_id' => 'nullable|string',
+            'min_id' => 'nullable|string',
+            'type' => 'nullable|in:accounts,hashtags,statuses',
+            'exclude_unreviewed' => 'nullable',
+            'resolve' => 'nullable',
+            'limit' => 'nullable|integer|max:40',
+            'offset' => 'nullable|integer',
+            'following' => 'nullable',
+        ]);
+
+        if ($request->user()->has_roles && ! UserRoleService::can('can-view-discover', $request->user()->id)) {
+            return [
+                'accounts' => [],
+                'hashtags' => [],
+                'statuses' => [],
+            ];
+        }
+
+        $mastodonMode = ! $request->has('_pe');
+
+        return $this->json(SearchApiV2Service::query($request, $mastodonMode));
+    }
+
+    /**
+     * GET /api/v2/streaming/config
+     *
+     *
+     * @return object
+     */
+    public function getWebsocketConfig()
+    {
+        return config('broadcasting.default') === 'pusher' ? [
+            'host' => config('broadcasting.connections.pusher.options.host'),
+            'port' => config('broadcasting.connections.pusher.options.port'),
+            'key' => config('broadcasting.connections.pusher.key'),
+            'cluster' => config('broadcasting.connections.pusher.options.cluster'),
+        ] : [];
     }
 
-	/**
-	 * GET /api/v2/search
-	 *
-	 *
-	 * @return array
-	 */
-	public function search(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:1|max:100',
-			'account_id' => 'nullable|string',
-			'max_id' => 'nullable|string',
-			'min_id' => 'nullable|string',
-			'type' => 'nullable|in:accounts,hashtags,statuses',
-			'exclude_unreviewed' => 'nullable',
-			'resolve' => 'nullable',
-			'limit' => 'nullable|integer|max:40',
-			'offset' => 'nullable|integer',
-			'following' => 'nullable'
-		]);
-
-		$mastodonMode = !$request->has('_pe');
-		return $this->json(SearchApiV2Service::query($request, $mastodonMode));
-	}
-
-	/**
-	 * GET /api/v2/streaming/config
-	 *
-	 *
-	 * @return object
-	 */
-	public function getWebsocketConfig()
-	{
-		return config('broadcasting.default') === 'pusher' ? [
-			'host' => config('broadcasting.connections.pusher.options.host'),
-			'port' => config('broadcasting.connections.pusher.options.port'),
-			'key' => config('broadcasting.connections.pusher.key'),
-			'cluster' => config('broadcasting.connections.pusher.options.cluster')
-		] : [];
-	}
-
-	/**
-	 * POST /api/v2/media
-	 *
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaUploadV2(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-		  	'file.*' => [
-				'required_without:file',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-			'file' => [
-				'required_without:file.*',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-		  'filter_name' => 'nullable|string|max:24',
-		  'filter_class' => 'nullable|alpha_dash|max:24',
-		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'),
-		  'replace_id' => 'sometimes'
-		]);
-
-		$user = $request->user();
-
-		if($user->last_active_at == null) {
-			return [];
-		}
-
-		if(empty($request->file('file'))) {
-			return response('', 422);
-		}
-
-		$limitKey = 'compose:rate-limit:media-upload:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
-
-			return $dailyLimit >= 1250;
-		});
-		abort_if($limitReached == true, 429);
-
-		$profile = $user->profile;
-
-		if(config_cache('pixelfed.enforce_account_limit') == true) {
-			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
-				return Media::whereUserId($user->id)->sum('size') / 1000;
-			});
-			$limit = (int) config_cache('pixelfed.max_account_size');
-			if ($size >= $limit) {
-			   abort(403, 'Account size limit reached.');
-			}
-		}
-
-		$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
-		$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
-
-		$photo = $request->file('file');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), $mimes) == false) {
-			abort(403, 'Invalid or unsupported mime type.');
-		}
-
-		$storagePath = MediaPathService::get($user, 2);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-		$license = null;
-		$mime = $photo->getMimeType();
-
-		$settings = UserSetting::whereUserId($user->id)->first();
-
-		if($settings && !empty($settings->compose_settings)) {
-			$compose = $settings->compose_settings;
-
-			if(isset($compose['default_license']) && $compose['default_license'] != 1) {
-				$license = $compose['default_license'];
-			}
-		}
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		if($request->has('replace_id')) {
-			$rpid = $request->input('replace_id');
-			$removeMedia = Media::whereNull('status_id')
-				->whereUserId($user->id)
-				->whereProfileId($profile->id)
-				->where('created_at', '>', now()->subHours(2))
-				->find($rpid);
-			if($removeMedia) {
-				MediaDeletePipeline::dispatch($removeMedia)
-					->onQueue('mmo')
-					->delay(now()->addMinutes(15));
-			}
-		}
-
-		$media = new Media();
-		$media->status_id = null;
-		$media->profile_id = $profile->id;
-		$media->user_id = $user->id;
-		$media->media_path = $path;
-		$media->original_sha256 = $hash;
-		$media->size = $photo->getSize();
-		$media->mime = $mime;
-		$media->caption = $request->input('description');
-		$media->filter_class = $filterClass;
-		$media->filter_name = $filterName;
-		if($license) {
-			$media->license = $license;
-		}
-		$media->save();
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-				ImageOptimize::dispatch($media)->onQueue('mmo');
-				break;
-
-			case 'video/mp4':
-				VideoThumbnail::dispatch($media)->onQueue('mmo');
-				$preview_url = '/storage/no-preview.png';
-				$url = '/storage/no-preview.png';
-				break;
-		}
-
-		Cache::forget($limitKey);
-		$fractal = new Fractal\Manager();
-		$fractal->setSerializer(new ArraySerializer());
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		$res = $fractal->createData($resource)->toArray();
-		$res['preview_url'] = $media->url(). '?v=' . time();
-		$res['url'] = null;
-		return $this->json($res, 202);
-	}
+    /**
+     * POST /api/v2/media
+     *
+     *
+     * @return MediaTransformer
+     */
+    public function mediaUploadV2(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $this->validate($request, [
+            'file.*' => [
+                'required_without:file',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'file' => [
+                'required_without:file.*',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'filter_name' => 'nullable|string|max:24',
+            'filter_class' => 'nullable|alpha_dash|max:24',
+            'description' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
+            'replace_id' => 'sometimes',
+        ]);
+
+        $user = $request->user();
+
+        if ($user->last_active_at == null) {
+            return [];
+        }
+
+        if (empty($request->file('file'))) {
+            return response('', 422);
+        }
+
+        $limitKey = 'compose:rate-limit:media-upload:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
+
+            return $dailyLimit >= 1250;
+        });
+        abort_if($limitReached == true, 429);
+
+        $profile = $user->profile;
+
+        $accountSize = UserStorageService::get($user->id);
+        abort_if($accountSize === -1, 403, 'Invalid request.');
+        $photo = $request->file('file');
+        $fileSize = $photo->getSize();
+        $sizeInKbs = (int) ceil($fileSize / 1000);
+        $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs;
+
+        if ((bool) config_cache('pixelfed.enforce_account_limit') == true) {
+            $limit = (int) config_cache('pixelfed.max_account_size');
+            if ($updatedAccountSize >= $limit) {
+                abort(403, 'Account size limit reached.');
+            }
+        }
+
+        $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
+        $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+        if (in_array($photo->getMimeType(), $mimes) == false) {
+            abort(403, 'Invalid or unsupported mime type.');
+        }
+
+        $storagePath = MediaPathService::get($user, 2);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+        $license = null;
+        $mime = $photo->getMimeType();
+
+        $settings = UserSetting::whereUserId($user->id)->first();
+
+        if ($settings && ! empty($settings->compose_settings)) {
+            $compose = $settings->compose_settings;
+
+            if (isset($compose['default_license']) && $compose['default_license'] != 1) {
+                $license = $compose['default_license'];
+            }
+        }
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        if ($request->has('replace_id')) {
+            $rpid = $request->input('replace_id');
+            $removeMedia = Media::whereNull('status_id')
+                ->whereUserId($user->id)
+                ->whereProfileId($profile->id)
+                ->where('created_at', '>', now()->subHours(2))
+                ->find($rpid);
+            if ($removeMedia) {
+                MediaDeletePipeline::dispatch($removeMedia)
+                    ->onQueue('mmo')
+                    ->delay(now()->addMinutes(15));
+            }
+        }
+
+        $media = new Media();
+        $media->status_id = null;
+        $media->profile_id = $profile->id;
+        $media->user_id = $user->id;
+        $media->media_path = $path;
+        $media->original_sha256 = $hash;
+        $media->size = $photo->getSize();
+        $media->mime = $mime;
+        $media->caption = $request->input('description');
+        $media->filter_class = $filterClass;
+        $media->filter_name = $filterName;
+        if ($license) {
+            $media->license = $license;
+        }
+        $media->save();
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+                ImageOptimize::dispatch($media)->onQueue('mmo');
+                break;
+
+            case 'video/mp4':
+                VideoThumbnail::dispatch($media)->onQueue('mmo');
+                $preview_url = '/storage/no-preview.png';
+                $url = '/storage/no-preview.png';
+                break;
+        }
+
+        $user->storage_used = (int) $updatedAccountSize;
+        $user->storage_used_updated_at = now();
+        $user->save();
+
+        Cache::forget($limitKey);
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer());
+        $res = $fractal->createData($resource)->toArray();
+        $res['preview_url'] = $media->url().'?v='.time();
+        $res['url'] = null;
+
+        return $this->json($res, 202);
+    }
 }

+ 5 - 2
app/Http/Controllers/Api/BaseApiController.php

@@ -99,6 +99,7 @@ class BaseApiController extends Controller
     public function avatarUpdate(Request $request)
     {
         abort_if(!$request->user(), 403);
+
         $this->validate($request, [
             'upload'   => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
         ]);
@@ -134,9 +135,10 @@ class BaseApiController extends Controller
 
     public function verifyCredentials(Request $request)
     {
+        abort_if(!$request->user(), 403);
+
         $user = $request->user();
-        abort_if(!$user, 403);
-        if($user->status != null) {
+        if ($user->status != null) {
             Auth::logout();
             abort(403);
         }
@@ -147,6 +149,7 @@ class BaseApiController extends Controller
     public function accountLikes(Request $request)
     {
         abort_if(!$request->user(), 403);
+
         $this->validate($request, [
         	'page' => 'sometimes|int|min:1|max:20',
         	'limit' => 'sometimes|int|min:1|max:10'

+ 4 - 6
app/Http/Controllers/Api/InstanceApiController.php

@@ -4,8 +4,9 @@ namespace App\Http\Controllers\Api;
 
 use Illuminate\Http\Request;
 use App\Http\Controllers\Controller;
-use App\{Profile, Status, User};
+use App\{Profile, Instance, Status, User};
 use Cache;
+use App\Services\StatusService;
 
 class InstanceApiController extends Controller {
 
@@ -40,11 +41,8 @@ class InstanceApiController extends Controller {
 			'urls' => [],
 			'stats' => [
 				'user_count' => User::count(),
-				'status_count' => Status::whereNull('uri')->count(),
-				'domain_count' => Profile::whereNotNull('domain')
-					->groupBy('domain')
-					->pluck('domain')
-					->count()
+				'status_count' => StatusService::totalLocalStatuses(),
+				'domain_count' => Instance::count()
 			],
 			'thumbnail' => '',
 			'languages' => [],

+ 147 - 0
app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace App\Http\Controllers\Api\V1\Admin;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use App\Http\Controllers\Api\ApiController;
+use App\Instance;
+use App\Services\InstanceService;
+use App\Http\Resources\MastoApi\Admin\DomainBlockResource;
+
+class DomainBlocksController extends ApiController {
+
+  public function __construct() {
+    $this->middleware(['auth:api', 'api.admin', 'scope:admin:read,admin:read:domain_blocks'])->only(['index', 'show']);
+    $this->middleware(['auth:api', 'api.admin', 'scope:admin:write,admin:write:domain_blocks'])->only(['create', 'update', 'delete']);
+  }
+
+  public function index(Request $request) {
+    $this->validate($request, [
+      'limit' => 'sometimes|integer|max:100|min:1',
+    ]);
+
+    $limit = $request->input('limit', 100);
+
+    $res = Instance::moderated()
+      ->orderBy('id')
+      ->cursorPaginate($limit)
+      ->withQueryString();
+
+    return $this->json(DomainBlockResource::collection($res), [
+      'Link' => $this->linksForCollection($res)
+    ]);
+  }
+
+  public function show(Request $request, $id) {
+    $domain_block = Instance::moderated()->find($id);
+
+    if (!$domain_block) {
+      return $this->json([ 'error' => 'Record not found'], [], 404);
+    }
+
+    return $this->json(new DomainBlockResource($domain_block));
+  }
+
+  public function create(Request $request) {
+    $this->validate($request, [
+      'domain' => 'required|string|min:1|max:120',
+      'severity' => [
+        'sometimes',
+        Rule::in(['noop', 'silence', 'suspend'])
+      ],
+      'reject_media' => 'sometimes|required|boolean',
+      'reject_reports' => 'sometimes|required|boolean',
+      'private_comment' => 'sometimes|string|min:1|max:1000',
+      'public_comment' => 'sometimes|string|min:1|max:1000',
+      'obfuscate' => 'sometimes|required|boolean'
+    ]);
+
+    $domain = $request->input('domain');
+    $severity = $request->input('severity', 'silence');
+    $private_comment = $request->input('private_comment');
+
+    abort_if(!strpos($domain, '.'), 400, 'Invalid domain');
+    abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain');
+
+    // This is because Pixelfed can't currently support wildcard domain blocks
+    // We have to find something that could plausibly be an instance
+    $parts = explode('.', $domain);
+    if ($parts[0] == '*') {
+      // If we only have two parts, e.g., "*", "example", then we want to fail:
+      abort_if(count($parts) <= 2, 400, 'Invalid domain: This API does not support wildcard domain blocks yet');
+
+      // Otherwise we convert the *.foo.example to foo.example
+      $domain = implode('.', array_slice($parts, 1));
+    }
+
+    // Double check we definitely haven't let anything through:
+    abort_if(str_contains($domain, '*'), 400, 'Invalid domain');
+
+    $existing_domain_block = Instance::moderated()->whereDomain($domain)->first();
+
+    if ($existing_domain_block) {
+      return $this->json([
+        'error' => 'A domain block already exists for this domain',
+        'existing_domain_block' => new DomainBlockResource($existing_domain_block)
+      ], [], 422);
+    }
+
+    $domain_block = Instance::updateOrCreate(
+      [ 'domain' => $domain ],
+      [ 'banned' => $severity === 'suspend', 'unlisted' => $severity === 'silence', 'notes' => [$private_comment]]
+    );
+
+    InstanceService::refresh();
+
+    return $this->json(new DomainBlockResource($domain_block));
+  }
+
+  public function update(Request $request, $id) {
+    $this->validate($request, [
+      'severity' => [
+        'sometimes',
+        Rule::in(['noop', 'silence', 'suspend'])
+      ],
+      'reject_media' => 'sometimes|required|boolean',
+      'reject_reports' => 'sometimes|required|boolean',
+      'private_comment' => 'sometimes|string|min:1|max:1000',
+      'public_comment' => 'sometimes|string|min:1|max:1000',
+      'obfuscate' => 'sometimes|required|boolean'
+    ]);
+
+    $severity = $request->input('severity', 'silence');
+    $private_comment = $request->input('private_comment');
+
+    $domain_block = Instance::moderated()->find($id);
+
+    if (!$domain_block) {
+      return $this->json([ 'error' => 'Record not found'], [], 404);
+    }
+
+    $domain_block->banned = $severity === 'suspend';
+    $domain_block->unlisted = $severity === 'silence';
+    $domain_block->notes = [$private_comment];
+    $domain_block->save();
+
+    InstanceService::refresh();
+
+    return $this->json(new DomainBlockResource($domain_block));
+  }
+
+  public function delete(Request $request, $id) {
+    $domain_block = Instance::moderated()->find($id);
+
+    if (!$domain_block) {
+      return $this->json([ 'error' => 'Record not found'], [], 404);
+    }
+
+    $domain_block->banned = false;
+    $domain_block->unlisted = false;
+    $domain_block->save();
+
+    InstanceService::refresh();
+
+    return $this->json(null, [], 200);
+  }
+}

+ 119 - 0
app/Http/Controllers/Api/V1/DomainBlockController.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Http\Controllers\Api\V1;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\UserDomainBlock;
+use App\Util\ActivityPub\Helpers;
+use App\Services\UserFilterService;
+use Illuminate\Bus\Batch;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Cache;
+use App\Jobs\HomeFeedPipeline\FeedRemoveDomainPipeline;
+use App\Jobs\ProfilePipeline\ProfilePurgeNotificationsByDomain;
+use App\Jobs\ProfilePipeline\ProfilePurgeFollowersByDomain;
+
+class DomainBlockController extends Controller
+{
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
+    public function index(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'limit' => 'sometimes|integer|min:1|max:200'
+        ]);
+        $limit = $request->input('limit', 100);
+        $id = $request->user()->profile_id;
+        $filters = UserDomainBlock::whereProfileId($id)->orderByDesc('id')->cursorPaginate($limit);
+        $links = null;
+        $headers = [];
+
+        if($filters->nextCursor()) {
+            $links .= '<'.$filters->nextPageUrl().'&limit='.$limit.'>; rel="next"';
+        }
+
+        if($filters->previousCursor()) {
+            if($links != null) {
+                $links .= ', ';
+            }
+            $links .= '<'.$filters->previousPageUrl().'&limit='.$limit.'>; rel="prev"';
+        }
+
+        if($links) {
+            $headers = ['Link' => $links];
+        }
+        return $this->json($filters->pluck('domain'), 200, $headers);
+    }
+
+    public function store(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'domain' => 'required|active_url|min:1|max:120'
+        ]);
+
+        $pid = $request->user()->profile_id;
+
+        $domain = trim($request->input('domain'));
+
+        if(Helpers::validateUrl($domain) == false) {
+            return abort(500, 'Invalid domain or already blocked by server admins');
+        }
+
+        $domain = strtolower(parse_url($domain, PHP_URL_HOST));
+
+        abort_if(config_cache('pixelfed.domain.app') == $domain, 400, 'Cannot ban your own server');
+
+        $existingCount = UserDomainBlock::whereProfileId($pid)->count();
+        $maxLimit = (int) config_cache('instance.user_filters.max_domain_blocks');
+        $errorMsg =  __('profile.block.domain.max', ['max' => $maxLimit]);
+
+        abort_if($existingCount >= $maxLimit, 400, $errorMsg);
+
+        $block = UserDomainBlock::updateOrCreate([
+            'profile_id' => $pid,
+            'domain' => $domain
+        ]);
+
+        if($block->wasRecentlyCreated) {
+            Bus::batch([
+                [
+                    new FeedRemoveDomainPipeline($pid, $domain),
+                    new ProfilePurgeNotificationsByDomain($pid, $domain),
+                    new ProfilePurgeFollowersByDomain($pid, $domain)
+                ]
+            ])->allowFailures()->onQueue('feed')->dispatch();
+
+            Cache::forget('profile:following:' . $pid);
+            UserFilterService::domainBlocks($pid, true);
+        }
+
+        return $this->json([]);
+    }
+
+    public function delete(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'domain' => 'required|min:1|max:120'
+        ]);
+
+        $pid = $request->user()->profile_id;
+
+        $domain = strtolower(trim($request->input('domain')));
+
+        $filters = UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->delete();
+
+        UserFilterService::domainBlocks($pid, true);
+
+        return $this->json([]);
+    }
+}

+ 209 - 0
app/Http/Controllers/Api/V1/TagsController.php

@@ -0,0 +1,209 @@
+<?php
+
+namespace App\Http\Controllers\Api\V1;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Hashtag;
+use App\HashtagFollow;
+use App\StatusHashtag;
+use App\Services\AccountService;
+use App\Services\HashtagService;
+use App\Services\HashtagFollowService;
+use App\Services\HashtagRelatedService;
+use App\Http\Resources\MastoApi\FollowedTagResource;
+use App\Jobs\HomeFeedPipeline\FeedWarmCachePipeline;
+use App\Jobs\HomeFeedPipeline\HashtagUnfollowPipeline;
+
+class TagsController extends Controller
+{
+    const PF_API_ENTITY_KEY = "_pe";
+
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
+    /**
+    * GET /api/v1/tags/:id/related
+    *
+    *
+    * @return array
+    */
+    public function relatedTags(Request $request, $tag)
+    {
+        abort_if(!$request->user() || !$request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $tag = Hashtag::whereSlug($tag)->firstOrFail();
+        return HashtagRelatedService::get($tag->id);
+    }
+
+    /**
+    * POST /api/v1/tags/:id/follow
+    *
+    *
+    * @return object
+    */
+    public function followHashtag(Request $request, $id)
+    {
+        abort_if(!$request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $account = AccountService::get($pid);
+
+        $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
+        $tag = Hashtag::where('name', $operator, $id)
+            ->orWhere('slug', $operator, $id)
+            ->first();
+
+        abort_if(!$tag, 422, 'Unknown hashtag');
+
+        abort_if(
+            HashtagFollow::whereProfileId($pid)->count() >= HashtagFollow::MAX_LIMIT,
+            422,
+            'You cannot follow more than ' . HashtagFollow::MAX_LIMIT . ' hashtags.'
+        );
+
+        $follows = HashtagFollow::updateOrCreate(
+            [
+                'profile_id' => $account['id'],
+                'hashtag_id' => $tag->id
+            ],
+            [
+                'user_id' => $request->user()->id
+            ]
+        );
+
+        HashtagService::follow($pid, $tag->id);
+        HashtagFollowService::add($tag->id, $pid);
+
+        return response()->json(FollowedTagResource::make($follows)->toArray($request));
+    }
+
+    /**
+    * POST /api/v1/tags/:id/unfollow
+    *
+    *
+    * @return object
+    */
+    public function unfollowHashtag(Request $request, $id)
+    {
+        abort_if(!$request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $account = AccountService::get($pid);
+
+        $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
+        $tag = Hashtag::where('name', $operator, $id)
+            ->orWhere('slug', $operator, $id)
+            ->first();
+
+        abort_if(!$tag, 422, 'Unknown hashtag');
+
+        $follows = HashtagFollow::whereProfileId($pid)
+            ->whereHashtagId($tag->id)
+            ->first();
+
+        if(!$follows) {
+            return [
+                'name' => $tag->name,
+                'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
+                'history' => [],
+                'following' => false
+            ];
+        }
+
+        if($follows) {
+            HashtagService::unfollow($pid, $tag->id);
+            HashtagFollowService::unfollow($tag->id, $pid);
+            HashtagUnfollowPipeline::dispatch($tag->id, $pid, $tag->slug)->onQueue('feed');
+            $follows->delete();
+        }
+
+        $res = FollowedTagResource::make($follows)->toArray($request);
+        $res['following'] = false;
+        return response()->json($res);
+    }
+
+    /**
+    * GET /api/v1/tags/:id
+    *
+    *
+    * @return object
+    */
+    public function getHashtag(Request $request, $id)
+    {
+        abort_if(!$request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $account = AccountService::get($pid);
+        $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
+        $tag = Hashtag::where('name', $operator, $id)
+            ->orWhere('slug', $operator, $id)
+            ->first();
+
+        if(!$tag) {
+            return [
+                'name' => $id,
+                'url' => config('app.url') . '/i/web/hashtag/' . $id,
+                'history' => [],
+                'following' => false
+            ];
+        }
+
+        $res = [
+            'name' => $tag->name,
+            'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
+            'history' => [],
+            'following' => HashtagService::isFollowing($pid, $tag->id)
+        ];
+
+        if($request->has(self::PF_API_ENTITY_KEY)) {
+            $res['count'] = HashtagService::count($tag->id);
+        }
+
+        return $this->json($res);
+    }
+
+    /**
+    * GET /api/v1/followed_tags
+    *
+    *
+    * @return array
+    */
+    public function getFollowedTags(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $account = AccountService::get($request->user()->profile_id);
+
+        $this->validate($request, [
+            'cursor' => 'sometimes',
+            'limit' => 'sometimes|integer|min:1|max:200'
+        ]);
+        $limit = $request->input('limit', 100);
+
+        $res = HashtagFollow::whereProfileId($account['id'])
+            ->orderByDesc('id')
+            ->cursorPaginate($limit)
+            ->withQueryString();
+
+        $pagination = false;
+        $prevPage = $res->nextPageUrl();
+        $nextPage = $res->previousPageUrl();
+        if($nextPage && $prevPage) {
+            $pagination = '<' . $nextPage . '>; rel="next", <' . $prevPage . '>; rel="prev"';
+        } else if($nextPage && !$prevPage) {
+            $pagination = '<' . $nextPage . '>; rel="next"';
+        } else if(!$nextPage && $prevPage) {
+            $pagination = '<' . $prevPage . '>; rel="prev"';
+        }
+
+        if($pagination) {
+            return response()->json(FollowedTagResource::collection($res)->collection)
+                ->header('Link', $pagination);
+        }
+        return response()->json(FollowedTagResource::collection($res)->collection);
+    }
+}

+ 1 - 1
app/Http/Controllers/Auth/ForgotPasswordController.php

@@ -62,7 +62,7 @@ class ForgotPasswordController extends Controller
 
 		usleep(random_int(100000, 3000000));
 
-    	if(config('captcha.enabled')) {
+    	if((bool) config_cache('captcha.enabled')) {
             $rules = [
 	    		'email' => 'required|email',
             	'h-captcha-response' => 'required|captcha'

+ 6 - 5
app/Http/Controllers/Auth/LoginController.php

@@ -71,20 +71,21 @@ class LoginController extends Controller
             $this->username() => 'required|email',
             'password'        => 'required|string|min:6',
         ];
+        $messages = [];
 
         if(
-        	config('captcha.enabled') ||
-        	config('captcha.active.login') ||
+        	(bool) config_cache('captcha.enabled') &&
+        	(bool) config_cache('captcha.active.login') ||
         	(
-				config('captcha.triggers.login.enabled') &&
+				(bool) config_cache('captcha.triggers.login.enabled') &&
 				request()->session()->has('login_attempts') &&
 				request()->session()->get('login_attempts') >= config('captcha.triggers.login.attempts')
 			)
         ) {
             $rules['h-captcha-response'] = 'required|filled|captcha|min:5';
+            $messages['h-captcha-response.required'] = 'The captcha must be filled';
         }
-        
-        $this->validate($request, $rules);
+        $request->validate($rules, $messages);
     }
 
     /**

+ 227 - 218
app/Http/Controllers/Auth/RegisterController.php

@@ -3,230 +3,239 @@
 namespace App\Http\Controllers\Auth;
 
 use App\Http\Controllers\Controller;
+use App\Services\BouncerService;
+use App\Services\EmailService;
 use App\User;
-use Purify;
 use App\Util\Lexer\RestrictedNames;
+use Illuminate\Auth\Events\Registered;
 use Illuminate\Foundation\Auth\RegistersUsers;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Validator;
-use Illuminate\Auth\Events\Registered;
-use Illuminate\Http\Request;
-use App\Services\EmailService;
-use App\Services\BouncerService;
+use Purify;
 
 class RegisterController extends Controller
 {
-	/*
-	|--------------------------------------------------------------------------
-	| Register Controller
-	|--------------------------------------------------------------------------
-	|
-	| This controller handles the registration of new users as well as their
-	| validation and creation. By default this controller uses a trait to
-	| provide this functionality without requiring any additional code.
-	|
-	*/
-
-	use RegistersUsers;
-
-	/**
-	 * Where to redirect users after registration.
-	 *
-	 * @var string
-	 */
-	protected $redirectTo = '/i/web';
-
-	/**
-	 * Create a new controller instance.
-	 *
-	 * @return void
-	 */
-	public function __construct()
-	{
-		$this->middleware('guest');
-	}
-
-	public function getRegisterToken()
-	{
-		return \Cache::remember('pf:register:rt', 900, function() {
-			return str_random(40);
-		});
-	}
-
-	/**
-	 * Get a validator for an incoming registration request.
-	 *
-	 * @param array $data
-	 *
-	 * @return \Illuminate\Contracts\Validation\Validator
-	 */
-	protected function validator(array $data)
-	{
-		if(config('database.default') == 'pgsql') {
-			$data['username'] = strtolower($data['username']);
-			$data['email'] = strtolower($data['email']);
-		}
-
-		$usernameRules = [
-			'required',
-			'min:2',
-			'max:15',
-			'unique:users',
-			function ($attribute, $value, $fail) {
-				$dash = substr_count($value, '-');
-				$underscore = substr_count($value, '_');
-				$period = substr_count($value, '.');
-
-				if(ends_with($value, ['.php', '.js', '.css'])) {
-					return $fail('Username is invalid.');
-				}
-
-				if(($dash + $underscore + $period) > 1) {
-					return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
-				}
-
-				if (!ctype_alnum($value[0])) {
-					return $fail('Username is invalid. Must start with a letter or number.');
-				}
-
-				if (!ctype_alnum($value[strlen($value) - 1])) {
-					return $fail('Username is invalid. Must end with a letter or number.');
-				}
-
-				$val = str_replace(['_', '.', '-'], '', $value);
-				if(!ctype_alnum($val)) {
-					return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
-				}
-
-				$restricted = RestrictedNames::get();
-				if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
-					return $fail('Username cannot be used.');
-				}
-			},
-		];
-
-		$emailRules = [
-			'required',
-			'string',
-			'email',
-			'max:255',
-			'unique:users',
-			function ($attribute, $value, $fail) {
-				$banned = EmailService::isBanned($value);
-				if($banned) {
-					return $fail('Email is invalid.');
-				}
-			},
-		];
-
-		$rt = [
-			'required',
-			function ($attribute, $value, $fail) {
-				if($value !== $this->getRegisterToken()) {
-					return $fail('Something went wrong');
-				}
-			}
-		];
-
-		$rules = [
-			'agecheck' => 'required|accepted',
-			'rt' 	   => $rt,
-			'name'     => 'nullable|string|max:'.config('pixelfed.max_name_length'),
-			'username' => $usernameRules,
-			'email'    => $emailRules,
-			'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed',
-		];
-
-		if(config('captcha.enabled') || config('captcha.active.register')) {
-			$rules['h-captcha-response'] = 'required|captcha';
-		}
-
-		return Validator::make($data, $rules);
-	}
-
-	/**
-	 * Create a new user instance after a valid registration.
-	 *
-	 * @param array $data
-	 *
-	 * @return \App\User
-	 */
-	protected function create(array $data)
-	{
-		if(config('database.default') == 'pgsql') {
-			$data['username'] = strtolower($data['username']);
-			$data['email'] = strtolower($data['email']);
-		}
-
-		return User::create([
-			'name'     => Purify::clean($data['name']),
-			'username' => $data['username'],
-			'email'    => $data['email'],
-			'password' => Hash::make($data['password']),
-			'app_register_ip' => request()->ip()
-		]);
-	}
-
-	/**
-	 * Show the application registration form.
-	 *
-	 * @return \Illuminate\Http\Response
-	 */
-	public function showRegistrationForm()
-	{
-		if(config_cache('pixelfed.open_registration')) {
-			if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
-				abort_if(BouncerService::checkIp(request()->ip()), 404);
-			}
-			$hasLimit = config('pixelfed.enforce_max_users');
-			if($hasLimit) {
-				$limit = config('pixelfed.max_users');
-				$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
-				if($limit <= $count) {
-					return redirect(route('help.instance-max-users-limit'));
-				}
-				abort_if($limit <= $count, 404);
-				return view('auth.register');
-			} else {
-				return view('auth.register');
-			}
-		} else {
-			abort(404);
-		}
-	}
-
-	/**
-	 * Handle a registration request for the application.
-	 *
-	 * @param  \Illuminate\Http\Request  $request
-	 * @return \Illuminate\Http\Response
-	 */
-	public function register(Request $request)
-	{
-		abort_if(config_cache('pixelfed.open_registration') == false, 400);
-
-		if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
-			abort_if(BouncerService::checkIp($request->ip()), 404);
-		}
-
-		$hasLimit = config('pixelfed.enforce_max_users');
-		if($hasLimit) {
-			$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
-			$limit = config('pixelfed.max_users');
-
-    		if($limit && $limit <= $count) {
-    			return redirect(route('help.instance-max-users-limit'));
-    		}
-		}
-
-
-		$this->validator($request->all())->validate();
-
-		event(new Registered($user = $this->create($request->all())));
-
-		$this->guard()->login($user);
-
-		return $this->registered($request, $user)
-			?: redirect($this->redirectPath());
-	}
+    /*
+    |--------------------------------------------------------------------------
+    | Register Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller handles the registration of new users as well as their
+    | validation and creation. By default this controller uses a trait to
+    | provide this functionality without requiring any additional code.
+    |
+    */
+
+    use RegistersUsers;
+
+    /**
+     * Where to redirect users after registration.
+     *
+     * @var string
+     */
+    protected $redirectTo = '/i/web';
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('guest');
+    }
+
+    public function getRegisterToken()
+    {
+        return \Cache::remember('pf:register:rt', 900, function () {
+            return str_random(40);
+        });
+    }
+
+    /**
+     * Get a validator for an incoming registration request.
+     *
+     *
+     * @return \Illuminate\Contracts\Validation\Validator
+     */
+    public function validator(array $data)
+    {
+        if (config('database.default') == 'pgsql') {
+            $data['username'] = strtolower($data['username']);
+            $data['email'] = strtolower($data['email']);
+        }
+
+        $usernameRules = [
+            'required',
+            'min:2',
+            'max:15',
+            'unique:users',
+            function ($attribute, $value, $fail) {
+                $dash = substr_count($value, '-');
+                $underscore = substr_count($value, '_');
+                $period = substr_count($value, '.');
+
+                if (ends_with($value, ['.php', '.js', '.css'])) {
+                    return $fail('Username is invalid.');
+                }
+
+                if (($dash + $underscore + $period) > 1) {
+                    return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+                }
+
+                if (! ctype_alnum($value[0])) {
+                    return $fail('Username is invalid. Must start with a letter or number.');
+                }
+
+                if (! ctype_alnum($value[strlen($value) - 1])) {
+                    return $fail('Username is invalid. Must end with a letter or number.');
+                }
+
+                $val = str_replace(['_', '.', '-'], '', $value);
+                if (! ctype_alnum($val)) {
+                    return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+                }
+
+                if (! preg_match('/[a-zA-Z]/', $value)) {
+                    return $fail('Username is invalid. Must contain at least one alphabetical character.');
+                }
+
+                $restricted = RestrictedNames::get();
+                if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+                    return $fail('Username cannot be used.');
+                }
+            },
+        ];
+
+        $emailRules = [
+            'required',
+            'string',
+            'email',
+            'max:255',
+            'unique:users',
+            function ($attribute, $value, $fail) {
+                $banned = EmailService::isBanned($value);
+                if ($banned) {
+                    return $fail('Email is invalid.');
+                }
+            },
+        ];
+
+        $rt = [
+            'required',
+            function ($attribute, $value, $fail) {
+                if ($value !== $this->getRegisterToken()) {
+                    return $fail('Something went wrong');
+                }
+            },
+        ];
+
+        $rules = [
+            'agecheck' => 'required|accepted',
+            'rt' => $rt,
+            'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
+            'username' => $usernameRules,
+            'email' => $emailRules,
+            'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed',
+        ];
+
+        if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+        }
+
+        return Validator::make($data, $rules);
+    }
+
+    /**
+     * Create a new user instance after a valid registration.
+     *
+     *
+     * @return \App\User
+     */
+    public function create(array $data)
+    {
+        if (config('database.default') == 'pgsql') {
+            $data['username'] = strtolower($data['username']);
+            $data['email'] = strtolower($data['email']);
+        }
+
+        return User::create([
+            'name' => Purify::clean($data['name']),
+            'username' => $data['username'],
+            'email' => $data['email'],
+            'password' => Hash::make($data['password']),
+            'app_register_ip' => request()->ip(),
+        ]);
+    }
+
+    /**
+     * Show the application registration form.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showRegistrationForm()
+    {
+        if ((bool) config_cache('pixelfed.open_registration')) {
+            if (config('pixelfed.bouncer.cloud_ips.ban_signups')) {
+                abort_if(BouncerService::checkIp(request()->ip()), 404);
+            }
+            $hasLimit = config('pixelfed.enforce_max_users');
+            if ($hasLimit) {
+                $limit = config('pixelfed.max_users');
+                $count = User::where(function ($q) {
+                    return $q->whereNull('status')->orWhereNotIn('status', ['deleted', 'delete']);
+                })->count();
+                if ($limit <= $count) {
+                    return redirect(route('help.instance-max-users-limit'));
+                }
+                abort_if($limit <= $count, 404);
+
+                return view('auth.register');
+            } else {
+                return view('auth.register');
+            }
+        } else {
+            if ((bool) config_cache('instance.curated_registration.enabled') && config('instance.curated_registration.state.fallback_on_closed_reg')) {
+                return redirect('/auth/sign_up');
+            } else {
+                abort(404);
+            }
+        }
+    }
+
+    /**
+     * Handle a registration request for the application.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function register(Request $request)
+    {
+        abort_if(config_cache('pixelfed.open_registration') == false, 400);
+
+        if (config('pixelfed.bouncer.cloud_ips.ban_signups')) {
+            abort_if(BouncerService::checkIp($request->ip()), 404);
+        }
+
+        $hasLimit = config('pixelfed.enforce_max_users');
+        if ($hasLimit) {
+            $count = User::where(function ($q) {
+                return $q->whereNull('status')->orWhereNotIn('status', ['deleted', 'delete']);
+            })->count();
+            $limit = config('pixelfed.max_users');
+
+            if ($limit && $limit <= $count) {
+                return redirect(route('help.instance-max-users-limit'));
+            }
+        }
+
+        $this->validator($request->all())->validate();
+
+        event(new Registered($user = $this->create($request->all())));
+
+        $this->guard()->login($user);
+
+        return $this->registered($request, $user)
+            ?: redirect($this->redirectPath());
+    }
 }

+ 1 - 1
app/Http/Controllers/Auth/ResetPasswordController.php

@@ -50,7 +50,7 @@ class ResetPasswordController extends Controller
     {
     	usleep(random_int(100000, 3000000));
 
-        if(config('captcha.enabled')) {
+        if((bool) config_cache('captcha.enabled')) {
             return [
 	            'token' => 'required',
 	            'email' => 'required|email',

+ 37 - 0
app/Http/Controllers/AuthorizeInteractionController.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Util\ActivityPub\Helpers;
+use Illuminate\Http\Request;
+
+class AuthorizeInteractionController extends Controller
+{
+    public function get(Request $request)
+    {
+        $request->validate([
+            'uri' => 'required|url',
+        ]);
+
+        abort_unless((bool) config_cache('federation.activitypub.enabled'), 404);
+
+        $uri = Helpers::validateUrl($request->input('uri'), true);
+        abort_unless($uri, 404);
+
+        if (! $request->user()) {
+            return redirect('/login?next='.urlencode($uri));
+        }
+
+        $status = Helpers::statusFetch($uri);
+        if ($status && isset($status['id'])) {
+            return redirect('/i/web/post/'.$status['id']);
+        }
+
+        $profile = Helpers::profileFetch($uri);
+        if ($profile && isset($profile['id'])) {
+            return redirect('/i/web/profile/'.$profile['id']);
+        }
+
+        return redirect('/i/web');
+    }
+}

+ 46 - 49
app/Http/Controllers/BookmarkController.php

@@ -3,65 +3,62 @@
 namespace App\Http\Controllers;
 
 use App\Bookmark;
-use App\Status;
-use Auth;
-use Illuminate\Http\Request;
+use App\Services\AccountService;
 use App\Services\BookmarkService;
 use App\Services\FollowerService;
+use App\Services\UserRoleService;
+use App\Status;
+use Illuminate\Http\Request;
 
 class BookmarkController extends Controller
 {
-	public function __construct()
-	{
-		$this->middleware('auth');
-	}
-
-	public function store(Request $request)
-	{
-		$this->validate($request, [
-			'item' => 'required|integer|min:1',
-		]);
-
-		$profile = Auth::user()->profile;
-		$status = Status::findOrFail($request->input('item'));
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
 
-		abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
-		abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
-		abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404);
+    public function store(Request $request)
+    {
+        $this->validate($request, [
+            'item' => 'required|integer|min:1',
+        ]);
 
-		if($status->scope == 'private') {
-			if($profile->id !== $status->profile_id && !FollowerService::follows($profile->id, $status->profile_id)) {
-				if($exists = Bookmark::whereStatusId($status->id)->whereProfileId($profile->id)->first()) {
-					BookmarkService::del($profile->id, $status->id);
-					$exists->delete();
+        $user = $request->user();
+        $status = Status::findOrFail($request->input('item'));
+        $account = AccountService::get($status->profile_id);
+        abort_if(isset($account['moved'], $account['moved']['id']), 422, 'Cannot bookmark or unbookmark a post from an account that has migrated');
+        abort_if($user->has_roles && ! UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
+        abort_if(! in_array($status->scope, ['public', 'unlisted', 'private']), 404);
+        abort_if(! in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']), 404);
 
-					if ($request->ajax()) {
-						return ['code' => 200, 'msg' => 'Bookmark removed!'];
-					} else {
-						return redirect()->back();
-					}
-				}
-				abort(404, 'Error: You cannot bookmark private posts from accounts you do not follow.');
-			}
-		}
+        if ($status->scope == 'private') {
+            if ($user->profile_id !== $status->profile_id && ! FollowerService::follows($user->profile_id, $status->profile_id)) {
+                if ($exists = Bookmark::whereStatusId($status->id)->whereProfileId($user->profile_id)->first()) {
+                    BookmarkService::del($user->profile_id, $status->id);
+                    $exists->delete();
 
-		$bookmark = Bookmark::firstOrCreate(
-			['status_id' => $status->id], ['profile_id' => $profile->id]
-		);
+                    if ($request->ajax()) {
+                        return ['code' => 200, 'msg' => 'Bookmark removed!'];
+                    } else {
+                        return redirect()->back();
+                    }
+                }
+                abort(404, 'Error: You cannot bookmark private posts from accounts you do not follow.');
+            }
+        }
 
-		if (!$bookmark->wasRecentlyCreated) {
-			BookmarkService::del($profile->id, $status->id);
-			$bookmark->delete();
-		} else {
-			BookmarkService::add($profile->id, $status->id);
-		}
+        $bookmark = Bookmark::firstOrCreate(
+            ['status_id' => $status->id], ['profile_id' => $user->profile_id]
+        );
 
-		if ($request->ajax()) {
-			$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
-		} else {
-			$response = redirect()->back();
-		}
+        if (! $bookmark->wasRecentlyCreated) {
+            BookmarkService::del($user->profile_id, $status->id);
+            $bookmark->delete();
+        } else {
+            BookmarkService::add($user->profile_id, $status->id);
+        }
 
-		return $response;
-	}
+        return $request->expectsJson() ? ['code' => 200, 'msg' => 'Bookmark saved!'] : redirect()->back();
+    }
 }

+ 138 - 120
app/Http/Controllers/CollectionController.php

@@ -2,72 +2,65 @@
 
 namespace App\Http\Controllers;
 
-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;
+use App\Collection;
+use App\CollectionItem;
 use App\Services\AccountService;
 use App\Services\CollectionService;
 use App\Services\FollowerService;
 use App\Services\StatusService;
+use App\Status;
+use Auth;
+use Illuminate\Http\Request;
 
 class CollectionController extends Controller
 {
     public function create(Request $request)
     {
-        abort_if(!Auth::check(), 403);
+        abort_if(! Auth::check(), 403);
         $profile = Auth::user()->profile;
 
         $collection = Collection::firstOrCreate([
             'profile_id' => $profile->id,
-            'published_at' => null
+            'published_at' => null,
         ]);
         $collection->visibility = 'draft';
         $collection->save();
+
         return view('collection.create', compact('collection'));
     }
 
     public function show(Request $request, int $id)
     {
         $user = $request->user();
-		$collection = CollectionService::getCollection($id);
-		abort_if(!$collection, 404);
-		if($collection['published_at'] == null || $collection['visibility'] != 'public') {
-			abort_if(!$user, 404);
-			if($user->profile_id != $collection['pid']) {
-				if(!$user->is_admin) {
-					abort_if($collection['visibility'] != 'private', 404);
-					abort_if(!FollowerService::follows($user->profile_id, $collection['pid']), 404);
-				}
-			}
-		}
-    	return view('collection.show', compact('collection'));
+        $collection = CollectionService::getCollection($id);
+        abort_if(! $collection, 404);
+        if ($collection['published_at'] == null || $collection['visibility'] != 'public') {
+            abort_if(! $user, 404);
+            if ($user->profile_id != $collection['pid']) {
+                if (! $user->is_admin) {
+                    abort_if($collection['visibility'] != 'private', 404);
+                    abort_if(! FollowerService::follows($user->profile_id, $collection['pid']), 404);
+                }
+            }
+        }
+
+        return view('collection.show', compact('collection'));
     }
 
     public function index(Request $request)
     {
-        abort_if(!Auth::check(), 403);
-    	return $request->all();
+        abort_if(! Auth::check(), 403);
+
+        return $request->all();
     }
 
     public function store(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
         $this->validate($request, [
-            'title'         => 'nullable|max:50',
-            'description'   => 'nullable|max:500',
-            'visibility'    => 'nullable|string|in:public,private,draft'
+            'title' => 'nullable|max:50',
+            'description' => 'nullable|max:500',
+            'visibility' => 'nullable|string|in:public,private,draft',
         ]);
 
         $pid = $request->user()->profile_id;
@@ -78,20 +71,21 @@ class CollectionController extends Controller
         $collection->save();
 
         CollectionService::deleteCollection($id);
+
         return CollectionService::setCollection($collection->id, $collection);
     }
 
     public function publish(Request $request, int $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
         $this->validate($request, [
-            'title'         => 'nullable|max:50',
-            'description'   => 'nullable|max:500',
-            'visibility'    => 'required|alpha|in:public,private,draft'
+            'title' => 'nullable|max:50',
+            'description' => 'nullable|max:500',
+            'visibility' => 'required|alpha|in:public,private,draft',
         ]);
-        $profile = Auth::user()->profile;   
+        $profile = Auth::user()->profile;
         $collection = Collection::whereProfileId($profile->id)->findOrFail($id);
-        if($collection->items()->count() == 0) {
+        if ($collection->items()->count() == 0) {
             abort(404);
         }
         $collection->title = strip_tags($request->input('title'));
@@ -99,12 +93,13 @@ class CollectionController extends Controller
         $collection->visibility = $request->input('visibility');
         $collection->published_at = now();
         $collection->save();
+
         return CollectionService::setCollection($collection->id, $collection);
     }
 
     public function delete(Request $request, int $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
         $user = $request->user();
 
         $collection = Collection::whereProfileId($user->profile_id)->findOrFail($id);
@@ -113,7 +108,7 @@ class CollectionController extends Controller
 
         CollectionService::deleteCollection($id);
 
-        if($request->wantsJson()) {
+        if ($request->wantsJson()) {
             return 200;
         }
 
@@ -122,13 +117,13 @@ class CollectionController extends Controller
 
     public function storeId(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
             'collection_id' => 'required|int|min:1|exists:collections,id',
-            'post_id'       => 'required|int|min:1'
+            'post_id' => 'required|int|min:1',
         ]);
-        
+
         $profileId = $request->user()->profile_id;
         $collectionId = $request->input('collection_id');
         $postId = $request->input('post_id');
@@ -136,157 +131,153 @@ class CollectionController extends Controller
         $collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
         $count = $collection->items()->count();
 
-        if($count) {
+        if ($count) {
             CollectionItem::whereCollectionId($collection->id)
                 ->get()
-                ->filter(function($col) {
+                ->filter(function ($col) {
                     return StatusService::get($col->object_id, false) == null;
                 })
-                ->each(function($col) use($collectionId) {
+                ->each(function ($col) use ($collectionId) {
                     CollectionService::removeItem($collectionId, $col->object_id);
                     $col->delete();
                 });
         }
 
         $max = config('pixelfed.max_collection_length');
-        if($count >= $max) {
+        if ($count >= $max) {
             abort(400, 'You can only add '.$max.' posts per collection');
         }
 
-        $status = Status::whereScope('public')
+        $status = Status::whereIn('scope', ['public', 'unlisted'])
             ->whereProfileId($profileId)
             ->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,
+            'object_type' => 'App\Status',
+            'object_id' => $status->id,
+        ], [
+            'order' => $count,
         ]);
 
-        CollectionService::addItem(
-        	$collection->id,
-        	$status->id,
-        	$count
-        );
+        CollectionService::deleteCollection($collection->id);
 
         $collection->updated_at = now();
         $collection->save();
         CollectionService::setCollection($collection->id, $collection);
 
-        return StatusService::get($status->id);
+        return StatusService::get($status->id, false);
     }
 
     public function getCollection(Request $request, $id)
     {
-		$user = $request->user();
-		$collection = CollectionService::getCollection($id);
+        $user = $request->user();
+        $collection = CollectionService::getCollection($id);
 
-        if(!$collection) {
+        if (! $collection) {
             return response()->json([], 404);
         }
 
-		if($collection['published_at'] == null || $collection['visibility'] != 'public') {
-			abort_unless($user, 404);
-			if($user->profile_id != $collection['pid']) {
-				if(!$user->is_admin) {
-					abort_if($collection['visibility'] != 'private', 404);
-					abort_if(!FollowerService::follows($user->profile_id, $collection['pid']), 404);
-				}
-			}
-		}
+        if ($collection['published_at'] == null || $collection['visibility'] != 'public') {
+            abort_unless($user, 404);
+            if ($user->profile_id != $collection['pid']) {
+                if (! $user->is_admin) {
+                    abort_if($collection['visibility'] != 'private', 404);
+                    abort_if(! FollowerService::follows($user->profile_id, $collection['pid']), 404);
+                }
+            }
+        }
 
         return $collection;
     }
 
     public function getItems(Request $request, int $id)
     {
-    	$user = $request->user();
-    	$collection = CollectionService::getCollection($id);
+        $user = $request->user();
+        $collection = CollectionService::getCollection($id);
 
-        if(!$collection) {
+        if (! $collection) {
             return response()->json([], 404);
         }
 
-        if($collection['published_at'] == null || $collection['visibility'] != 'public') {
-			abort_unless($user, 404);
-			if($user->profile_id != $collection['pid']) {
-				if(!$user->is_admin) {
-					abort_if($collection['visibility'] != 'private', 404);
-					abort_if(!FollowerService::follows($user->profile_id, $collection['pid']), 404);
-				}
-			}
-		}
+        if ($collection['published_at'] == null || $collection['visibility'] != 'public') {
+            abort_unless($user, 404);
+            if ($user->profile_id != $collection['pid']) {
+                if (! $user->is_admin) {
+                    abort_if($collection['visibility'] != 'private', 404);
+                    abort_if(! FollowerService::follows($user->profile_id, $collection['pid']), 404);
+                }
+            }
+        }
         $page = $request->input('page') ?? 1;
         $start = $page == 1 ? 0 : ($page * 10 - 10);
         $end = $start + 10;
         $items = CollectionService::getItems($id, $start, $end);
 
         return collect($items)
-        	->map(function($id) {
-        		return StatusService::get($id);
-        	})
-        	->filter(function($item) {
-        		return $item && isset($item['account'], $item['media_attachments']);
-        	})
-        	->values();
+            ->map(function ($id) {
+                return StatusService::get($id, false);
+            })
+            ->filter(function ($item) {
+                return $item && ($item['visibility'] == 'public' || $item['visibility'] == 'unlisted') && isset($item['account'], $item['media_attachments']);
+            })
+            ->values();
     }
 
     public function getUserCollections(Request $request, int $id)
     {
-    	$user = $request->user();
-    	$pid = $user ? $user->profile_id : null;
-    	$follows = false;
-    	$visibility = ['public'];
+        $user = $request->user();
+        $pid = $user ? $user->profile_id : null;
+        $follows = false;
+        $visibility = ['public'];
 
         $profile = AccountService::get($id, true);
-        if(!$profile || !isset($profile['id'])) {
+        if (! $profile || ! isset($profile['id'])) {
             return response()->json([], 404);
         }
 
-        if($pid) {
-        	$follows = FollowerService::follows($pid, $profile['id']);
+        if ($pid) {
+            $follows = FollowerService::follows($pid, $profile['id']);
         }
 
-        if($profile['locked']) {
-            abort_if(!$pid, 404);
-            if(!$user->is_admin) {
-            	abort_if($profile['id'] != $pid && $follows == false, 404);
+        if ($profile['locked']) {
+            abort_if(! $pid, 404);
+            if (! $user->is_admin) {
+                abort_if($profile['id'] != $pid && $follows == false, 404);
             }
         }
 
         $owner = $pid ? $pid == $profile['id'] : false;
 
-        if($follows) {
-        	$visibility = ['public', 'private'];
+        if ($follows) {
+            $visibility = ['public', 'private'];
         }
 
-        if($pid && $pid == $profile['id']) {
-        	$visibility = ['public', 'private', 'draft'];
+        if ($pid && $pid == $profile['id']) {
+            $visibility = ['public', 'private', 'draft'];
         }
 
         return Collection::whereProfileId($profile['id'])
-        	->whereIn('visibility', $visibility)
-        	->when(!$owner, function($q, $owner) {
-        		return $q->whereNotNull('published_at');
-        	})
+            ->whereIn('visibility', $visibility)
+            ->when(! $owner, function ($q, $owner) {
+                return $q->whereNotNull('published_at');
+            })
             ->orderByDesc('id')
             ->paginate(9)
-            ->map(function($collection) {
-            	return CollectionService::getCollection($collection->id);
-        });
+            ->map(function ($collection) {
+                return CollectionService::getCollection($collection->id);
+            });
     }
 
     public function deleteId(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
         $this->validate($request, [
             'collection_id' => 'required|int|min:1|exists:collections,id',
-            'post_id'       => 'required|int|min:1'
+            'post_id' => 'required|int|min:1',
         ]);
-        
+
         $profileId = $request->user()->profile_id;
         $collectionId = $request->input('collection_id');
         $postId = $request->input('post_id');
@@ -294,11 +285,11 @@ class CollectionController extends Controller
         $collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
         $count = $collection->items()->count();
 
-        if($count == 1) {
+        if ($count == 1) {
             abort(400, 'You cannot delete the only post of a collection!');
         }
 
-        $status = Status::whereScope('public')
+        $status = Status::whereIn('scope', ['public', 'unlisted'])
             ->whereIn('type', ['photo', 'photo:album', 'video'])
             ->findOrFail($postId);
 
@@ -312,7 +303,7 @@ class CollectionController extends Controller
         CollectionItem::whereCollectionId($collection->id)
             ->orderBy('created_at')
             ->get()
-            ->each(function($item, $index) {
+            ->each(function ($item, $index) {
                 $item->order = $index;
                 $item->save();
             });
@@ -323,4 +314,31 @@ class CollectionController extends Controller
 
         return 200;
     }
+
+    public function getSelfCollections(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+        $user = $request->user();
+        $pid = $user->profile_id;
+
+        $profile = AccountService::get($pid, true);
+        if (! $profile || ! isset($profile['id'])) {
+            return response()->json([], 404);
+        }
+
+        return Collection::whereProfileId($pid)
+            ->orderByDesc('id')
+            ->paginate(9)
+            ->map(function ($collection) {
+                $c = CollectionService::getCollection($collection->id);
+                $c['items'] = collect(CollectionService::getItems($collection->id))
+                    ->map(function ($id) {
+                        return StatusService::get($id, false);
+                    })->filter()->values();
+
+                return $c;
+            })
+            ->filter()
+            ->values();
+    }
 }

+ 20 - 24
app/Http/Controllers/CommentController.php

@@ -2,23 +2,18 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use Auth;
-use DB;
-use Cache;
-
-use App\Comment;
 use App\Jobs\CommentPipeline\CommentPipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
-use App\Util\Lexer\Autolink;
-use App\Profile;
+use App\Services\StatusService;
 use App\Status;
+use App\Transformer\Api\StatusTransformer;
 use App\UserFilter;
+use Auth;
+use DB;
+use Illuminate\Http\Request;
 use League\Fractal;
-use App\Transformer\Api\StatusTransformer;
 use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Services\StatusService;
+use Purify;
 
 class CommentController extends Controller
 {
@@ -33,9 +28,9 @@ class CommentController extends Controller
             abort(403);
         }
         $this->validate($request, [
-            'item'    => 'required|integer|min:1',
-            'comment' => 'required|string|max:'.(int) config('pixelfed.max_caption_length'),
-            'sensitive' => 'nullable|boolean'
+            'item' => 'required|integer|min:1',
+            'comment' => 'required|string|max:'.config_cache('pixelfed.max_caption_length'),
+            'sensitive' => 'nullable|boolean',
         ]);
         $comment = $request->input('comment');
         $statusId = $request->input('item');
@@ -45,7 +40,7 @@ class CommentController extends Controller
         $profile = $user->profile;
         $status = Status::findOrFail($statusId);
 
-        if($status->comments_disabled == true) {
+        if ($status->comments_disabled == true) {
             return;
         }
 
@@ -55,18 +50,19 @@ class CommentController extends Controller
             ->whereFilterableId($profile->id)
             ->exists();
 
-        if($filtered == true) {
+        if ($filtered == true) {
             return;
         }
 
-        $reply = DB::transaction(function() use($comment, $status, $profile, $nsfw) {
+        $reply = DB::transaction(function () use ($comment, $status, $profile, $nsfw) {
+            $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+
             $scope = $profile->is_private == true ? 'private' : 'public';
-            $autolink = Autolink::create()->autolink($comment);
-            $reply = new Status();
+            $reply = new Status;
             $reply->profile_id = $profile->id;
             $reply->is_nsfw = $nsfw;
-            $reply->caption = e($comment);
-            $reply->rendered = $autolink;
+            $reply->caption = Purify::clean($comment);
+            $reply->rendered = $defaultCaption;
             $reply->in_reply_to_id = $status->id;
             $reply->in_reply_to_profile_id = $status->profile_id;
             $reply->scope = $scope;
@@ -81,9 +77,9 @@ class CommentController extends Controller
         CommentPipeline::dispatch($status, $reply);
 
         if ($request->ajax()) {
-            $fractal = new Fractal\Manager();
-            $fractal->setSerializer(new ArraySerializer());
-            $entity = new Fractal\Resource\Item($reply, new StatusTransformer());
+            $fractal = new Fractal\Manager;
+            $fractal->setSerializer(new ArraySerializer);
+            $entity = new Fractal\Resource\Item($reply, new StatusTransformer);
             $entity = $fractal->createData($entity)->toArray();
             $response = [
                 'code' => 200,

+ 782 - 765
app/Http/Controllers/ComposeController.php

@@ -2,293 +2,295 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use Auth, Cache, DB, Storage, URL;
-use Carbon\Carbon;
-use App\{
-	Avatar,
-	Collection,
-	CollectionItem,
-	Hashtag,
-	Like,
-	Media,
-	MediaTag,
-	Notification,
-	Profile,
-	Place,
-	Status,
-	UserFilter,
-	UserSetting
-};
-use App\Models\Poll;
-use App\Transformer\Api\{
-	MediaTransformer,
-	MediaDraftTransformer,
-	StatusTransformer,
-	StatusStatelessTransformer
-};
-use League\Fractal;
-use App\Util\Media\Filter;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use App\Jobs\AvatarPipeline\AvatarOptimize;
+use App\Collection;
+use App\CollectionItem;
+use App\Hashtag;
 use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageThumbnail;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
-use App\Jobs\VideoPipeline\{
-	VideoOptimize,
-	VideoPostProcess,
-	VideoThumbnail
-};
+use App\Jobs\VideoPipeline\VideoThumbnail;
+use App\Media;
+use App\MediaTag;
+use App\Models\Poll;
+use App\Notification;
+use App\Profile;
 use App\Services\AccountService;
 use App\Services\CollectionService;
-use App\Services\NotificationService;
-use App\Services\MediaPathService;
 use App\Services\MediaBlocklistService;
+use App\Services\MediaPathService;
 use App\Services\MediaStorageService;
 use App\Services\MediaTagService;
-use App\Services\StatusService;
 use App\Services\SnowflakeService;
-use Illuminate\Support\Str;
-use App\Util\Lexer\Autolink;
-use App\Util\Lexer\Extractor;
+use App\Services\UserRoleService;
+use App\Services\UserStorageService;
+use App\Status;
+use App\Transformer\Api\MediaTransformer;
+use App\UserFilter;
+use App\Util\Media\Filter;
 use App\Util\Media\License;
-use Image;
+use Auth;
+use Cache;
+use DB;
+use Purify;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 
 class ComposeController extends Controller
 {
-	protected $fractal;
-
-	public function __construct()
-	{
-		$this->middleware('auth');
-		$this->fractal = new Fractal\Manager();
-		$this->fractal->setSerializer(new ArraySerializer());
-	}
-
-	public function show(Request $request)
-	{
-		return view('status.compose');
-	}
-
-	public function mediaUpload(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'file.*' => [
-				'required_without:file',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-			'file' => [
-				'required_without:file.*',
-				'mimetypes:' . config_cache('pixelfed.media_types'),
-				'max:' . config_cache('pixelfed.max_photo_size'),
-			],
-			'filter_name' => 'nullable|string|max:24',
-			'filter_class' => 'nullable|alpha_dash|max:24'
-		]);
-
-		$user = Auth::user();
-		$profile = $user->profile;
-
-		$limitKey = 'compose:rate-limit:media-upload:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
-
-			return $dailyLimit >= 1250;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		if(config_cache('pixelfed.enforce_account_limit') == true) {
-			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
-				return Media::whereUserId($user->id)->sum('size') / 1000;
-			});
-			$limit = (int) config_cache('pixelfed.max_account_size');
-			if ($size >= $limit) {
-				abort(403, 'Account size limit reached.');
-			}
-		}
-
-		$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
-		$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
-
-		$photo = $request->file('file');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-
-		abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format');
-
-		$storagePath = MediaPathService::get($user, 2);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-		$mime = $photo->getMimeType();
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		$media = new Media();
-		$media->status_id = null;
-		$media->profile_id = $profile->id;
-		$media->user_id = $user->id;
-		$media->media_path = $path;
-		$media->original_sha256 = $hash;
-		$media->size = $photo->getSize();
-		$media->mime = $mime;
-		$media->filter_class = $filterClass;
-		$media->filter_name = $filterName;
-		$media->version = 3;
-		$media->save();
-
-		$preview_url = $media->url() . '?v=' . time();
-		$url = $media->url() . '?v=' . time();
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-			case 'image/webp':
-			ImageOptimize::dispatch($media)->onQueue('mmo');
-			break;
-
-			case 'video/mp4':
-			VideoThumbnail::dispatch($media)->onQueue('mmo');
-			$preview_url = '/storage/no-preview.png';
-			$url = '/storage/no-preview.png';
-			break;
-
-			default:
-			break;
-		}
-
-		Cache::forget($limitKey);
-		$resource = new Fractal\Resource\Item($media, new MediaTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-		$res['preview_url'] = $preview_url;
-		$res['url'] = $url;
-		return response()->json($res);
-	}
-
-	public function mediaUpdate(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required',
-			'file' => function() {
-				return [
-					'required',
-					'mimetypes:' . config_cache('pixelfed.media_types'),
-					'max:' . config_cache('pixelfed.max_photo_size'),
-				];
-			},
-		]);
-
-		$user = Auth::user();
-
-		$limitKey = 'compose:rate-limit:media-updates:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
-
-			return $dailyLimit >= 1500;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		$photo = $request->file('file');
-		$id = $request->input('id');
-
-		$media = Media::whereUserId($user->id)
-		->whereProfileId($user->profile_id)
-		->whereNull('status_id')
-		->findOrFail($id);
-
-		$media->save();
-
-		$fragments = explode('/', $media->media_path);
-		$name = last($fragments);
-		array_pop($fragments);
-		$dir = implode('/', $fragments);
-		$path = $photo->storePubliclyAs($dir, $name);
-		$res = [
-			'url' => $media->url() . '?v=' . time()
-		];
-		ImageOptimize::dispatch($media)->onQueue('mmo');
-		Cache::forget($limitKey);
-		return $res;
-	}
-
-	public function mediaDelete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'id' => 'required|integer|min:1|exists:media,id'
-		]);
-
-		$media = Media::whereNull('status_id')
-		->whereUserId(Auth::id())
-		->findOrFail($request->input('id'));
-
-		MediaStorageService::delete($media, true);
-
-		return response()->json([
-			'msg' => 'Successfully deleted',
-			'code' => 200
-		]);
-	}
-
-	public function searchTag(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:1|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		if(Str::of($q)->startsWith('@')) {
-			if(strlen($q) < 3) {
-				return [];
-			}
-			$q = mb_substr($q, 1);
-		}
-
-		$blocked = UserFilter::whereFilterableType('App\Profile')
-			->whereFilterType('block')
-			->whereFilterableId($request->user()->profile_id)
-			->pluck('user_id');
-
-		$blocked->push($request->user()->profile_id);
-
-		$results = Profile::select('id','domain','username')
-			->whereNotIn('id', $blocked)
-			->whereNull('domain')
-			->where('username','like','%'.$q.'%')
-			->limit(15)
-			->get()
-			->map(function($r) {
-				return [
-					'id' => (string) $r->id,
-					'name' => $r->username,
-					'privacy' => true,
-					'avatar' => $r->avatarUrl()
-				];
-		});
-
-		return $results;
-	}
+    protected $fractal;
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
+    }
+
+    public function show(Request $request)
+    {
+        return view('status.compose');
+    }
+
+    public function mediaUpload(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'file.*' => [
+                'required_without:file',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'file' => [
+                'required_without:file.*',
+                'mimetypes:'.config_cache('pixelfed.media_types'),
+                'max:'.config_cache('pixelfed.max_photo_size'),
+            ],
+            'filter_name' => 'nullable|string|max:24',
+            'filter_class' => 'nullable|alpha_dash|max:24',
+        ]);
+
+        $user = $request->user();
+        $profile = $user->profile;
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        $limitKey = 'compose:rate-limit:media-upload:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
+
+            return $dailyLimit >= 1250;
+        });
+
+        abort_if($limitReached == true, 429);
+
+        $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
+        $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
+        $accountSize = UserStorageService::get($user->id);
+        abort_if($accountSize === -1, 403, 'Invalid request.');
+        $photo = $request->file('file');
+        $fileSize = $photo->getSize();
+        $sizeInKbs = (int) ceil($fileSize / 1000);
+        $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs;
+
+        if ((bool) config_cache('pixelfed.enforce_account_limit') == true) {
+            $limit = (int) config_cache('pixelfed.max_account_size');
+            if ($updatedAccountSize >= $limit) {
+                abort(403, 'Account size limit reached.');
+            }
+        }
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+
+        abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format');
+
+        $storagePath = MediaPathService::get($user, 2);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+        $mime = $photo->getMimeType();
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        $media = new Media;
+        $media->status_id = null;
+        $media->profile_id = $profile->id;
+        $media->user_id = $user->id;
+        $media->media_path = $path;
+        $media->original_sha256 = $hash;
+        $media->size = $photo->getSize();
+        $media->caption = '';
+        $media->mime = $mime;
+        $media->filter_class = $filterClass;
+        $media->filter_name = $filterName;
+        $media->version = '3';
+        $media->save();
+
+        $preview_url = $media->url().'?v='.time();
+        $url = $media->url().'?v='.time();
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+            case 'image/webp':
+                ImageOptimize::dispatch($media)->onQueue('mmo');
+                break;
+
+            case 'video/mp4':
+                VideoThumbnail::dispatch($media)->onQueue('mmo');
+                $preview_url = '/storage/no-preview.png';
+                $url = '/storage/no-preview.png';
+                break;
+
+            default:
+                break;
+        }
+
+        $user->storage_used = (int) $updatedAccountSize;
+        $user->storage_used_updated_at = now();
+        $user->save();
+
+        Cache::forget($limitKey);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        $res['preview_url'] = $preview_url;
+        $res['url'] = $url;
+
+        return response()->json($res);
+    }
+
+    public function mediaUpdate(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+            'file' => function () {
+                return [
+                    'required',
+                    'mimetypes:'.config_cache('pixelfed.media_types'),
+                    'max:'.config_cache('pixelfed.max_photo_size'),
+                ];
+            },
+        ]);
+
+        $user = Auth::user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        $limitKey = 'compose:rate-limit:media-updates:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+            $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
+
+            return $dailyLimit >= 1500;
+        });
+
+        abort_if($limitReached == true, 429);
+
+        $photo = $request->file('file');
+        $id = $request->input('id');
+
+        $media = Media::whereUserId($user->id)
+            ->whereProfileId($user->profile_id)
+            ->whereNull('status_id')
+            ->findOrFail($id);
+
+        $media->save();
+
+        $fragments = explode('/', $media->media_path);
+        $name = last($fragments);
+        array_pop($fragments);
+        $dir = implode('/', $fragments);
+        $path = $photo->storePubliclyAs($dir, $name);
+        $res = [
+            'url' => $media->url().'?v='.time(),
+        ];
+        ImageOptimize::dispatch($media)->onQueue('mmo');
+        Cache::forget($limitKey);
+        UserStorageService::recalculateUpdateStorageUsed($request->user()->id);
+
+        return $res;
+    }
+
+    public function mediaDelete(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'id' => 'required|integer|min:1|exists:media,id',
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $media = Media::whereNull('status_id')
+            ->whereUserId(Auth::id())
+            ->findOrFail($request->input('id'));
+
+        MediaStorageService::delete($media, true);
+
+        UserStorageService::recalculateUpdateStorageUsed($request->user()->id);
+
+        return response()->json([
+            'msg' => 'Successfully deleted',
+            'code' => 200,
+        ]);
+    }
+
+    public function searchTag(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:1|max:50',
+        ]);
+
+        $q = $request->input('q');
+
+        if (Str::of($q)->startsWith('@')) {
+            if (strlen($q) < 3) {
+                return [];
+            }
+            $q = mb_substr($q, 1);
+        }
+
+        $user = $request->user();
+
+        abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        $blocked = UserFilter::whereFilterableType('App\Profile')
+            ->whereFilterType('block')
+            ->whereFilterableId($request->user()->profile_id)
+            ->pluck('user_id');
+
+        $blocked->push($request->user()->profile_id);
+
+        $results = Profile::select('id', 'domain', 'username')
+            ->whereNotIn('id', $blocked)
+            ->whereNull('domain')
+            ->where('username', 'like', '%'.$q.'%')
+            ->limit(15)
+            ->get()
+            ->map(function ($r) {
+                return [
+                    'id' => (string) $r->id,
+                    'name' => $r->username,
+                    'privacy' => true,
+                    'avatar' => $r->avatarUrl(),
+                ];
+            });
+
+        return $results;
+    }
 
     public function searchUntag(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
             'status_id' => 'required',
-            'profile_id' => 'required'
+            'profile_id' => 'required',
         ]);
 
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
         $user = $request->user();
         $status_id = $request->input('status_id');
         $profile_id = (int) $request->input('profile_id');
@@ -299,7 +301,7 @@ class ComposeController extends Controller
             ->whereProfileId($profile_id)
             ->first();
 
-        if(!$tag) {
+        if (! $tag) {
             return [];
         }
         Notification::whereItemType('App\MediaTag')
@@ -313,506 +315,521 @@ class ComposeController extends Controller
         return [200];
     }
 
-	public function searchLocation(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-		$this->validate($request, [
-			'q' => 'required|string|max:100'
-		]);
-		$pid = $request->user()->profile_id;
-		abort_if(!$pid, 400);
-		$q = e($request->input('q'));
-
-		$popular = Cache::remember('pf:search:location:v1:popular', 1209600, function() {
-			$minId = SnowflakeService::byDate(now()->subDays(290));
-			if(config('database.default') == 'pgsql') {
-				return Status::selectRaw('id, place_id, count(place_id) as pc')
-				->whereNotNull('place_id')
-				->where('id', '>', $minId)
-				->orderByDesc('pc')
-				->groupBy(['place_id', 'id'])
-				->limit(400)
-				->get()
-				->filter(function($post) {
-					return $post;
-				})
-				->map(function($place) {
-					return [
-						'id' => $place->place_id,
-						'count' => $place->pc
-					];
-				})
-				->unique('id')
-				->values();
-			}
-			return Status::selectRaw('id, place_id, count(place_id) as pc')
-				->whereNotNull('place_id')
-				->where('id', '>', $minId)
-				->groupBy('place_id')
-				->orderByDesc('pc')
-				->limit(400)
-				->get()
-				->filter(function($post) {
-					return $post;
-				})
-				->map(function($place) {
-					return [
-						'id' => $place->place_id,
-						'count' => $place->pc
-					];
-				});
-		});
-		$q = '%' . $q . '%';
-		$wildcard = config('database.default') === 'pgsql' ? 'ilike' : 'like';
-
-		$places = DB::table('places')
-		->where('name', $wildcard, $q)
-		->limit((strlen($q) > 5 ? 360 : 30))
-		->get()
-		->sortByDesc(function($place, $key) use($popular) {
-			return $popular->filter(function($p) use($place) {
-				return $p['id'] == $place->id;
-			})->map(function($p) use($place) {
-				return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1;
-			})->values();
-		})
-		->map(function($r) {
-			return [
-				'id' => $r->id,
-				'name' => $r->name,
-				'country' => $r->country,
-				'url'   => url('/discover/places/' . $r->id . '/' . $r->slug)
-			];
-		})
-		->values()
-		->all();
-		return $places;
-	}
-
-	public function searchMentionAutocomplete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:2|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		if(Str::of($q)->startsWith('@')) {
-			if(strlen($q) < 3) {
-				return [];
-			}
-		}
-
-		$blocked = UserFilter::whereFilterableType('App\Profile')
-			->whereFilterType('block')
-			->whereFilterableId($request->user()->profile_id)
-			->pluck('user_id');
-
-		$blocked->push($request->user()->profile_id);
-
-		$results = Profile::select('id','domain','username')
-			->whereNotIn('id', $blocked)
-			->where('username','like','%'.$q.'%')
-			->groupBy('id', 'domain')
-			->limit(15)
-			->get()
-			->map(function($profile) {
-				$username = $profile->domain ? substr($profile->username, 1) : $profile->username;
+    public function searchLocation(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+        $this->validate($request, [
+            'q' => 'required|string|max:100',
+        ]);
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+        $pid = $request->user()->profile_id;
+        abort_if(! $pid, 400);
+        $q = e($request->input('q'));
+
+        $popular = Cache::remember('pf:search:location:v1:popular', 1209600, function () {
+            $minId = SnowflakeService::byDate(now()->subDays(290));
+            if (config('database.default') == 'pgsql') {
+                return Status::selectRaw('id, place_id, count(place_id) as pc')
+                    ->whereNotNull('place_id')
+                    ->where('id', '>', $minId)
+                    ->orderByDesc('pc')
+                    ->groupBy(['place_id', 'id'])
+                    ->limit(400)
+                    ->get()
+                    ->filter(function ($post) {
+                        return $post;
+                    })
+                    ->map(function ($place) {
+                        return [
+                            'id' => $place->place_id,
+                            'count' => $place->pc,
+                        ];
+                    })
+                    ->unique('id')
+                    ->values();
+            }
+
+            return Status::selectRaw('id, place_id, count(place_id) as pc')
+                ->whereNotNull('place_id')
+                ->where('id', '>', $minId)
+                ->groupBy('place_id')
+                ->orderByDesc('pc')
+                ->limit(400)
+                ->get()
+                ->filter(function ($post) {
+                    return $post;
+                })
+                ->map(function ($place) {
+                    return [
+                        'id' => $place->place_id,
+                        'count' => $place->pc,
+                    ];
+                });
+        });
+        $q = '%'.$q.'%';
+        $wildcard = config('database.default') === 'pgsql' ? 'ilike' : 'like';
+
+        $places = DB::table('places')
+            ->where('name', $wildcard, $q)
+            ->limit((strlen($q) > 5 ? 360 : 30))
+            ->get()
+            ->sortByDesc(function ($place, $key) use ($popular) {
+                return $popular->filter(function ($p) use ($place) {
+                    return $p['id'] == $place->id;
+                })->map(function ($p) use ($place) {
+                    return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1;
+                })->values();
+            })
+            ->map(function ($r) {
                 return [
-                    'key' => '@' . str_limit($username, 30),
+                    'id' => $r->id,
+                    'name' => $r->name,
+                    'country' => $r->country,
+                    'url' => url('/discover/places/'.$r->id.'/'.$r->slug),
+                ];
+            })
+            ->values()
+            ->all();
+
+        return $places;
+    }
+
+    public function searchMentionAutocomplete(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:2|max:50',
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $q = $request->input('q');
+
+        if (Str::of($q)->startsWith('@')) {
+            if (strlen($q) < 3) {
+                return [];
+            }
+        }
+
+        $blocked = UserFilter::whereFilterableType('App\Profile')
+            ->whereFilterType('block')
+            ->whereFilterableId($request->user()->profile_id)
+            ->pluck('user_id');
+
+        $blocked->push($request->user()->profile_id);
+
+        $results = Profile::select('id', 'domain', 'username')
+            ->whereNotIn('id', $blocked)
+            ->where('username', 'like', '%'.$q.'%')
+            ->groupBy('id', 'domain')
+            ->limit(15)
+            ->get()
+            ->map(function ($profile) {
+                $username = $profile->domain ? substr($profile->username, 1) : $profile->username;
+
+                return [
+                    'key' => '@'.str_limit($username, 30),
                     'value' => $username,
                 ];
-		});
-
-		return $results;
-	}
-
-	public function searchHashtagAutocomplete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:2|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		$results = Hashtag::select('slug')
-			->where('slug', 'like', '%'.$q.'%')
-			->whereIsNsfw(false)
-			->whereIsBanned(false)
-			->limit(5)
-			->get()
-			->map(function($tag) {
-				return [
-					'key' => '#' . $tag->slug,
-					'value' => $tag->slug
-				];
-		});
-
-		return $results;
-	}
-
-	public function store(Request $request)
-	{
-		$this->validate($request, [
-			'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
-			'media.*'   => 'required',
-			'media.*.id' => 'required|integer|min:1',
-			'media.*.filter_class' => 'nullable|alpha_dash|max:30',
-			'media.*.license' => 'nullable|string|max:140',
-			'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
-			'cw' => 'nullable|boolean',
-			'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
-			'place' => 'nullable',
-			'comments_disabled' => 'nullable',
-			'tagged' => 'nullable',
-			'license' => 'nullable|integer|min:1|max:16',
-			'collections' => 'sometimes|array|min:1|max:5',
-			'spoiler_text' => 'nullable|string|max:140',
-			// 'optimize_media' => 'nullable'
-		]);
-
-		if(config('costar.enabled') == true) {
-			$blockedKeywords = config('costar.keyword.block');
-			if($blockedKeywords !== null && $request->caption) {
-				$keywords = config('costar.keyword.block');
-				foreach($keywords as $kw) {
-					if(Str::contains($request->caption, $kw) == true) {
-						abort(400, 'Invalid object');
-					}
-				}
-			}
-		}
-
-		$user = Auth::user();
-		$profile = $user->profile;
-
-		$limitKey = 'compose:rate-limit:store:' . $user->id;
-		$limitTtl = now()->addMinutes(15);
-		$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
-			$dailyLimit = Status::whereProfileId($user->profile_id)
-				->whereNull('in_reply_to_id')
-				->whereNull('reblog_of_id')
-				->where('created_at', '>', now()->subDays(1))
-				->count();
-
-			return $dailyLimit >= 1000;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		$license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null;
-
-		$visibility = $request->input('visibility');
-		$medias = $request->input('media');
-		$attachments = [];
-		$status = new Status;
-		$mimes = [];
-		$place = $request->input('place');
-		$cw = $request->input('cw');
-		$tagged = $request->input('tagged');
-		$optimize_media = (bool) $request->input('optimize_media');
-
-		foreach($medias as $k => $media) {
-			if($k + 1 > config_cache('pixelfed.max_album_length')) {
-				continue;
-			}
-			$m = Media::findOrFail($media['id']);
-			if($m->profile_id !== $profile->id || $m->status_id) {
-				abort(403, 'Invalid media id');
-			}
-			$m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null;
-			$m->license = $license;
-			$m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null;
-			$m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
-
-			if($cw == true || $profile->cw == true) {
-				$m->is_nsfw = $cw;
-				$status->is_nsfw = $cw;
-			}
-			$m->save();
-			$attachments[] = $m;
-			array_push($mimes, $m->mime);
-		}
-
-		abort_if(empty($attachments), 422);
-
-		$mediaType = StatusController::mimeTypeCheck($mimes);
-
-		if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
-			abort(400, __('exception.compose.invalid.album'));
-		}
-
-		if($place && is_array($place)) {
-			$status->place_id = $place['id'];
-		}
-
-		if($request->filled('comments_disabled')) {
-			$status->comments_disabled = (bool) $request->input('comments_disabled');
-		}
-
-		if($request->filled('spoiler_text') && $cw) {
-			$status->cw_summary = $request->input('spoiler_text');
-		}
-
-		$status->caption = strip_tags($request->caption);
-		$status->rendered = Autolink::create()->autolink($status->caption);
-		$status->scope = 'draft';
-		$status->visibility = 'draft';
-		$status->profile_id = $profile->id;
-		$status->save();
-
-		foreach($attachments as $media) {
-			$media->status_id = $status->id;
-			$media->save();
-		}
-
-		$visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
-		$visibility = $profile->is_private ? 'private' : $visibility;
-		$cw = $profile->cw == true ? true : $cw;
-		$status->is_nsfw = $cw;
-		$status->visibility = $visibility;
-		$status->scope = $visibility;
-		$status->type = $mediaType;
-		$status->save();
-
-		foreach($tagged as $tg) {
-			$mt = new MediaTag;
-			$mt->status_id = $status->id;
-			$mt->media_id = $status->media->first()->id;
-			$mt->profile_id = $tg['id'];
-			$mt->tagged_username = $tg['name'];
-			$mt->is_public = true;
-			$mt->metadata = json_encode([
-				'_v' => 1,
-			]);
-			$mt->save();
-			MediaTagService::set($mt->status_id, $mt->profile_id);
-			MediaTagService::sendNotification($mt);
-		}
-
-		if($request->filled('collections')) {
-			$collections = Collection::whereProfileId($profile->id)
-				->find($request->input('collections'))
-				->each(function($collection) use($status) {
-					$count = $collection->items()->count();
-					CollectionItem::firstOrCreate([
-						'collection_id' => $collection->id,
-						'object_type' => 'App\Status',
-						'object_id' => $status->id
-					], [
-						'order' => $count
-					]);
-
-					CollectionService::addItem(
-						$collection->id,
-						$status->id,
-						$count
-					);
-
-					$collection->updated_at = now();
+            });
+
+        return $results;
+    }
+
+    public function searchHashtagAutocomplete(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:2|max:50',
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $q = $request->input('q');
+
+        $results = Hashtag::select('slug')
+            ->where('slug', 'like', '%'.$q.'%')
+            ->whereIsNsfw(false)
+            ->whereIsBanned(false)
+            ->limit(5)
+            ->get()
+            ->map(function ($tag) {
+                return [
+                    'key' => '#'.$tag->slug,
+                    'value' => $tag->slug,
+                ];
+            });
+
+        return $results;
+    }
+
+    public function store(Request $request)
+    {
+        $this->validate($request, [
+            'caption' => 'nullable|string|max:'.config_cache('pixelfed.max_caption_length', 500),
+            'media.*' => 'required',
+            'media.*.id' => 'required|integer|min:1',
+            'media.*.filter_class' => 'nullable|alpha_dash|max:30',
+            'media.*.license' => 'nullable|string|max:140',
+            'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
+            'cw' => 'nullable|boolean',
+            'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
+            'place' => 'nullable',
+            'comments_disabled' => 'nullable',
+            'tagged' => 'nullable',
+            'license' => 'nullable|integer|min:1|max:16',
+            'collections' => 'sometimes|array|min:1|max:5',
+            'spoiler_text' => 'nullable|string|max:140',
+            // 'optimize_media' => 'nullable'
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        if (config('costar.enabled') == true) {
+            $blockedKeywords = config('costar.keyword.block');
+            if ($blockedKeywords !== null && $request->caption) {
+                $keywords = config('costar.keyword.block');
+                foreach ($keywords as $kw) {
+                    if (Str::contains($request->caption, $kw) == true) {
+                        abort(400, 'Invalid object');
+                    }
+                }
+            }
+        }
+
+        $user = $request->user();
+        $profile = $user->profile;
+
+        $limitKey = 'compose:rate-limit:store:'.$user->id;
+        $limitTtl = now()->addMinutes(15);
+        // $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
+        //     $dailyLimit = Status::whereProfileId($user->profile_id)
+        //         ->whereNull('in_reply_to_id')
+        //         ->whereNull('reblog_of_id')
+        //         ->where('created_at', '>', now()->subDays(1))
+        //         ->count();
+
+        //     return $dailyLimit >= 1000;
+        // });
+
+        // abort_if($limitReached == true, 429);
+
+        $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null;
+
+        $visibility = $request->input('visibility');
+        $medias = $request->input('media');
+        $attachments = [];
+        $status = new Status;
+        $mimes = [];
+        $place = $request->input('place');
+        $cw = $request->input('cw');
+        $tagged = $request->input('tagged');
+        $optimize_media = (bool) $request->input('optimize_media');
+
+        foreach ($medias as $k => $media) {
+            if ($k + 1 > config_cache('pixelfed.max_album_length')) {
+                continue;
+            }
+            $m = Media::findOrFail($media['id']);
+            if ($m->profile_id !== $profile->id || $m->status_id) {
+                abort(403, 'Invalid media id');
+            }
+            $m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null;
+            $m->license = $license;
+            $m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null;
+            $m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
+
+            if ($cw == true || $profile->cw == true) {
+                $m->is_nsfw = $cw;
+                $status->is_nsfw = $cw;
+            }
+            $m->save();
+            $attachments[] = $m;
+            array_push($mimes, $m->mime);
+        }
+
+        abort_if(empty($attachments), 422);
+
+        $mediaType = StatusController::mimeTypeCheck($mimes);
+
+        if (in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
+            abort(400, __('exception.compose.invalid.album'));
+        }
+
+        if ($place && is_array($place)) {
+            $status->place_id = $place['id'];
+        }
+
+        if ($request->filled('comments_disabled')) {
+            $status->comments_disabled = (bool) $request->input('comments_disabled');
+        }
+
+        if ($request->filled('spoiler_text') && $cw) {
+            $status->cw_summary = $request->input('spoiler_text');
+        }
+
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
+        $status->rendered = $defaultCaption;
+        $status->scope = 'draft';
+        $status->visibility = 'draft';
+        $status->profile_id = $profile->id;
+        $status->save();
+
+        foreach ($attachments as $media) {
+            $media->status_id = $status->id;
+            $media->save();
+        }
+
+        $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
+        $visibility = $profile->is_private ? 'private' : $visibility;
+        $cw = $profile->cw == true ? true : $cw;
+        $status->is_nsfw = $cw;
+        $status->visibility = $visibility;
+        $status->scope = $visibility;
+        $status->type = $mediaType;
+        $status->save();
+
+        foreach ($tagged as $tg) {
+            $mt = new MediaTag;
+            $mt->status_id = $status->id;
+            $mt->media_id = $status->media->first()->id;
+            $mt->profile_id = $tg['id'];
+            $mt->tagged_username = $tg['name'];
+            $mt->is_public = true;
+            $mt->metadata = json_encode([
+                '_v' => 1,
+            ]);
+            $mt->save();
+            MediaTagService::set($mt->status_id, $mt->profile_id);
+            MediaTagService::sendNotification($mt);
+        }
+
+        if ($request->filled('collections')) {
+            $collections = Collection::whereProfileId($profile->id)
+                ->find($request->input('collections'))
+                ->each(function ($collection) use ($status) {
+                    $count = $collection->items()->count();
+                    CollectionItem::firstOrCreate([
+                        'collection_id' => $collection->id,
+                        'object_type' => 'App\Status',
+                        'object_id' => $status->id,
+                    ], [
+                        'order' => $count,
+                    ]);
+
+                    CollectionService::addItem(
+                        $collection->id,
+                        $status->id,
+                        $count
+                    );
+
+                    $collection->updated_at = now();
                     $collection->save();
                     CollectionService::setCollection($collection->id, $collection);
-				});
-		}
-
-		NewStatusPipeline::dispatch($status);
-		Cache::forget('user:account:id:'.$profile->user_id);
-		Cache::forget('_api:statuses:recent_9:'.$profile->id);
-		Cache::forget('profile:status_count:'.$profile->id);
-		Cache::forget('status:transformer:media:attachments:'.$status->id);
-		Cache::forget($user->storageUsedKey());
-		Cache::forget('profile:embed:' . $status->profile_id);
-		Cache::forget($limitKey);
-
-		return $status->url();
-	}
-
-	public function storeText(Request $request)
-	{
-		abort_unless(config('exp.top'), 404);
-		$this->validate($request, [
-			'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
-			'cw' => 'nullable|boolean',
-			'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
-			'place' => 'nullable',
-			'comments_disabled' => 'nullable',
-			'tagged' => 'nullable',
-		]);
-
-		if(config('costar.enabled') == true) {
-			$blockedKeywords = config('costar.keyword.block');
-			if($blockedKeywords !== null && $request->caption) {
-				$keywords = config('costar.keyword.block');
-				foreach($keywords as $kw) {
-					if(Str::contains($request->caption, $kw) == true) {
-						abort(400, 'Invalid object');
-					}
-				}
-			}
-		}
-
-		$user = Auth::user();
-		$profile = $user->profile;
-		$visibility = $request->input('visibility');
-		$status = new Status;
-		$place = $request->input('place');
-		$cw = $request->input('cw');
-		$tagged = $request->input('tagged');
-
-		if($place && is_array($place)) {
-			$status->place_id = $place['id'];
-		}
-
-		if($request->filled('comments_disabled')) {
-			$status->comments_disabled = (bool) $request->input('comments_disabled');
-		}
-
-		$status->caption = strip_tags($request->caption);
-		$status->profile_id = $profile->id;
-		$entities = [];
-		$visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
-		$cw = $profile->cw == true ? true : $cw;
-		$status->is_nsfw = $cw;
-		$status->visibility = $visibility;
-		$status->scope = $visibility;
-		$status->type = 'text';
-		$status->rendered = Autolink::create()->autolink($status->caption);
-		$status->entities = json_encode(array_merge([
-			'timg' => [
-				'version' => 0,
-				'bg_id' => 1,
-				'font_size' => strlen($status->caption) <= 140 ? 'h1' : 'h3',
-				'length' => strlen($status->caption),
-			]
-		], $entities), JSON_UNESCAPED_SLASHES);
-		$status->save();
-
-		foreach($tagged as $tg) {
-			$mt = new MediaTag;
-			$mt->status_id = $status->id;
-			$mt->media_id = $status->media->first()->id;
-			$mt->profile_id = $tg['id'];
-			$mt->tagged_username = $tg['name'];
-			$mt->is_public = true;
-			$mt->metadata = json_encode([
-				'_v' => 1,
-			]);
-			$mt->save();
-			MediaTagService::set($mt->status_id, $mt->profile_id);
-			MediaTagService::sendNotification($mt);
-		}
-
-
-		Cache::forget('user:account:id:'.$profile->user_id);
-		Cache::forget('_api:statuses:recent_9:'.$profile->id);
-		Cache::forget('profile:status_count:'.$profile->id);
-
-		return $status->url();
-	}
-
-	public function mediaProcessingCheck(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required|integer|min:1'
-		]);
-
-		$media = Media::whereUserId($request->user()->id)
-			->whereNull('status_id')
-			->findOrFail($request->input('id'));
-
-		if(config('pixelfed.media_fast_process')) {
-			return [
-				'finished' => true
-			];
-		}
-
-		$finished = false;
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-			case 'video/mp4':
-				$finished = config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at;
-				break;
-
-			default:
-				# code...
-				break;
-		}
-
-		return [
-			'finished' => $finished
-		];
-	}
-
-	public function composeSettings(Request $request)
-	{
-		$uid = $request->user()->id;
-		$default = [
-			'default_license' => 1,
-			'media_descriptions' => false,
-			'max_altext_length' => config_cache('pixelfed.max_altext_length')
-		];
-		$settings = AccountService::settings($uid);
-		if(isset($settings['other']) && isset($settings['other']['scope'])) {
-			$s = $settings['compose_settings'];
-			$s['default_scope'] = $settings['other']['scope'];
-			$settings['compose_settings'] = $s;
-		}
-
-		return array_merge($default, $settings['compose_settings']);
-	}
-
-	public function createPoll(Request $request)
-	{
-		$this->validate($request, [
-			'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
-			'cw' => 'nullable|boolean',
-			'visibility' => 'required|string|in:public,private',
-			'comments_disabled' => 'nullable',
-			'expiry' => 'required|in:60,360,1440,10080',
-			'pollOptions' => 'required|array|min:1|max:4'
-		]);
-
-		abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled');
-
-		abort_if(Status::whereType('poll')
-			->whereProfileId($request->user()->profile_id)
-			->whereCaption($request->input('caption'))
-			->where('created_at', '>', now()->subDays(2))
-			->exists()
-		, 422, 'Duplicate detected.');
-
-		$status = new Status;
-		$status->profile_id = $request->user()->profile_id;
-		$status->caption = $request->input('caption');
-		$status->rendered = Autolink::create()->autolink($status->caption);
-		$status->visibility = 'draft';
-		$status->scope = 'draft';
-		$status->type = 'poll';
-		$status->local = true;
-		$status->save();
-
-		$poll = new Poll;
-		$poll->status_id = $status->id;
-		$poll->profile_id = $status->profile_id;
-		$poll->poll_options = $request->input('pollOptions');
-		$poll->expires_at = now()->addMinutes($request->input('expiry'));
-		$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
-			return 0;
-		})->toArray();
-		$poll->save();
-
-		$status->visibility = $request->input('visibility');
-		$status->scope = $request->input('visibility');
-		$status->save();
-
-		NewStatusPipeline::dispatch($status);
-
-		return ['url' => $status->url()];
-	}
+                });
+        }
+
+        NewStatusPipeline::dispatch($status);
+        Cache::forget('user:account:id:'.$profile->user_id);
+        Cache::forget('_api:statuses:recent_9:'.$profile->id);
+        Cache::forget('profile:status_count:'.$profile->id);
+        Cache::forget('status:transformer:media:attachments:'.$status->id);
+        Cache::forget('profile:embed:'.$status->profile_id);
+        Cache::forget($limitKey);
+
+        return $status->url();
+    }
+
+    public function storeText(Request $request)
+    {
+        abort_unless(config('exp.top'), 404);
+        $this->validate($request, [
+            'caption' => 'nullable|string|max:'.config_cache('pixelfed.max_caption_length', 500),
+            'cw' => 'nullable|boolean',
+            'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
+            'place' => 'nullable',
+            'comments_disabled' => 'nullable',
+            'tagged' => 'nullable',
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        if (config('costar.enabled') == true) {
+            $blockedKeywords = config('costar.keyword.block');
+            if ($blockedKeywords !== null && $request->caption) {
+                $keywords = config('costar.keyword.block');
+                foreach ($keywords as $kw) {
+                    if (Str::contains($request->caption, $kw) == true) {
+                        abort(400, 'Invalid object');
+                    }
+                }
+            }
+        }
+
+        $user = $request->user();
+        $profile = $user->profile;
+        $visibility = $request->input('visibility');
+        $status = new Status;
+        $place = $request->input('place');
+        $cw = $request->input('cw');
+        $tagged = $request->input('tagged');
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+
+        if ($place && is_array($place)) {
+            $status->place_id = $place['id'];
+        }
+
+        if ($request->filled('comments_disabled')) {
+            $status->comments_disabled = (bool) $request->input('comments_disabled');
+        }
+
+        $status->caption = $request->filled('caption') ? strip_tags($request->caption) : $defaultCaption;
+        $status->rendered = $defaultCaption;
+        $status->profile_id = $profile->id;
+        $entities = [];
+        $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
+        $cw = $profile->cw == true ? true : $cw;
+        $status->is_nsfw = $cw;
+        $status->visibility = $visibility;
+        $status->scope = $visibility;
+        $status->type = 'text';
+        $status->entities = json_encode(array_merge([
+            'timg' => [
+                'version' => 0,
+                'bg_id' => 1,
+                'font_size' => strlen($status->caption) <= 140 ? 'h1' : 'h3',
+                'length' => strlen($status->caption),
+            ],
+        ], $entities), JSON_UNESCAPED_SLASHES);
+        $status->save();
+
+        foreach ($tagged as $tg) {
+            $mt = new MediaTag;
+            $mt->status_id = $status->id;
+            $mt->media_id = $status->media->first()->id;
+            $mt->profile_id = $tg['id'];
+            $mt->tagged_username = $tg['name'];
+            $mt->is_public = true;
+            $mt->metadata = json_encode([
+                '_v' => 1,
+            ]);
+            $mt->save();
+            MediaTagService::set($mt->status_id, $mt->profile_id);
+            MediaTagService::sendNotification($mt);
+        }
+
+        Cache::forget('user:account:id:'.$profile->user_id);
+        Cache::forget('_api:statuses:recent_9:'.$profile->id);
+        Cache::forget('profile:status_count:'.$profile->id);
+
+        return $status->url();
+    }
+
+    public function mediaProcessingCheck(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|integer|min:1',
+        ]);
+
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $media = Media::whereUserId($request->user()->id)
+            ->whereNull('status_id')
+            ->findOrFail($request->input('id'));
+
+        if (config('pixelfed.media_fast_process')) {
+            return [
+                'finished' => true,
+            ];
+        }
+
+        $finished = false;
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+            case 'video/mp4':
+                $finished = (bool) config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at;
+                break;
+
+            default:
+                // code...
+                break;
+        }
+
+        return [
+            'finished' => $finished,
+        ];
+    }
+
+    public function composeSettings(Request $request)
+    {
+        $uid = $request->user()->id;
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $default = [
+            'default_license' => 1,
+            'media_descriptions' => false,
+            'max_altext_length' => config_cache('pixelfed.max_altext_length'),
+        ];
+        $settings = AccountService::settings($uid);
+        if (isset($settings['other']) && isset($settings['other']['scope'])) {
+            $s = $settings['compose_settings'];
+            $s['default_scope'] = $settings['other']['scope'];
+            $settings['compose_settings'] = $s;
+        }
+
+        return array_merge($default, $settings['compose_settings']);
+    }
+
+    public function createPoll(Request $request)
+    {
+        $this->validate($request, [
+            'caption' => 'nullable|string|max:'.config_cache('pixelfed.max_caption_length', 500),
+            'cw' => 'nullable|boolean',
+            'visibility' => 'required|string|in:public,private',
+            'comments_disabled' => 'nullable',
+            'expiry' => 'required|in:60,360,1440,10080',
+            'pollOptions' => 'required|array|min:1|max:4',
+        ]);
+        abort(404);
+        abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled');
+        abort_if($request->user()->has_roles && ! UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        abort_if(Status::whereType('poll')
+            ->whereProfileId($request->user()->profile_id)
+            ->whereCaption($request->input('caption'))
+            ->where('created_at', '>', now()->subDays(2))
+            ->exists(), 422, 'Duplicate detected.');
+
+        $status = new Status;
+        $status->profile_id = $request->user()->profile_id;
+        $status->caption = $request->input('caption');
+        $status->visibility = 'draft';
+        $status->scope = 'draft';
+        $status->type = 'poll';
+        $status->local = true;
+        $status->save();
+
+        $poll = new Poll;
+        $poll->status_id = $status->id;
+        $poll->profile_id = $status->profile_id;
+        $poll->poll_options = $request->input('pollOptions');
+        $poll->expires_at = now()->addMinutes($request->input('expiry'));
+        $poll->cached_tallies = collect($poll->poll_options)->map(function ($o) {
+            return 0;
+        })->toArray();
+        $poll->save();
+
+        $status->visibility = $request->input('visibility');
+        $status->scope = $request->input('visibility');
+        $status->save();
+
+        NewStatusPipeline::dispatch($status);
+
+        return ['url' => $status->url()];
+    }
 }

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

@@ -50,4 +50,15 @@ class ContactController extends Controller
 
 		return redirect()->back()->with('status', 'Success - Your message has been sent to admins.');
 	}
+
+    public function showAdminResponse(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $uid = $request->user()->id;
+        $contact = Contact::whereUserId($uid)
+            ->whereNotNull('response')
+            ->whereNotNull('responded_at')
+            ->findOrFail($id);
+        return view('site.contact.admin-response', compact('contact'));
+    }
 }

+ 399 - 0
app/Http/Controllers/CuratedRegisterController.php

@@ -0,0 +1,399 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\User;
+use App\Models\CuratedRegister;
+use App\Models\CuratedRegisterActivity;
+use App\Services\EmailService;
+use App\Services\BouncerService;
+use App\Util\Lexer\RestrictedNames;
+use App\Mail\CuratedRegisterConfirmEmail;
+use App\Mail\CuratedRegisterNotifyAdmin;
+use Illuminate\Support\Facades\Mail;
+use App\Jobs\CuratedOnboarding\CuratedOnboardingNotifyAdminNewApplicationPipeline;
+
+class CuratedRegisterController extends Controller
+{
+    public function __construct()
+    {
+        abort_unless((bool) config_cache('instance.curated_registration.enabled'), 404);
+
+        if((bool) config_cache('pixelfed.open_registration')) {
+            abort_if(config('instance.curated_registration.state.only_enabled_on_closed_reg'), 404);
+        } else {
+            abort_unless(config('instance.curated_registration.state.fallback_on_closed_reg'), 404);
+        }
+    }
+
+    public function index(Request $request)
+    {
+        abort_if($request->user(), 404);
+        return view('auth.curated-register.index', ['step' => 1]);
+    }
+
+    public function concierge(Request $request)
+    {
+        abort_if($request->user(), 404);
+        $emailConfirmed = $request->session()->has('cur-reg-con.email-confirmed') &&
+            $request->has('next') &&
+            $request->session()->has('cur-reg-con.cr-id');
+        return view('auth.curated-register.concierge', compact('emailConfirmed'));
+    }
+
+    public function conciergeResponseSent(Request $request)
+    {
+        return view('auth.curated-register.user_response_sent');
+    }
+
+    public function conciergeFormShow(Request $request)
+    {
+        abort_if($request->user(), 404);
+        abort_unless(
+            $request->session()->has('cur-reg-con.email-confirmed') &&
+            $request->session()->has('cur-reg-con.cr-id') &&
+            $request->session()->has('cur-reg-con.ac-id'), 404);
+        $crid = $request->session()->get('cur-reg-con.cr-id');
+        $arid = $request->session()->get('cur-reg-con.ac-id');
+        $showCaptcha = config('instance.curated_registration.captcha_enabled');
+        if($attempts = $request->session()->get('cur-reg-con-attempt')) {
+            $showCaptcha = $attempts && $attempts >= 2;
+        } else {
+            $showCaptcha = false;
+        }
+        $activity = CuratedRegisterActivity::whereRegisterId($crid)->whereFromAdmin(true)->findOrFail($arid);
+        return view('auth.curated-register.concierge_form', compact('activity', 'showCaptcha'));
+    }
+
+    public function conciergeFormStore(Request $request)
+    {
+        abort_if($request->user(), 404);
+        $request->session()->increment('cur-reg-con-attempt');
+        abort_unless(
+            $request->session()->has('cur-reg-con.email-confirmed') &&
+            $request->session()->has('cur-reg-con.cr-id') &&
+            $request->session()->has('cur-reg-con.ac-id'), 404);
+        $attempts = $request->session()->get('cur-reg-con-attempt');
+        $messages = [];
+        $rules = [
+            'response' => 'required|string|min:5|max:1000',
+            'crid' => 'required|integer|min:1',
+            'acid' => 'required|integer|min:1'
+        ];
+        if(config('instance.curated_registration.captcha_enabled') && $attempts >= 3) {
+            $rules['h-captcha-response'] = 'required|captcha';
+            $messages['h-captcha-response.required'] = 'The captcha must be filled';
+        }
+        $this->validate($request, $rules, $messages);
+        $crid = $request->session()->get('cur-reg-con.cr-id');
+        $acid = $request->session()->get('cur-reg-con.ac-id');
+        abort_if((string) $crid !== $request->input('crid'), 404);
+        abort_if((string) $acid !== $request->input('acid'), 404);
+
+        if(CuratedRegisterActivity::whereRegisterId($crid)->whereReplyToId($acid)->exists()) {
+            return redirect()->back()->withErrors(['code' => 'You already replied to this request.']);
+        }
+
+        $act = CuratedRegisterActivity::create([
+            'register_id' => $crid,
+            'reply_to_id' => $acid,
+            'type' => 'user_response',
+            'message' => $request->input('response'),
+            'from_user' => true,
+            'action_required' => true,
+        ]);
+
+        CuratedRegister::findOrFail($crid)->update(['user_has_responded' => true]);
+        $request->session()->pull('cur-reg-con');
+        $request->session()->pull('cur-reg-con-attempt');
+
+        return view('auth.curated-register.user_response_sent');
+    }
+
+    public function conciergeStore(Request $request)
+    {
+        abort_if($request->user(), 404);
+        $rules = [
+            'sid' => 'required_if:action,email|integer|min:1|max:20000000',
+            'id' => 'required_if:action,email|integer|min:1|max:20000000',
+            'code' => 'required_if:action,email',
+            'action' => 'required|string|in:email,message',
+            'email' => 'required_if:action,email|email',
+            'response' => 'required_if:action,message|string|min:20|max:1000',
+        ];
+        $messages = [];
+        if(config('instance.curated_registration.captcha_enabled')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+            $messages['h-captcha-response.required'] = 'The captcha must be filled';
+        }
+        $this->validate($request, $rules, $messages);
+
+        $action = $request->input('action');
+        $sid = $request->input('sid');
+        $id = $request->input('id');
+        $code = $request->input('code');
+        $email = $request->input('email');
+
+        $cr = CuratedRegister::whereIsClosed(false)->findOrFail($sid);
+        $ac = CuratedRegisterActivity::whereRegisterId($cr->id)->whereFromAdmin(true)->findOrFail($id);
+
+        if(!hash_equals($ac->secret_code, $code)) {
+            return redirect()->back()->withErrors(['code' => 'Invalid code']);
+        }
+
+        if(!hash_equals($cr->email, $email)) {
+            return redirect()->back()->withErrors(['email' => 'Invalid email']);
+        }
+
+        $request->session()->put('cur-reg-con.email-confirmed', true);
+        $request->session()->put('cur-reg-con.cr-id', $cr->id);
+        $request->session()->put('cur-reg-con.ac-id', $ac->id);
+        $emailConfirmed = true;
+        return redirect('/auth/sign_up/concierge/form');
+    }
+
+    public function confirmEmail(Request $request)
+    {
+        if($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        return view('auth.curated-register.confirm_email');
+    }
+
+    public function emailConfirmed(Request $request)
+    {
+        if($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        return view('auth.curated-register.email_confirmed');
+    }
+
+    public function resendConfirmation(Request $request)
+    {
+        return view('auth.curated-register.resend-confirmation');
+    }
+
+    public function resendConfirmationProcess(Request $request)
+    {
+        $rules = [
+            'email' => [
+                'required',
+                'string',
+                app()->environment() === 'production' ? 'email:rfc,dns,spoof' : 'email',
+                'exists:curated_registers',
+            ]
+        ];
+
+        $messages = [];
+
+        if(config('instance.curated_registration.captcha_enabled')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+            $messages['h-captcha-response.required'] = 'The captcha must be filled';
+        }
+
+        $this->validate($request, $rules, $messages);
+
+        $cur = CuratedRegister::whereEmail($request->input('email'))->whereIsClosed(false)->first();
+        if(!$cur) {
+            return redirect()->back()->withErrors(['email' => 'The selected email is invalid.']);
+        }
+
+        $totalCount = CuratedRegisterActivity::whereRegisterId($cur->id)
+            ->whereType('user_resend_email_confirmation')
+            ->count();
+
+        if($totalCount && $totalCount >= config('instance.curated_registration.resend_confirmation_limit')) {
+            return redirect()->back()->withErrors(['email' => 'You have re-attempted too many times. To proceed with your application, please <a href="/site/contact" class="text-white" style="text-decoration: underline;">contact the admin team</a>.']);
+        }
+
+        $count = CuratedRegisterActivity::whereRegisterId($cur->id)
+            ->whereType('user_resend_email_confirmation')
+            ->where('created_at', '>', now()->subHours(12))
+            ->count();
+
+        if($count) {
+            return redirect()->back()->withErrors(['email' => 'You can only re-send the confirmation email once per 12 hours. Try again later.']);
+        }
+
+        CuratedRegisterActivity::create([
+            'register_id' => $cur->id,
+            'type' => 'user_resend_email_confirmation',
+            'admin_only_view' => true,
+            'from_admin' => false,
+            'from_user' => false,
+            'action_required' => false,
+        ]);
+
+        Mail::to($cur->email)->send(new CuratedRegisterConfirmEmail($cur));
+        return view('auth.curated-register.resent-confirmation');
+        return $request->all();
+    }
+
+    public function confirmEmailHandle(Request $request)
+    {
+        $rules = [
+            'sid' => 'required',
+            'code' => 'required'
+        ];
+        $messages = [];
+        if(config('instance.curated_registration.captcha_enabled')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+            $messages['h-captcha-response.required'] = 'The captcha must be filled';
+        }
+        $this->validate($request, $rules, $messages);
+
+        $cr = CuratedRegister::whereNull('email_verified_at')
+            ->where('created_at', '>', now()->subHours(24))
+            ->find($request->input('sid'));
+        if(!$cr) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        if(!hash_equals($cr->verify_code, $request->input('code'))) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        $cr->email_verified_at = now();
+        $cr->save();
+
+        if(config('instance.curated_registration.notify.admin.on_verify_email.enabled')) {
+            CuratedOnboardingNotifyAdminNewApplicationPipeline::dispatch($cr);
+        }
+        return view('auth.curated-register.email_confirmed');
+    }
+
+    public function proceed(Request $request)
+    {
+        $this->validate($request, [
+            'step' => 'required|integer|in:1,2,3,4'
+        ]);
+        $step = $request->input('step');
+
+        switch($step) {
+            case 1:
+                $step = 2;
+                $request->session()->put('cur-step', 1);
+                return view('auth.curated-register.index', compact('step'));
+            break;
+
+            case 2:
+                $this->stepTwo($request);
+                $step = 3;
+                $request->session()->put('cur-step', 2);
+                return view('auth.curated-register.index', compact('step'));
+            break;
+
+            case 3:
+                $this->stepThree($request);
+                $step = 3;
+                $request->session()->put('cur-step', 3);
+                $verifiedEmail = true;
+                $request->session()->pull('cur-reg');
+                return view('auth.curated-register.index', compact('step', 'verifiedEmail'));
+            break;
+        }
+    }
+
+    protected function stepTwo($request)
+    {
+        if($request->filled('reason')) {
+            $request->session()->put('cur-reg.form-reason', $request->input('reason'));
+        }
+        if($request->filled('username')) {
+            $request->session()->put('cur-reg.form-username', $request->input('username'));
+        }
+        if($request->filled('email')) {
+            $request->session()->put('cur-reg.form-email', $request->input('email'));
+        }
+        $this->validate($request, [
+            'username' => [
+                'required',
+                'min:2',
+                'max:15',
+                'unique:curated_registers',
+                'unique:users',
+                function ($attribute, $value, $fail) {
+                    $dash = substr_count($value, '-');
+                    $underscore = substr_count($value, '_');
+                    $period = substr_count($value, '.');
+
+                    if(ends_with($value, ['.php', '.js', '.css'])) {
+                        return $fail('Username is invalid.');
+                    }
+
+                    if(($dash + $underscore + $period) > 1) {
+                        return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+                    }
+
+                    if (!ctype_alnum($value[0])) {
+                        return $fail('Username is invalid. Must start with a letter or number.');
+                    }
+
+                    if (!ctype_alnum($value[strlen($value) - 1])) {
+                        return $fail('Username is invalid. Must end with a letter or number.');
+                    }
+
+                    $val = str_replace(['_', '.', '-'], '', $value);
+                    if(!ctype_alnum($val)) {
+                        return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+                    }
+
+                    $restricted = RestrictedNames::get();
+                    if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+                        return $fail('Username cannot be used.');
+                    }
+                },
+            ],
+            'email' => [
+                'required',
+                'string',
+                app()->environment() === 'production' ? 'email:rfc,dns,spoof' : 'email',
+                'max:255',
+                'unique:users',
+                'unique:curated_registers',
+                function ($attribute, $value, $fail) {
+                    $banned = EmailService::isBanned($value);
+                    if($banned) {
+                        return $fail('Email is invalid.');
+                    }
+                },
+            ],
+            'password' => 'required|min:8',
+            'password_confirmation' => 'required|same:password',
+            'reason' => 'required|min:20|max:1000',
+            'agree' => 'required|accepted'
+        ]);
+        $request->session()->put('cur-reg.form-email', $request->input('email'));
+        $request->session()->put('cur-reg.form-password', $request->input('password'));
+    }
+
+    protected function stepThree($request)
+    {
+        $this->validate($request, [
+            'email' => [
+                'required',
+                'string',
+                app()->environment() === 'production' ? 'email:rfc,dns,spoof' : 'email',
+                'max:255',
+                'unique:users',
+                'unique:curated_registers',
+                function ($attribute, $value, $fail) {
+                    $banned = EmailService::isBanned($value);
+                    if($banned) {
+                        return $fail('Email is invalid.');
+                    }
+                },
+            ]
+        ]);
+        $cr = new CuratedRegister;
+        $cr->email = $request->email;
+        $cr->username = $request->session()->get('cur-reg.form-username');
+        $cr->password = bcrypt($request->session()->get('cur-reg.form-password'));
+        $cr->ip_address = $request->ip();
+        $cr->reason_to_join = $request->session()->get('cur-reg.form-reason');
+        $cr->verify_code = Str::random(40);
+        $cr->save();
+
+        Mail::to($cr->email)->send(new CuratedRegisterConfirmEmail($cr));
+    }
+}

+ 899 - 847
app/Http/Controllers/DirectMessageController.php

@@ -2,857 +2,909 @@
 
 namespace App\Http\Controllers;
 
-use Auth, Cache;
-use Illuminate\Http\Request;
-use App\{
-	DirectMessage,
-	Media,
-	Notification,
-	Profile,
-	Status,
-	User,
-	UserFilter,
-	UserSetting
-};
-use App\Services\MediaPathService;
-use App\Services\MediaBlocklistService;
-use App\Jobs\StatusPipeline\NewStatusPipeline;
-use Illuminate\Support\Str;
-use App\Util\ActivityPub\Helpers;
+use App\DirectMessage;
+use App\Jobs\DirectPipeline\DirectDeletePipeline;
+use App\Jobs\DirectPipeline\DirectDeliverPipeline;
+use App\Jobs\StatusPipeline\StatusDelete;
+use App\Media;
+use App\Models\Conversation;
+use App\Notification;
+use App\Profile;
 use App\Services\AccountService;
+use App\Services\MediaBlocklistService;
+use App\Services\MediaPathService;
+use App\Services\MediaService;
 use App\Services\StatusService;
+use App\Services\UserFilterService;
+use App\Services\UserRoleService;
+use App\Services\UserStorageService;
 use App\Services\WebfingerService;
-use App\Models\Conversation;
+use App\Status;
+use App\UserFilter;
+use App\Util\ActivityPub\Helpers;
+use App\Util\Lexer\Autolink;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 
 class DirectMessageController extends Controller
 {
-	public function __construct()
-	{
-		$this->middleware('auth');
-	}
-
-	public function browse(Request $request)
-	{
-		$this->validate($request, [
-			'a' => 'nullable|string|in:inbox,sent,filtered',
-			'page' => 'nullable|integer|min:1|max:99'
-		]);
-
-		$profile = $request->user()->profile_id;
-		$action = $request->input('a', 'inbox');
-		$page = $request->input('page');
-
-		if(config('database.default') == 'pgsql') {
-			if($action == 'inbox') {
-				$dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-				->whereToId($profile)
-				->with(['author','status'])
-				->whereIsHidden(false)
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->latest()
-				->get()
-				->unique('from_id')
-				->take(8)
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				})->values();
-			}
-
-			if($action == 'sent') {
-				$dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-				->whereFromId($profile)
-				->with(['author','status'])
-				->orderBy('id', 'desc')
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->get()
-				->unique('to_id')
-				->take(8)
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				});
-			}
-
-			if($action == 'filtered') {
-				$dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-				->whereToId($profile)
-				->with(['author','status'])
-				->whereIsHidden(true)
-				->orderBy('id', 'desc')
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->get()
-				->unique('from_id')
-				->take(8)
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				});
-			}
-		} elseif(config('database.default') == 'mysql') {
-			if($action == 'inbox') {
-				$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-				->whereToId($profile)
-				->with(['author','status'])
-				->whereIsHidden(false)
-				->groupBy('from_id')
-				->latest()
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->limit(8)
-				->get()
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				});
-			}
-
-			if($action == 'sent') {
-				$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-				->whereFromId($profile)
-				->with(['author','status'])
-				->groupBy('to_id')
-				->orderBy('createdAt', 'desc')
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->limit(8)
-				->get()
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				});
-			}
-
-			if($action == 'filtered') {
-				$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-				->whereToId($profile)
-				->with(['author','status'])
-				->whereIsHidden(true)
-				->groupBy('from_id')
-				->orderBy('createdAt', 'desc')
-				->when($page, function($q, $page) {
-					if($page > 1) {
-						return $q->offset($page * 8 - 8);
-					}
-				})
-				->limit(8)
-				->get()
-				->map(function($r) use($profile) {
-					return $r->from_id !== $profile ? [
-						'id' => (string) $r->from_id,
-						'name' => $r->author->name,
-						'username' => $r->author->username,
-						'avatar' => $r->author->avatarUrl(),
-						'url' => $r->author->url(),
-						'isLocal' => (bool) !$r->author->domain,
-						'domain' => $r->author->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					] : [
-						'id' => (string) $r->to_id,
-						'name' => $r->recipient->name,
-						'username' => $r->recipient->username,
-						'avatar' => $r->recipient->avatarUrl(),
-						'url' => $r->recipient->url(),
-						'isLocal' => (bool) !$r->recipient->domain,
-						'domain' => $r->recipient->domain,
-						'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-						'lastMessage' => $r->status->caption,
-						'messages' => []
-					];
-				});
-			}
-		}
-
-		return response()->json($dms->all());
-	}
-
-	public function create(Request $request)
-	{
-		$this->validate($request, [
-			'to_id' => 'required',
-			'message' => 'required|string|min:1|max:500',
-			'type'  => 'required|in:text,emoji'
-		]);
-
-		$profile = $request->user()->profile;
-		$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
-
-		abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
-		$msg = $request->input('message');
-
-		if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
-			if($recipient->follows($profile) == true) {
-				$hidden = false;
-			} else {
-				$hidden = true;
-			}
-		} else {
-			$hidden = false;
-		}
-
-		$status = new Status;
-		$status->profile_id = $profile->id;
-		$status->caption = $msg;
-		$status->rendered = $msg;
-		$status->visibility = 'direct';
-		$status->scope = 'direct';
-		$status->in_reply_to_profile_id = $recipient->id;
-		$status->save();
-
-		$dm = new DirectMessage;
-		$dm->to_id = $recipient->id;
-		$dm->from_id = $profile->id;
-		$dm->status_id = $status->id;
-		$dm->is_hidden = $hidden;
-		$dm->type = $request->input('type');
-		$dm->save();
-
-		Conversation::updateOrInsert(
-			[
-				'to_id' => $recipient->id,
-				'from_id' => $profile->id
-			],
-			[
-				'type' => $dm->type,
-				'status_id' => $status->id,
-				'dm_id' => $dm->id,
-				'is_hidden' => $hidden
-			]
-		);
-
-		if(filter_var($msg, FILTER_VALIDATE_URL)) {
-			if(Helpers::validateUrl($msg)) {
-				$dm->type = 'link';
-				$dm->meta = [
-					'domain' => parse_url($msg, PHP_URL_HOST),
-					'local' => parse_url($msg, PHP_URL_HOST) ==
-					parse_url(config('app.url'), PHP_URL_HOST)
-				];
-				$dm->save();
-			}
-		}
-
-		$nf = UserFilter::whereUserId($recipient->id)
-		->whereFilterableId($profile->id)
-		->whereFilterableType('App\Profile')
-		->whereFilterType('dm.mute')
-		->exists();
-
-		if($recipient->domain == null && $hidden == false && !$nf) {
-			$notification = new Notification();
-			$notification->profile_id = $recipient->id;
-			$notification->actor_id = $profile->id;
-			$notification->action = 'dm';
-			$notification->item_id = $dm->id;
-			$notification->item_type = "App\DirectMessage";
-			$notification->save();
-		}
-
-		if($recipient->domain) {
-			$this->remoteDeliver($dm);
-		}
-
-		$res = [
-			'id' => (string) $dm->id,
-			'isAuthor' => $profile->id == $dm->from_id,
-			'reportId' => (string) $dm->status_id,
-			'hidden' => (bool) $dm->is_hidden,
-			'type'  => $dm->type,
-			'text' => $dm->status->caption,
-			'media' => null,
-			'timeAgo' => $dm->created_at->diffForHumans(null,null,true),
-			'seen' => $dm->read_at != null,
-			'meta' => $dm->meta
-		];
-
-		return response()->json($res);
-	}
-
-	public function thread(Request $request)
-	{
-		$this->validate($request, [
-			'pid' => 'required'
-		]);
-		$uid = $request->user()->profile_id;
-		$pid = $request->input('pid');
-		$max_id = $request->input('max_id');
-		$min_id = $request->input('min_id');
-
-		$r = Profile::findOrFail($pid);
-
-		if($min_id) {
-			$res = DirectMessage::select('*')
-			->where('id', '>', $min_id)
-			->where(function($q) use($pid,$uid) {
-				return $q->where([['from_id',$pid],['to_id',$uid]
-			])->orWhere([['from_id',$uid],['to_id',$pid]]);
-			})
-			->latest()
-			->take(8)
-			->get();
-		} else if ($max_id) {
-			$res = DirectMessage::select('*')
-			->where('id', '<', $max_id)
-			->where(function($q) use($pid,$uid) {
-				return $q->where([['from_id',$pid],['to_id',$uid]
-			])->orWhere([['from_id',$uid],['to_id',$pid]]);
-			})
-			->latest()
-			->take(8)
-			->get();
-		} else {
-			$res = DirectMessage::where(function($q) use($pid,$uid) {
-				return $q->where([['from_id',$pid],['to_id',$uid]
-			])->orWhere([['from_id',$uid],['to_id',$pid]]);
-			})
-			->latest()
-			->take(8)
-			->get();
-		}
-
-		$res = $res->filter(function($s) {
-			return $s && $s->status;
-		})
-		->map(function($s) use ($uid) {
-			return [
-				'id' => (string) $s->id,
-				'hidden' => (bool) $s->is_hidden,
-				'isAuthor' => $uid == $s->from_id,
-				'type'  => $s->type,
-				'text' => $s->status->caption,
-				'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
-				'timeAgo' => $s->created_at->diffForHumans(null,null,true),
-				'seen' => $s->read_at != null,
-				'reportId' => (string) $s->status_id,
-				'meta' => json_decode($s->meta,true)
-			];
-		})
-		->values();
-
-		$w = [
-			'id' => (string) $r->id,
-			'name' => $r->name,
-			'username' => $r->username,
-			'avatar' => $r->avatarUrl(),
-			'url' => $r->url(),
-			'muted' => UserFilter::whereUserId($uid)
-				->whereFilterableId($r->id)
-				->whereFilterableType('App\Profile')
-				->whereFilterType('dm.mute')
-				->first() ? true : false,
-			'isLocal' => (bool) !$r->domain,
-			'domain' => $r->domain,
-			'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-			'lastMessage' => '',
-			'messages' => $res
-		];
-
-		return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function delete(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required'
-		]);
-
-		$sid = $request->input('id');
-		$pid = $request->user()->profile_id;
-
-		$dm = DirectMessage::whereFromId($pid)
-			->whereStatusId($sid)
-			->firstOrFail();
-
-		$status = Status::whereProfileId($pid)
-			->findOrFail($dm->status_id);
-
-		$recipient = AccountService::get($dm->to_id);
-
-		if(!$recipient) {
-			return response('', 422);
-		}
-
-		if($recipient['local'] == false) {
-			$dmc = $dm;
-			$this->remoteDelete($dmc);
-		}
-
-		if(Conversation::whereStatusId($sid)->count()) {
-			$latest = DirectMessage::where(['from_id' => $dm->from_id, 'to_id' => $dm->to_id])
-				->orWhere(['to_id' => $dm->from_id, 'from_id' => $dm->to_id])
-				->latest()
-				->first();
-
-			if($latest->status_id == $sid) {
-				Conversation::where(['to_id' => $dm->from_id, 'from_id' => $dm->to_id])
-					->update([
-						'updated_at' => $latest->updated_at,
-						'status_id' => $latest->status_id,
-						'type' => $latest->type,
-						'is_hidden' => false
-					]);
-
-				Conversation::where(['to_id' => $dm->to_id, 'from_id' => $dm->from_id])
-					->update([
-						'updated_at' => $latest->updated_at,
-						'status_id' => $latest->status_id,
-						'type' => $latest->type,
-						'is_hidden' => false
-					]);
-			} else {
-				Conversation::where([
-					'status_id' => $sid,
-					'to_id' => $dm->from_id,
-					'from_id' => $dm->to_id
-				])->delete();
-
-				Conversation::where([
-					'status_id' => $sid,
-					'from_id' => $dm->from_id,
-					'to_id' => $dm->to_id
-				])->delete();
-			}
-		}
-
-		StatusService::del($status->id, true);
-
-		$status->delete();
-		$dm->delete();
-
-		return [200];
-	}
-
-	public function get(Request $request, $id)
-	{
-		$pid = $request->user()->profile_id;
-		$dm = DirectMessage::whereStatusId($id)->firstOrFail();
-		abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404);
-		return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function mediaUpload(Request $request)
-	{
-		$this->validate($request, [
-			'file'      => function() {
-				return [
-					'required',
-					'mimetypes:' . config_cache('pixelfed.media_types'),
-					'max:' . config_cache('pixelfed.max_photo_size'),
-				];
-			},
-			'to_id'     => 'required'
-		]);
-
-		$user = $request->user();
-		$profile = $user->profile;
-		$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
-		abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
-
-		if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
-			if($recipient->follows($profile) == true) {
-				$hidden = false;
-			} else {
-				$hidden = true;
-			}
-		} else {
-			$hidden = false;
-		}
-
-		if(config_cache('pixelfed.enforce_account_limit') == true) {
-			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
-				return Media::whereUserId($user->id)->sum('size') / 1000;
-			});
-			$limit = (int) config_cache('pixelfed.max_account_size');
-			if ($size >= $limit) {
-				abort(403, 'Account size limit reached.');
-			}
-		}
-		$photo = $request->file('file');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), $mimes) == false) {
-			abort(403, 'Invalid or unsupported mime type.');
-		}
-
-		$storagePath = MediaPathService::get($user, 2) . Str::random(8);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		$status = new Status;
-		$status->profile_id = $profile->id;
-		$status->caption = null;
-		$status->rendered = null;
-		$status->visibility = 'direct';
-		$status->scope = 'direct';
-		$status->in_reply_to_profile_id = $recipient->id;
-		$status->save();
-
-		$media = new Media();
-		$media->status_id = $status->id;
-		$media->profile_id = $profile->id;
-		$media->user_id = $user->id;
-		$media->media_path = $path;
-		$media->original_sha256 = $hash;
-		$media->size = $photo->getSize();
-		$media->mime = $photo->getMimeType();
-		$media->caption = null;
-		$media->filter_class = null;
-		$media->filter_name = null;
-		$media->save();
-
-		$dm = new DirectMessage;
-		$dm->to_id = $recipient->id;
-		$dm->from_id = $profile->id;
-		$dm->status_id = $status->id;
-		$dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo';
-		$dm->is_hidden = $hidden;
-		$dm->save();
-
-		Conversation::updateOrInsert(
-			[
-				'to_id' => $recipient->id,
-				'from_id' => $profile->id
-			],
-			[
-				'type' => $dm->type,
-				'status_id' => $status->id,
-				'dm_id' => $dm->id,
-				'is_hidden' => $hidden
-			]
-		);
-
-		if($recipient->domain) {
-			$this->remoteDeliver($dm);
-		}
-
-		return [
-			'id' => $dm->id,
-			'reportId' => (string) $dm->status_id,
-			'type' => $dm->type,
-			'url' => $media->url()
-		];
-	}
-
-	public function composeLookup(Request $request)
-	{
-		$this->validate($request, [
-			'q' => 'required|string|min:2|max:50',
-			'remote' => 'nullable',
-		]);
-
-		$q = $request->input('q');
-		$r = $request->input('remote', false);
-
-		if($r && !Str::of($q)->contains('.')) {
-			return [];
-		}
-
-		if($r && Helpers::validateUrl($q)) {
-			Helpers::profileFetch($q);
-		}
-
-		if(Str::of($q)->startsWith('@')) {
-			if(strlen($q) < 3) {
-				return [];
-			}
-			if(substr_count($q, '@') == 2) {
-				WebfingerService::lookup($q);
-			}
-			$q = mb_substr($q, 1);
-		}
-
-		$blocked = UserFilter::whereFilterableType('App\Profile')
-		->whereFilterType('block')
-		->whereFilterableId($request->user()->profile_id)
-		->pluck('user_id');
-
-		$blocked->push($request->user()->profile_id);
-
-		$results = Profile::select('id','domain','username')
-		->whereNotIn('id', $blocked)
-		->where('username','like','%'.$q.'%')
-		->orderBy('domain')
-		->limit(8)
-		->get()
-		->map(function($r) {
-			$acct = AccountService::get($r->id);
-			return [
-				'local' => (bool) !$r->domain,
-				'id' => (string) $r->id,
-				'name' => $r->username,
-				'privacy' => true,
-				'avatar' => $r->avatarUrl(),
-				'account' => $acct
-			];
-		});
-
-		return $results;
-	}
-
-	public function read(Request $request)
-	{
-		$this->validate($request, [
-			'pid' => 'required',
-			'sid' => 'required'
-		]);
-
-		$pid = $request->input('pid');
-		$sid = $request->input('sid');
-
-		$dms = DirectMessage::whereToId($request->user()->profile_id)
-		->whereFromId($pid)
-		->where('status_id', '>=', $sid)
-		->get();
-
-		$now = now();
-		foreach($dms as $dm) {
-			$dm->read_at = $now;
-			$dm->save();
-		}
-
-		return response()->json($dms->pluck('id'));
-	}
-
-	public function mute(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required'
-		]);
-
-		$fid = $request->input('id');
-		$pid = $request->user()->profile_id;
-
-		UserFilter::firstOrCreate(
-			[
-				'user_id' => $pid,
-				'filterable_id' => $fid,
-				'filterable_type' => 'App\Profile',
-				'filter_type' => 'dm.mute'
-			]
-		);
-
-		return [200];
-	}
-
-	public function unmute(Request $request)
-	{
-		$this->validate($request, [
-			'id' => 'required'
-		]);
-
-		$fid = $request->input('id');
-		$pid = $request->user()->profile_id;
-
-		$f = UserFilter::whereUserId($pid)
-		->whereFilterableId($fid)
-		->whereFilterableType('App\Profile')
-		->whereFilterType('dm.mute')
-		->firstOrFail();
-
-		$f->delete();
-
-		return [200];
-	}
-
-	public function remoteDeliver($dm)
-	{
-		$profile = $dm->author;
-		$url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url;
-
-		$tags = [
-			[
-				'type' => 'Mention',
-				'href' => $dm->recipient->permalink(),
-				'name' => $dm->recipient->emailUrl(),
-			]
-		];
-
-		$body = [
-			'@context' => [
-				'https://w3id.org/security/v1',
-				'https://www.w3.org/ns/activitystreams',
-			],
-			'id'                    => $dm->status->permalink(),
-			'type'                  => 'Create',
-			'actor'                 => $dm->status->profile->permalink(),
-			'published'             => $dm->status->created_at->toAtomString(),
-			'to'                    => [$dm->recipient->permalink()],
-			'cc'                    => [],
-			'object' => [
-				'id'                => $dm->status->url(),
-				'type'              => 'Note',
-				'summary'           => null,
-				'content'           => $dm->status->rendered ?? $dm->status->caption,
-				'inReplyTo'         => null,
-				'published'         => $dm->status->created_at->toAtomString(),
-				'url'               => $dm->status->url(),
-				'attributedTo'      => $dm->status->profile->permalink(),
-				'to'                => [$dm->recipient->permalink()],
-				'cc'                => [],
-				'sensitive'         => (bool) $dm->status->is_nsfw,
-				'attachment'        => $dm->status->media()->orderBy('order')->get()->map(function ($media) {
-					return [
-						'type'      => $media->activityVerb(),
-						'mediaType' => $media->mime,
-						'url'       => $media->url(),
-						'name'      => $media->caption,
-					];
-				})->toArray(),
-				'tag'               => $tags,
-			]
-		];
-
-		Helpers::sendSignedObject($profile, $url, $body);
-	}
-
-	public function remoteDelete($dm)
-	{
-		$profile = $dm->author;
-		$url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url;
-
-		$body = [
-			'@context' => [
-				'https://www.w3.org/ns/activitystreams',
-			],
-			'id' => $dm->status->permalink('#delete'),
-			'to' => [
-				'https://www.w3.org/ns/activitystreams#Public'
-			],
-			'type' => 'Delete',
-			'actor' => $dm->status->profile->permalink(),
-			'object' => [
-				'id' => $dm->status->url(),
-				'type' => 'Tombstone'
-			]
-		];
-
-		Helpers::sendSignedObject($profile, $url, $body);
-	}
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function browse(Request $request)
+    {
+        $this->validate($request, [
+            'a' => 'nullable|string|in:inbox,sent,filtered',
+            'page' => 'nullable|integer|min:1|max:99',
+        ]);
+
+        $user = $request->user();
+        if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
+            return [];
+        }
+        $profile = $user->profile_id;
+        $action = $request->input('a', 'inbox');
+        $page = $request->input('page');
+
+        if (config('database.default') == 'pgsql') {
+            if ($action == 'inbox') {
+                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->whereToId($profile)
+                    ->with(['author', 'status'])
+                    ->whereIsHidden(false)
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->latest()
+                    ->get()
+                    ->unique('from_id')
+                    ->take(8)
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    })->values();
+            }
+
+            if ($action == 'sent') {
+                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->whereFromId($profile)
+                    ->with(['author', 'status'])
+                    ->orderBy('id', 'desc')
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->get()
+                    ->unique('to_id')
+                    ->take(8)
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    });
+            }
+
+            if ($action == 'filtered') {
+                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->whereToId($profile)
+                    ->with(['author', 'status'])
+                    ->whereIsHidden(true)
+                    ->orderBy('id', 'desc')
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->get()
+                    ->unique('from_id')
+                    ->take(8)
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    });
+            }
+        } elseif (config('database.default') == 'mysql') {
+            if ($action == 'inbox') {
+                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+                    ->whereToId($profile)
+                    ->with(['author', 'status'])
+                    ->whereIsHidden(false)
+                    ->groupBy('from_id')
+                    ->latest()
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->limit(8)
+                    ->get()
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    });
+            }
+
+            if ($action == 'sent') {
+                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+                    ->whereFromId($profile)
+                    ->with(['author', 'status'])
+                    ->groupBy('to_id')
+                    ->orderBy('createdAt', 'desc')
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->limit(8)
+                    ->get()
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    });
+            }
+
+            if ($action == 'filtered') {
+                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+                    ->whereToId($profile)
+                    ->with(['author', 'status'])
+                    ->whereIsHidden(true)
+                    ->groupBy('from_id')
+                    ->orderBy('createdAt', 'desc')
+                    ->when($page, function ($q, $page) {
+                        if ($page > 1) {
+                            return $q->offset($page * 8 - 8);
+                        }
+                    })
+                    ->limit(8)
+                    ->get()
+                    ->map(function ($r) use ($profile) {
+                        return $r->from_id !== $profile ? [
+                            'id' => (string) $r->from_id,
+                            'name' => $r->author->name,
+                            'username' => $r->author->username,
+                            'avatar' => $r->author->avatarUrl(),
+                            'url' => $r->author->url(),
+                            'isLocal' => (bool) ! $r->author->domain,
+                            'domain' => $r->author->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ] : [
+                            'id' => (string) $r->to_id,
+                            'name' => $r->recipient->name,
+                            'username' => $r->recipient->username,
+                            'avatar' => $r->recipient->avatarUrl(),
+                            'url' => $r->recipient->url(),
+                            'isLocal' => (bool) ! $r->recipient->domain,
+                            'domain' => $r->recipient->domain,
+                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                            'lastMessage' => $r->status->caption,
+                            'messages' => [],
+                        ];
+                    });
+            }
+        }
+
+        return response()->json($dms->all());
+    }
+
+    public function create(Request $request)
+    {
+        $this->validate($request, [
+            'to_id' => 'required',
+            'message' => 'required|string|min:1|max:500',
+            'type' => 'required|in:text,emoji',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+        if (! $user->is_admin) {
+            abort_if($user->created_at->gt(now()->subHours(72)), 400, 'You need to wait a bit before you can DM another account');
+        }
+        $profile = $user->profile;
+        $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
+
+        abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
+        $msg = $request->input('message');
+
+        if ((! $recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
+            if ($recipient->follows($profile) == true) {
+                $hidden = false;
+            } else {
+                $hidden = true;
+            }
+        } else {
+            $hidden = false;
+        }
+
+        $status = new Status;
+        $status->profile_id = $profile->id;
+        $status->caption = $msg;
+        $status->visibility = 'direct';
+        $status->scope = 'direct';
+        $status->in_reply_to_profile_id = $recipient->id;
+        $status->save();
+
+        $dm = new DirectMessage;
+        $dm->to_id = $recipient->id;
+        $dm->from_id = $profile->id;
+        $dm->status_id = $status->id;
+        $dm->is_hidden = $hidden;
+        $dm->type = $request->input('type');
+        $dm->save();
+
+        Conversation::updateOrInsert(
+            [
+                'to_id' => $recipient->id,
+                'from_id' => $profile->id,
+            ],
+            [
+                'type' => $dm->type,
+                'status_id' => $status->id,
+                'dm_id' => $dm->id,
+                'is_hidden' => $hidden,
+            ]
+        );
+
+        if (filter_var($msg, FILTER_VALIDATE_URL)) {
+            if (Helpers::validateUrl($msg)) {
+                $dm->type = 'link';
+                $dm->meta = [
+                    'domain' => parse_url($msg, PHP_URL_HOST),
+                    'local' => parse_url($msg, PHP_URL_HOST) ==
+                    parse_url(config('app.url'), PHP_URL_HOST),
+                ];
+                $dm->save();
+            }
+        }
+
+        $nf = UserFilter::whereUserId($recipient->id)
+            ->whereFilterableId($profile->id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('dm.mute')
+            ->exists();
+
+        if ($recipient->domain == null && $hidden == false && ! $nf) {
+            $notification = new Notification;
+            $notification->profile_id = $recipient->id;
+            $notification->actor_id = $profile->id;
+            $notification->action = 'dm';
+            $notification->item_id = $dm->id;
+            $notification->item_type = "App\DirectMessage";
+            $notification->save();
+        }
+
+        if ($recipient->domain) {
+            $this->remoteDeliver($dm);
+        }
+
+        $res = [
+            'id' => (string) $dm->id,
+            'isAuthor' => $profile->id == $dm->from_id,
+            'reportId' => (string) $dm->status_id,
+            'hidden' => (bool) $dm->is_hidden,
+            'type' => $dm->type,
+            'text' => $dm->status->caption,
+            'media' => null,
+            'timeAgo' => $dm->created_at->diffForHumans(null, null, true),
+            'seen' => $dm->read_at != null,
+            'meta' => $dm->meta,
+        ];
+
+        return response()->json($res);
+    }
+
+    public function thread(Request $request)
+    {
+        $this->validate($request, [
+            'pid' => 'required',
+            'max_id' => 'sometimes|integer',
+            'min_id' => 'sometimes|integer',
+        ]);
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+
+        $uid = $user->profile_id;
+        $pid = $request->input('pid');
+        $max_id = $request->input('max_id');
+        $min_id = $request->input('min_id');
+
+        $r = Profile::findOrFail($pid);
+
+        if ($min_id) {
+            $res = DirectMessage::select('*')
+                ->where('id', '>', $min_id)
+                ->where(function ($query) use ($pid, $uid) {
+                    $query->where('from_id', $pid)->where('to_id', $uid);
+                })->orWhere(function ($query) use ($pid, $uid) {
+                    $query->where('from_id', $uid)->where('to_id', $pid);
+                })
+                ->orderBy('id', 'asc')
+                ->take(8)
+                ->get()
+                ->reverse();
+        } elseif ($max_id) {
+            $res = DirectMessage::select('*')
+                ->where('id', '<', $max_id)
+                ->where(function ($query) use ($pid, $uid) {
+                    $query->where('from_id', $pid)->where('to_id', $uid);
+                })->orWhere(function ($query) use ($pid, $uid) {
+                    $query->where('from_id', $uid)->where('to_id', $pid);
+                })
+                ->orderBy('id', 'desc')
+                ->take(8)
+                ->get();
+        } else {
+            $res = DirectMessage::where(function ($query) use ($pid, $uid) {
+                $query->where('from_id', $pid)->where('to_id', $uid);
+            })->orWhere(function ($query) use ($pid, $uid) {
+                $query->where('from_id', $uid)->where('to_id', $pid);
+            })
+                ->orderBy('id', 'desc')
+                ->take(8)
+                ->get();
+        }
+
+        $res = $res->filter(function ($s) {
+            return $s && $s->status;
+        })
+            ->map(function ($s) use ($uid) {
+                return [
+                    'id' => (string) $s->id,
+                    'hidden' => (bool) $s->is_hidden,
+                    'isAuthor' => $uid == $s->from_id,
+                    'type' => $s->type,
+                    'text' => $s->status->caption,
+                    'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
+                    'carousel' => MediaService::get($s->status_id),
+                    'created_at' => $s->created_at->format('c'),
+                    'timeAgo' => $s->created_at->diffForHumans(null, null, true),
+                    'seen' => $s->read_at != null,
+                    'reportId' => (string) $s->status_id,
+                    'meta' => json_decode($s->meta, true),
+                ];
+            })
+            ->values();
+
+        $filters = UserFilterService::mutes($uid);
+
+        $w = [
+            'id' => (string) $r->id,
+            'name' => $r->name,
+            'username' => $r->username,
+            'avatar' => $r->avatarUrl(),
+            'url' => $r->url(),
+            'muted' => in_array($r->id, $filters),
+            'isLocal' => (bool) ! $r->domain,
+            'domain' => $r->domain,
+            'created_at' => $r->created_at->format('c'),
+            'updated_at' => $r->updated_at->format('c'),
+            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+            'lastMessage' => '',
+            'messages' => $res,
+        ];
+
+        return response()->json($w, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    }
+
+    public function delete(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $sid = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        $dm = DirectMessage::whereFromId($pid)
+            ->whereStatusId($sid)
+            ->firstOrFail();
+
+        $status = Status::whereProfileId($pid)
+            ->findOrFail($dm->status_id);
+
+        $recipient = AccountService::get($dm->to_id);
+
+        if (! $recipient) {
+            return response('', 422);
+        }
+
+        if ($recipient['local'] == false) {
+            $dmc = $dm;
+            $this->remoteDelete($dmc);
+        } else {
+            StatusDelete::dispatch($status)->onQueue('high');
+        }
+
+        if (Conversation::whereStatusId($sid)->count()) {
+            $latest = DirectMessage::where(['from_id' => $dm->from_id, 'to_id' => $dm->to_id])
+                ->orWhere(['to_id' => $dm->from_id, 'from_id' => $dm->to_id])
+                ->latest()
+                ->first();
+
+            if ($latest->status_id == $sid) {
+                Conversation::where(['to_id' => $dm->from_id, 'from_id' => $dm->to_id])
+                    ->update([
+                        'updated_at' => $latest->updated_at,
+                        'status_id' => $latest->status_id,
+                        'type' => $latest->type,
+                        'is_hidden' => false,
+                    ]);
+
+                Conversation::where(['to_id' => $dm->to_id, 'from_id' => $dm->from_id])
+                    ->update([
+                        'updated_at' => $latest->updated_at,
+                        'status_id' => $latest->status_id,
+                        'type' => $latest->type,
+                        'is_hidden' => false,
+                    ]);
+            } else {
+                Conversation::where([
+                    'status_id' => $sid,
+                    'to_id' => $dm->from_id,
+                    'from_id' => $dm->to_id,
+                ])->delete();
+
+                Conversation::where([
+                    'status_id' => $sid,
+                    'from_id' => $dm->from_id,
+                    'to_id' => $dm->to_id,
+                ])->delete();
+            }
+        }
+
+        StatusService::del($status->id, true);
+
+        $status->forceDeleteQuietly();
+
+        return [200];
+    }
+
+    public function get(Request $request, $id)
+    {
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+
+        $pid = $request->user()->profile_id;
+        $dm = DirectMessage::whereStatusId($id)->firstOrFail();
+        abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404);
+
+        return response()->json($dm, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    }
+
+    public function mediaUpload(Request $request)
+    {
+        $this->validate($request, [
+            'file' => function () {
+                return [
+                    'required',
+                    'mimetypes:'.config_cache('pixelfed.media_types'),
+                    'max:'.config_cache('pixelfed.max_photo_size'),
+                ];
+            },
+            'to_id' => 'required',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+        $profile = $user->profile;
+        $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
+        abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
+
+        if ((! $recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
+            if ($recipient->follows($profile) == true) {
+                $hidden = false;
+            } else {
+                $hidden = true;
+            }
+        } else {
+            $hidden = false;
+        }
+
+        $accountSize = UserStorageService::get($user->id);
+        abort_if($accountSize === -1, 403, 'Invalid request.');
+        $photo = $request->file('file');
+        $fileSize = $photo->getSize();
+        $sizeInKbs = (int) ceil($fileSize / 1000);
+        $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs;
+
+        if ((bool) config_cache('pixelfed.enforce_account_limit') == true) {
+            $limit = (int) config_cache('pixelfed.max_account_size');
+            if ($updatedAccountSize >= $limit) {
+                abort(403, 'Account size limit reached.');
+            }
+        }
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+        if (in_array($photo->getMimeType(), $mimes) == false) {
+            abort(403, 'Invalid or unsupported mime type.');
+        }
+
+        $storagePath = MediaPathService::get($user, 2).Str::random(8);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        $status = new Status;
+        $status->profile_id = $profile->id;
+        $status->caption = null;
+        $status->visibility = 'direct';
+        $status->scope = 'direct';
+        $status->in_reply_to_profile_id = $recipient->id;
+        $status->save();
+
+        $media = new Media;
+        $media->status_id = $status->id;
+        $media->profile_id = $profile->id;
+        $media->user_id = $user->id;
+        $media->media_path = $path;
+        $media->original_sha256 = $hash;
+        $media->size = $photo->getSize();
+        $media->mime = $photo->getMimeType();
+        $media->caption = null;
+        $media->filter_class = null;
+        $media->filter_name = null;
+        $media->save();
+
+        $dm = new DirectMessage;
+        $dm->to_id = $recipient->id;
+        $dm->from_id = $profile->id;
+        $dm->status_id = $status->id;
+        $dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo';
+        $dm->is_hidden = $hidden;
+        $dm->save();
+
+        Conversation::updateOrInsert(
+            [
+                'to_id' => $recipient->id,
+                'from_id' => $profile->id,
+            ],
+            [
+                'type' => $dm->type,
+                'status_id' => $status->id,
+                'dm_id' => $dm->id,
+                'is_hidden' => $hidden,
+            ]
+        );
+
+        $user->storage_used = (int) $updatedAccountSize;
+        $user->storage_used_updated_at = now();
+        $user->save();
+
+        if ($recipient->domain) {
+            $this->remoteDeliver($dm);
+        }
+
+        return [
+            'id' => $dm->id,
+            'reportId' => (string) $dm->status_id,
+            'type' => $dm->type,
+            'url' => $media->url(),
+        ];
+    }
+
+    public function composeLookup(Request $request)
+    {
+        $this->validate($request, [
+            'q' => 'required|string|min:2|max:50',
+            'remote' => 'nullable',
+        ]);
+
+        $user = $request->user();
+        if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
+            return [];
+        }
+
+        $q = $request->input('q');
+        $r = $request->input('remote', false);
+
+        if ($r && ! Str::of($q)->contains('.')) {
+            return [];
+        }
+
+        if ($r && Helpers::validateUrl($q)) {
+            Helpers::profileFetch($q);
+        }
+
+        if (Str::of($q)->startsWith('@')) {
+            if (strlen($q) < 3) {
+                return [];
+            }
+            if (substr_count($q, '@') == 2) {
+                WebfingerService::lookup($q);
+            }
+            $q = mb_substr($q, 1);
+        }
+
+        $blocked = UserFilter::whereFilterableType('App\Profile')
+            ->whereFilterType('block')
+            ->whereFilterableId($request->user()->profile_id)
+            ->pluck('user_id');
+
+        $blocked->push($request->user()->profile_id);
+
+        $results = Profile::select('id', 'domain', 'username')
+            ->whereNotIn('id', $blocked)
+            ->where('username', 'like', '%'.$q.'%')
+            ->orderBy('domain')
+            ->limit(8)
+            ->get()
+            ->map(function ($r) {
+                $acct = AccountService::get($r->id);
+
+                return [
+                    'local' => (bool) ! $r->domain,
+                    'id' => (string) $r->id,
+                    'name' => $r->username,
+                    'privacy' => true,
+                    'avatar' => $r->avatarUrl(),
+                    'account' => $acct,
+                ];
+            });
+
+        return $results;
+    }
+
+    public function read(Request $request)
+    {
+        $this->validate($request, [
+            'pid' => 'required',
+            'sid' => 'required',
+        ]);
+
+        $pid = $request->input('pid');
+        $sid = $request->input('sid');
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+
+        $dms = DirectMessage::whereToId($request->user()->profile_id)
+            ->whereFromId($pid)
+            ->where('status_id', '>=', $sid)
+            ->get();
+
+        $now = now();
+        foreach ($dms as $dm) {
+            $dm->read_at = $now;
+            $dm->save();
+        }
+
+        return response()->json($dms->pluck('id'));
+    }
+
+    public function mute(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+        $fid = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        UserFilter::firstOrCreate(
+            [
+                'user_id' => $pid,
+                'filterable_id' => $fid,
+                'filterable_type' => 'App\Profile',
+                'filter_type' => 'dm.mute',
+            ]
+        );
+
+        return [200];
+    }
+
+    public function unmute(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+
+        $fid = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        $f = UserFilter::whereUserId($pid)
+            ->whereFilterableId($fid)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('dm.mute')
+            ->firstOrFail();
+
+        $f->delete();
+
+        return [200];
+    }
+
+    public function remoteDeliver($dm)
+    {
+        $profile = $dm->author;
+        $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url;
+        $status = $dm->status;
+
+        if (! $status) {
+            return;
+        }
+
+        $tags = [
+            [
+                'type' => 'Mention',
+                'href' => $dm->recipient->permalink(),
+                'name' => $dm->recipient->emailUrl(),
+            ],
+        ];
+
+        $content = $status->caption ? Autolink::create()->autolink($status->caption) : null;
+
+        $body = [
+            '@context' => [
+                'https://w3id.org/security/v1',
+                'https://www.w3.org/ns/activitystreams',
+            ],
+            'id' => $dm->status->permalink(),
+            'type' => 'Create',
+            'actor' => $dm->status->profile->permalink(),
+            'published' => $dm->status->created_at->toAtomString(),
+            'to' => [$dm->recipient->permalink()],
+            'cc' => [],
+            'object' => [
+                'id' => $dm->status->url(),
+                'type' => 'Note',
+                'summary' => null,
+                'content' => $content,
+                'inReplyTo' => null,
+                'published' => $dm->status->created_at->toAtomString(),
+                'url' => $dm->status->url(),
+                'attributedTo' => $dm->status->profile->permalink(),
+                'to' => [$dm->recipient->permalink()],
+                'cc' => [],
+                'sensitive' => (bool) $dm->status->is_nsfw,
+                'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) {
+                    return [
+                        'type' => $media->activityVerb(),
+                        'mediaType' => $media->mime,
+                        'url' => $media->url(),
+                        'name' => $media->caption,
+                    ];
+                })->toArray(),
+                'tag' => $tags,
+            ],
+        ];
+
+        DirectDeliverPipeline::dispatch($profile, $url, $body)->onQueue('high');
+    }
+
+    public function remoteDelete($dm)
+    {
+        $profile = $dm->author;
+        $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url;
+
+        $body = [
+            '@context' => [
+                'https://www.w3.org/ns/activitystreams',
+            ],
+            'id' => $dm->status->permalink('#delete'),
+            'to' => [
+                'https://www.w3.org/ns/activitystreams#Public',
+            ],
+            'type' => 'Delete',
+            'actor' => $dm->status->profile->permalink(),
+            'object' => [
+                'id' => $dm->status->url(),
+                'type' => 'Tombstone',
+            ],
+        ];
+        DirectDeletePipeline::dispatch($profile, $url, $body)->onQueue('high');
+    }
 }

+ 414 - 350
app/Http/Controllers/DiscoverController.php

@@ -2,366 +2,430 @@
 
 namespace App\Http\Controllers;
 
-use App\{
-	DiscoverCategory,
-	Follower,
-	Hashtag,
-	HashtagFollow,
-	Instance,
-	Like,
-	Profile,
-	Status,
-	StatusHashtag,
-	UserFilter
-};
-use Auth, DB, Cache;
-use Illuminate\Http\Request;
+use App\Hashtag;
+use App\Instance;
+use App\Like;
+use App\Services\AccountService;
+use App\Services\AdminShadowFilterService;
 use App\Services\BookmarkService;
 use App\Services\ConfigCacheService;
+use App\Services\FollowerService;
 use App\Services\HashtagService;
+use App\Services\Internal\BeagleService;
 use App\Services\LikeService;
 use App\Services\ReblogService;
-use App\Services\StatusHashtagService;
 use App\Services\SnowflakeService;
+use App\Services\StatusHashtagService;
 use App\Services\StatusService;
 use App\Services\TrendingHashtagService;
 use App\Services\UserFilterService;
+use App\Status;
+use Auth;
+use Cache;
+use DB;
+use Illuminate\Http\Request;
 
 class DiscoverController extends Controller
 {
-	public function home(Request $request)
-	{
-		abort_if(!Auth::check() && config('instance.discover.public') == false, 403);
-		return view('discover.home');
-	}
-
-	public function showTags(Request $request, $hashtag)
-	{
-			abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
-
-			$tag = Hashtag::whereName($hashtag)
-				->orWhere('slug', $hashtag)
-				->where('is_banned', '!=', true)
-				->firstOrFail();
-			$tagCount = StatusHashtagService::count($tag->id);
-			return view('discover.tags.show', compact('tag', 'tagCount'));
-	}
-
-	public function getHashtags(Request $request)
-	{
-		$user = $request->user();
-		abort_if(!config('instance.discover.tags.is_public') && !$user, 403);
-
-		$this->validate($request, [
-			'hashtag' => 'required|string|min:1|max:124',
-			'page' => 'nullable|integer|min:1|max:' . ($user ? 29 : 3)
-		]);
-
-		$page = $request->input('page') ?? '1';
-		$end = $page > 1 ? $page * 9 : 0;
-		$tag = $request->input('hashtag');
-
-		if(config('database.default') === 'pgsql') {
-			$hashtag = Hashtag::where('name', 'ilike', $tag)->firstOrFail();
-		} else {
-			$hashtag = Hashtag::whereName($tag)->firstOrFail();
-		}
-
-		if($hashtag->is_banned == true) {
-			return [];
-		}
-		if($user) {
-			$res['follows'] = HashtagService::isFollowing($user->profile_id, $hashtag->id);
-		}
-		$res['hashtag'] = [
-			'name' => $hashtag->name,
-			'url' => $hashtag->url()
-		];
-		if($user) {
-			$tags = StatusHashtagService::get($hashtag->id, $page, $end);
-			$res['tags'] = collect($tags)
-				->map(function($tag) use($user) {
-					$tag['status']['favourited'] = (bool) LikeService::liked($user->profile_id, $tag['status']['id']);
-					$tag['status']['reblogged'] = (bool) ReblogService::get($user->profile_id, $tag['status']['id']);
-					$tag['status']['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $tag['status']['id']);
-					return $tag;
-				})
-				->filter(function($tag) {
-					if(!StatusService::get($tag['status']['id'])) {
-						return false;
-					}
-					return true;
-				})
-				->values();
-		} else {
-			if($page != 1) {
-				$res['tags'] = [];
-				return $res;
-			}
-			$key = 'discover:tags:public_feed:' . $hashtag->id . ':page:' . $page;
-			$tags = Cache::remember($key, 43200, function() use($hashtag, $page, $end) {
-				return collect(StatusHashtagService::get($hashtag->id, $page, $end))
-					->filter(function($tag) {
-						if(!$tag['status']['local']) {
-							return false;
-						}
-						return true;
-					})
-					->values();
-			});
-			$res['tags'] = collect($tags)
-				->filter(function($tag) {
-					if(!StatusService::get($tag['status']['id'])) {
-						return false;
-					}
-					return true;
-				})
-				->values();
-		}
-		return $res;
-	}
-
-	public function profilesDirectory(Request $request)
-	{
-		return redirect('/')->with('statusRedirect', 'The Profile Directory is unavailable at this time.');
-	}
-
-	public function profilesDirectoryApi(Request $request)
-	{
-		return ['error' => 'Temporarily unavailable.'];
-	}
-
-	public function trendingApi(Request $request)
-	{
-		abort_if(config('instance.discover.public') == false && !$request->user(), 403);
-
-		$this->validate($request, [
-			'range' => 'nullable|string|in:daily,monthly,yearly',
-		]);
-
-		$range = $request->input('range');
-		$days = $range == 'monthly' ? 31 : ($range == 'daily' ? 1 : 365);
-		$ttls = [
-			1 => 1500,
-			31 => 14400,
-			365 => 86400
-		];
-		$key = ':api:discover:trending:v2.12:range:' . $days;
-
-		$ids = Cache::remember($key, $ttls[$days], function() use($days) {
-			$min_id = SnowflakeService::byDate(now()->subDays($days));
-			return DB::table('statuses')
-				->select(
-					'id',
-					'scope',
-					'type',
-					'is_nsfw',
-					'likes_count',
-					'created_at'
-				)
-				->where('id', '>', $min_id)
-				->whereNull('uri')
-				->whereScope('public')
-				->whereIn('type', [
-					'photo',
-					'photo:album',
-					'video'
-				])
-				->whereIsNsfw(false)
-				->orderBy('likes_count','desc')
-				->take(30)
-				->pluck('id');
-		});
-
-		$filtered = Auth::check() ? UserFilterService::filters(Auth::user()->profile_id) : [];
-
-		$res = $ids->map(function($s) {
-			return StatusService::get($s);
-		})->filter(function($s) use($filtered) {
-			return
-				$s &&
-				!in_array($s['account']['id'], $filtered) &&
-				isset($s['account']);
-		})->values();
-
-		return response()->json($res);
-	}
-
-	public function trendingHashtags(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$res = TrendingHashtagService::getTrending();
-		return $res;
-	}
-
-	public function trendingPlaces(Request $request)
-	{
-		return [];
-	}
-
-	public function myMemories(Request $request)
-	{
-		abort_if(!$request->user(), 404);
-		$pid = $request->user()->profile_id;
-		abort_if(!$this->config()['memories']['enabled'], 404);
-		$type = $request->input('type') ?? 'posts';
-
-		switch($type) {
-			case 'posts':
-				$res = Status::whereProfileId($pid)
-					->whereDay('created_at', date('d'))
-					->whereMonth('created_at', date('m'))
-					->whereYear('created_at', '!=', date('Y'))
-					->whereNull(['reblog_of_id', 'in_reply_to_id'])
-					->limit(20)
-					->pluck('id')
-					->map(function($id) {
-						return StatusService::get($id, false);
-					})
-					->filter(function($post) {
-						return $post && isset($post['account']);
-					})
-					->values();
-			break;
-
-			case 'liked':
-				$res = Like::whereProfileId($pid)
-					->whereDay('created_at', date('d'))
-					->whereMonth('created_at', date('m'))
-					->whereYear('created_at', '!=', date('Y'))
-					->orderByDesc('status_id')
-					->limit(20)
-					->pluck('status_id')
-					->map(function($id) {
-						$status = StatusService::get($id, false);
-						$status['favourited'] = true;
-						return $status;
-					})
-					->filter(function($post) {
-						return $post && isset($post['account']);
-					})
-					->values();
-			break;
-		}
-
-		return $res;
-	}
-
-	public function accountInsightsPopularPosts(Request $request)
-	{
-		abort_if(!$request->user(), 404);
-		$pid = $request->user()->profile_id;
-		abort_if(!$this->config()['insights']['enabled'], 404);
-		$posts = Cache::remember('pf:discover:metro2:accinsights:popular:' . $pid, 43200, function() use ($pid) {
-			return Status::whereProfileId($pid)
-			->whereNotNull('likes_count')
-			->orderByDesc('likes_count')
-			->limit(12)
-			->pluck('id')
-			->map(function($id) {
-				return StatusService::get($id, false);
-			})
-			->filter(function($post) {
-				return $post && isset($post['account']);
-			})
-			->values();
-		});
-
-		return $posts;
-	}
-
-	public function config()
-	{
-		$cc = ConfigCacheService::get('config.discover.features');
-		if($cc) {
-			return is_string($cc) ? json_decode($cc, true) : $cc;
-		}
-		return [
-			'hashtags' => [
-				'enabled' => false,
-			],
-			'memories' => [
-				'enabled' => false,
-			],
-			'insights' => [
-				'enabled' => false,
-			],
-			'friends' => [
-				'enabled' => false,
-			],
-			'server' => [
-				'enabled' => false,
-				'mode' => 'allowlist',
-				'domains' => []
-			]
-		];
-	}
-
-	public function serverTimeline(Request $request)
-	{
-		abort_if(!$request->user(), 404);
-		abort_if(!$this->config()['server']['enabled'], 404);
-		$pid = $request->user()->profile_id;
-		$domain = $request->input('domain');
-		$config = $this->config();
-		$domains = explode(',', $config['server']['domains']);
-		abort_unless(in_array($domain, $domains), 400);
-
-		$res = Status::whereNotNull('uri')
-			->where('uri', 'like', 'https://' . $domain . '%')
-			->whereNull(['in_reply_to_id', 'reblog_of_id'])
-			->orderByDesc('id')
-			->limit(12)
-			->pluck('id')
-			->map(function($id) {
-				return StatusService::get($id);
-			})
-			->filter(function($post) {
-				return $post && isset($post['account']);
-			})
-			->values();
-		return $res;
-	}
-
-	public function enabledFeatures(Request $request)
-	{
-		abort_if(!$request->user(), 404);
-		return $this->config();
-	}
-
-	public function updateFeatures(Request $request)
-	{
-		abort_if(!$request->user(), 404);
-		abort_if(!$request->user()->is_admin, 404);
-		$pid = $request->user()->profile_id;
-		$this->validate($request, [
-			'features.friends.enabled' => 'boolean',
-			'features.hashtags.enabled' => 'boolean',
-			'features.insights.enabled' => 'boolean',
-			'features.memories.enabled' => 'boolean',
-			'features.server.enabled' => 'boolean',
-		]);
-		$res = $request->input('features');
-		if($res['server'] && isset($res['server']['domains']) && !empty($res['server']['domains'])) {
-			$parts = explode(',', $res['server']['domains']);
-			$parts = array_filter($parts, function($v) {
-				$len = strlen($v);
-				$pos = strpos($v, '.');
-				$domain = trim($v);
-				if($pos == false || $pos == ($len + 1)) {
-					return false;
-				}
-				if(!Instance::whereDomain($domain)->exists()) {
-					return false;
-				}
-				return true;
-			});
-			$parts = array_slice($parts, 0, 10);
-			$d = implode(',', array_map('trim', $parts));
-			$res['server']['domains'] = $d;
-		}
-		ConfigCacheService::put('config.discover.features', json_encode($res));
-		return $res;
-	}
+    public function home(Request $request)
+    {
+        abort_if(! Auth::check() && config('instance.discover.public') == false, 403);
+
+        return view('discover.home');
+    }
+
+    public function showTags(Request $request, $hashtag)
+    {
+        if ($request->user()) {
+            return redirect('/i/web/hashtag/'.$hashtag.'?src=pd');
+        }
+        abort_if(! config('instance.discover.tags.is_public') && ! Auth::check(), 403);
+
+        $tag = Hashtag::whereName($hashtag)
+            ->orWhere('slug', $hashtag)
+            ->where('is_banned', '!=', true)
+            ->firstOrFail();
+        $tagCount = $tag->cached_count ?? 0;
+
+        return view('discover.tags.show', compact('tag', 'tagCount'));
+    }
+
+    public function getHashtags(Request $request)
+    {
+        $user = $request->user();
+        abort_if(! config('instance.discover.tags.is_public') && ! $user, 403);
+
+        $this->validate($request, [
+            'hashtag' => 'required|string|min:1|max:124',
+            'page' => 'nullable|integer|min:1|max:'.($user ? 29 : 3),
+        ]);
+
+        $page = $request->input('page') ?? '1';
+        $end = $page > 1 ? $page * 9 : 0;
+        $tag = $request->input('hashtag');
+
+        if (config('database.default') === 'pgsql') {
+            $hashtag = Hashtag::where('name', 'ilike', $tag)->firstOrFail();
+        } else {
+            $hashtag = Hashtag::whereName($tag)->firstOrFail();
+        }
+
+        if ($hashtag->is_banned == true) {
+            return [];
+        }
+        if ($user) {
+            $res['follows'] = HashtagService::isFollowing($user->profile_id, $hashtag->id);
+        }
+        $res['hashtag'] = [
+            'name' => $hashtag->name,
+            'url' => $hashtag->url(),
+        ];
+        if ($user) {
+            $tags = StatusHashtagService::get($hashtag->id, $page, $end);
+            $res['tags'] = collect($tags)
+                ->map(function ($tag) use ($user) {
+                    $tag['status']['favourited'] = (bool) LikeService::liked($user->profile_id, $tag['status']['id']);
+                    $tag['status']['reblogged'] = (bool) ReblogService::get($user->profile_id, $tag['status']['id']);
+                    $tag['status']['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $tag['status']['id']);
+
+                    return $tag;
+                })
+                ->filter(function ($tag) {
+                    if (! StatusService::get($tag['status']['id'])) {
+                        return false;
+                    }
+
+                    return true;
+                })
+                ->values();
+        } else {
+            if ($page != 1) {
+                $res['tags'] = [];
+
+                return $res;
+            }
+            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page;
+            $tags = Cache::remember($key, 43200, function () use ($hashtag, $page, $end) {
+                return collect(StatusHashtagService::get($hashtag->id, $page, $end))
+                    ->filter(function ($tag) {
+                        if (! $tag['status']['local']) {
+                            return false;
+                        }
+
+                        return true;
+                    })
+                    ->values();
+            });
+            $res['tags'] = collect($tags)
+                ->filter(function ($tag) {
+                    if (! StatusService::get($tag['status']['id'])) {
+                        return false;
+                    }
+
+                    return true;
+                })
+                ->values();
+        }
+
+        return $res;
+    }
+
+    public function profilesDirectory(Request $request)
+    {
+        return redirect('/')->with('statusRedirect', 'The Profile Directory is unavailable at this time.');
+    }
+
+    public function profilesDirectoryApi(Request $request)
+    {
+        return ['error' => 'Temporarily unavailable.'];
+    }
+
+    public function trendingApi(Request $request)
+    {
+        abort_if(config('instance.discover.public') == false && ! $request->user(), 403);
+
+        $this->validate($request, [
+            'range' => 'nullable|string|in:daily,monthly,yearly',
+        ]);
+
+        $range = $request->input('range');
+        $days = $range == 'monthly' ? 31 : ($range == 'daily' ? 1 : 365);
+        $ttls = [
+            1 => 1500,
+            31 => 14400,
+            365 => 86400,
+        ];
+        $key = ':api:discover:trending:v2.12:range:'.$days;
+
+        $ids = Cache::remember($key, $ttls[$days], function () use ($days) {
+            $min_id = SnowflakeService::byDate(now()->subDays($days));
+
+            return DB::table('statuses')
+                ->select(
+                    'id',
+                    'scope',
+                    'type',
+                    'is_nsfw',
+                    'likes_count',
+                    'created_at'
+                )
+                ->where('id', '>', $min_id)
+                ->whereNull('uri')
+                ->whereScope('public')
+                ->whereIn('type', [
+                    'photo',
+                    'photo:album',
+                    'video',
+                ])
+                ->whereIsNsfw(false)
+                ->orderBy('likes_count', 'desc')
+                ->take(30)
+                ->pluck('id');
+        });
+
+        $filtered = Auth::check() ? UserFilterService::filters(Auth::user()->profile_id) : [];
+
+        $res = $ids->map(function ($s) {
+            return StatusService::get($s);
+        })->filter(function ($s) use ($filtered) {
+            return
+                $s &&
+                ! in_array($s['account']['id'], $filtered) &&
+                isset($s['account']);
+        })->values();
+
+        return response()->json($res);
+    }
+
+    public function trendingHashtags(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $res = TrendingHashtagService::getTrending();
+
+        return $res;
+    }
+
+    public function trendingPlaces(Request $request)
+    {
+        return [];
+    }
+
+    public function myMemories(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+        $pid = $request->user()->profile_id;
+        abort_if(! $this->config()['memories']['enabled'], 404);
+        $type = $request->input('type') ?? 'posts';
+
+        switch ($type) {
+            case 'posts':
+                $res = Status::whereProfileId($pid)
+                    ->whereDay('created_at', date('d'))
+                    ->whereMonth('created_at', date('m'))
+                    ->whereYear('created_at', '!=', date('Y'))
+                    ->whereNull(['reblog_of_id', 'in_reply_to_id'])
+                    ->limit(20)
+                    ->pluck('id')
+                    ->map(function ($id) {
+                        return StatusService::get($id, false);
+                    })
+                    ->filter(function ($post) {
+                        return $post && isset($post['account']);
+                    })
+                    ->values();
+                break;
+
+            case 'liked':
+                $res = Like::whereProfileId($pid)
+                    ->whereDay('created_at', date('d'))
+                    ->whereMonth('created_at', date('m'))
+                    ->whereYear('created_at', '!=', date('Y'))
+                    ->orderByDesc('status_id')
+                    ->limit(20)
+                    ->pluck('status_id')
+                    ->map(function ($id) {
+                        $status = StatusService::get($id, false);
+                        $status['favourited'] = true;
+
+                        return $status;
+                    })
+                    ->filter(function ($post) {
+                        return $post && isset($post['account']);
+                    })
+                    ->values();
+                break;
+        }
+
+        return $res;
+    }
+
+    public function accountInsightsPopularPosts(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+        $pid = $request->user()->profile_id;
+        abort_if(! $this->config()['insights']['enabled'], 404);
+        $posts = Cache::remember('pf:discover:metro2:accinsights:popular:'.$pid, 43200, function () use ($pid) {
+            return Status::whereProfileId($pid)
+                ->whereNotNull('likes_count')
+                ->orderByDesc('likes_count')
+                ->limit(12)
+                ->pluck('id')
+                ->map(function ($id) {
+                    return StatusService::get($id, false);
+                })
+                ->filter(function ($post) {
+                    return $post && isset($post['account']);
+                })
+                ->values();
+        });
+
+        return $posts;
+    }
+
+    public function config()
+    {
+        $cc = ConfigCacheService::get('config.discover.features');
+        if ($cc) {
+            return is_string($cc) ? json_decode($cc, true) : $cc;
+        }
+
+        return [
+            'hashtags' => [
+                'enabled' => false,
+            ],
+            'memories' => [
+                'enabled' => false,
+            ],
+            'insights' => [
+                'enabled' => false,
+            ],
+            'friends' => [
+                'enabled' => false,
+            ],
+            'server' => [
+                'enabled' => false,
+                'mode' => 'allowlist',
+                'domains' => [],
+            ],
+        ];
+    }
+
+    public function serverTimeline(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+        abort_if(! $this->config()['server']['enabled'], 404);
+        $pid = $request->user()->profile_id;
+        $domain = $request->input('domain');
+        $config = $this->config();
+        $domains = explode(',', $config['server']['domains']);
+        abort_unless(in_array($domain, $domains), 400);
+
+        $res = Status::whereNotNull('uri')
+            ->where('uri', 'like', 'https://'.$domain.'%')
+            ->whereNull(['in_reply_to_id', 'reblog_of_id'])
+            ->orderByDesc('id')
+            ->limit(12)
+            ->pluck('id')
+            ->map(function ($id) {
+                return StatusService::get($id);
+            })
+            ->filter(function ($post) {
+                return $post && isset($post['account']);
+            })
+            ->values();
+
+        return $res;
+    }
+
+    public function enabledFeatures(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+
+        return $this->config();
+    }
+
+    public function updateFeatures(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+        abort_if(! $request->user()->is_admin, 404);
+        $pid = $request->user()->profile_id;
+        $this->validate($request, [
+            'features.friends.enabled' => 'boolean',
+            'features.hashtags.enabled' => 'boolean',
+            'features.insights.enabled' => 'boolean',
+            'features.memories.enabled' => 'boolean',
+            'features.server.enabled' => 'boolean',
+        ]);
+        $res = $request->input('features');
+        if ($res['server'] && isset($res['server']['domains']) && ! empty($res['server']['domains'])) {
+            $parts = explode(',', $res['server']['domains']);
+            $parts = array_filter($parts, function ($v) {
+                $len = strlen($v);
+                $pos = strpos($v, '.');
+                $domain = trim($v);
+                if ($pos == false || $pos == ($len + 1)) {
+                    return false;
+                }
+                if (! Instance::whereDomain($domain)->exists()) {
+                    return false;
+                }
+
+                return true;
+            });
+            $parts = array_slice($parts, 0, 10);
+            $d = implode(',', array_map('trim', $parts));
+            $res['server']['domains'] = $d;
+        }
+        ConfigCacheService::put('config.discover.features', json_encode($res));
+
+        return $res;
+    }
+
+    public function discoverAccountsPopular(Request $request)
+    {
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+
+        $ids = Cache::remember('api:v1.1:discover:accounts:popular', 14400, function () {
+            return DB::table('profiles')
+                ->where('is_private', false)
+                ->whereNull('status')
+                ->orderByDesc('profiles.followers_count')
+                ->limit(30)
+                ->get();
+        });
+        $filters = UserFilterService::filters($pid);
+        $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+        $ids = $ids->map(function ($profile) {
+            return AccountService::get($profile->id, true);
+        })
+            ->filter(function ($profile) {
+                return $profile && isset($profile['id'], $profile['locked']) && ! $profile['locked'];
+            })
+            ->filter(function ($profile) use ($pid) {
+                return $profile['id'] != $pid;
+            })
+            ->filter(function ($profile) use ($pid) {
+                return ! FollowerService::follows($pid, $profile['id'], true);
+            })
+            ->filter(function ($profile) use ($asf) {
+                return ! in_array($profile['id'], $asf);
+            })
+            ->filter(function ($profile) use ($filters) {
+                return ! in_array($profile['id'], $filters);
+            })
+            ->take(16)
+            ->values();
+
+        return response()->json($ids, 200, [], JSON_UNESCAPED_SLASHES);
+    }
+
+    public function discoverNetworkTrending(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+
+        return BeagleService::getDiscoverPosts();
+    }
 }

+ 293 - 254
app/Http/Controllers/FederationController.php

@@ -2,265 +2,304 @@
 
 namespace App\Http\Controllers;
 
-use App\Jobs\InboxPipeline\{
-	DeleteWorker,
-	InboxWorker,
-	InboxValidator
-};
-use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
-use App\{
-	AccountLog,
-	Like,
-	Profile,
-	Status,
-	User
-};
+use App\Jobs\InboxPipeline\DeleteWorker;
+use App\Jobs\InboxPipeline\InboxValidator;
+use App\Jobs\InboxPipeline\InboxWorker;
+use App\Profile;
+use App\Services\AccountService;
+use App\Services\InstanceService;
+use App\Status;
 use App\Util\Lexer\Nickname;
+use App\Util\Site\Nodeinfo;
 use App\Util\Webfinger\Webfinger;
-use Auth;
 use Cache;
-use Carbon\Carbon;
 use Illuminate\Http\Request;
-use League\Fractal;
-use App\Util\Site\Nodeinfo;
-use App\Util\ActivityPub\{
-	Helpers,
-	HttpSignature,
-	Outbox
-};
-use Zttp\Zttp;
-use App\Services\InstanceService;
 
 class FederationController extends Controller
 {
-	public function nodeinfoWellKnown()
-	{
-		abort_if(!config('federation.nodeinfo.enabled'), 404);
-		return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES)
-			->header('Access-Control-Allow-Origin','*');
-	}
-
-	public function nodeinfo()
-	{
-		abort_if(!config('federation.nodeinfo.enabled'), 404);
-		return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES)
-			->header('Access-Control-Allow-Origin','*');
-	}
-
-	public function webfinger(Request $request)
-	{
-		if (!config('federation.webfinger.enabled') ||
-			!$request->has('resource') ||
-			!$request->filled('resource')
-		) {
-			return response('', 400);
-		}
-
-		$resource = $request->input('resource');
-		$domain = config('pixelfed.domain.app');
-
-		if(config('federation.activitypub.sharedInbox') &&
-			$resource == 'acct:' . $domain . '@' . $domain) {
-			$res = [
-				'subject' => 'acct:' . $domain . '@' . $domain,
-				'aliases' => [
-					'https://' . $domain . '/i/actor'
-				],
-				'links' => [
-					[
-						'rel' => 'http://webfinger.net/rel/profile-page',
-						'type' => 'text/html',
-						'href' => 'https://' . $domain . '/site/kb/instance-actor'
-					],
-					[
-						'rel' => 'self',
-						'type' => 'application/activity+json',
-						'href' => 'https://' . $domain . '/i/actor'
-					]
-				]
-			];
-			return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
-		}
-		$hash = hash('sha256', $resource);
-		$key = 'federation:webfinger:sha256:' . $hash;
-		if($cached = Cache::get($key)) {
-			return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
-		}
-		if(strpos($resource, $domain) == false) {
-			return response('', 400);
-		}
-		$parsed = Nickname::normalizeProfileUrl($resource);
-		if(empty($parsed) || $parsed['domain'] !== $domain) {
-			return response('', 400);
-		}
-		$username = $parsed['username'];
-		$profile = Profile::whereNull('domain')->whereUsername($username)->first();
-		if(!$profile || $profile->status !== null) {
-			return response('', 400);
-		}
-		$webfinger = (new Webfinger($profile))->generate();
-		Cache::put($key, $webfinger, 1209600);
-
-		return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES)
-			->header('Access-Control-Allow-Origin','*');
-	}
-
-	public function hostMeta(Request $request)
-	{
-		abort_if(!config('federation.webfinger.enabled'), 404);
-
-		$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>';
-
-		return response($xml)->header('Content-Type', 'application/xrd+xml');
-	}
-
-	public function userOutbox(Request $request, $username)
-	{
-		abort_if(!config_cache('federation.activitypub.enabled'), 404);
-
-		if(!$request->wantsJson()) {
-			return redirect('/' . $username);
-		}
-
-		$res = [
-			'@context' => 'https://www.w3.org/ns/activitystreams',
-			'id' => 'https://' . config('pixelfed.domain.app') . '/users/' . $username . '/outbox',
-			'type' => 'OrderedCollection',
-			'totalItems' => 0,
-			'orderedItems' => []
-		];
-
-		return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
-	}
-
-	public function userInbox(Request $request, $username)
-	{
-		abort_if(!config_cache('federation.activitypub.enabled'), 404);
-		abort_if(!config('federation.activitypub.inbox'), 404);
-
-		$headers = $request->headers->all();
-		$payload = $request->getContent();
-		if(!$payload || empty($payload)) {
-			return;
-		}
-		$obj = json_decode($payload, true, 8);
-		if(!isset($obj['id'])) {
-			return;
-		}
-		$domain = parse_url($obj['id'], PHP_URL_HOST);
-		if(in_array($domain, InstanceService::getBannedDomains())) {
-			return;
-		}
-
-		if(isset($obj['type']) && $obj['type'] === 'Delete') {
-			if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
-				if($obj['object']['type'] === 'Person') {
-					if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
-						dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
-						return;
-					}
-				}
-
-				if($obj['object']['type'] === 'Tombstone') {
-					if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
-						dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
-						return;
-					}
-				}
-
-				if($obj['object']['type'] === 'Story') {
-					dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
-					return;
-				}
-			}
-			return;
-		} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
-			dispatch(new InboxValidator($username, $headers, $payload))->onQueue('follow');
-		} else {
-			dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
-		}
-		return;
-	}
-
-	public function sharedInbox(Request $request)
-	{
-		abort_if(!config_cache('federation.activitypub.enabled'), 404);
-		abort_if(!config('federation.activitypub.sharedInbox'), 404);
-
-		$headers = $request->headers->all();
-		$payload = $request->getContent();
-
-		if(!$payload || empty($payload)) {
-			return;
-		}
-
-		$obj = json_decode($payload, true, 8);
-		if(!isset($obj['id'])) {
-			return;
-		}
-
-		$domain = parse_url($obj['id'], PHP_URL_HOST);
-		if(in_array($domain, InstanceService::getBannedDomains())) {
-			return;
-		}
-
-		if(isset($obj['type']) && $obj['type'] === 'Delete') {
-			if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
-				if($obj['object']['type'] === 'Person') {
-					if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
-						dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
-						return;
-					}
-				}
-
-				if($obj['object']['type'] === 'Tombstone') {
-					if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
-						dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
-						return;
-					}
-				}
-
-				if($obj['object']['type'] === 'Story') {
-					dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
-					return;
-				}
-			}
-			return;
-		} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
-			dispatch(new InboxWorker($headers, $payload))->onQueue('follow');
-		} else {
-			dispatch(new InboxWorker($headers, $payload))->onQueue('shared');
-		}
-		return;
-	}
-
-	public function userFollowing(Request $request, $username)
-	{
-		abort_if(!config_cache('federation.activitypub.enabled'), 404);
-
-		$obj = [
-			'@context' => 'https://www.w3.org/ns/activitystreams',
-			'id'       => $request->getUri(),
-			'type'     => 'OrderedCollectionPage',
-			'totalItems' => 0,
-			'orderedItems' => []
-		];
-		return response()->json($obj);
-	}
-
-	public function userFollowers(Request $request, $username)
-	{
-		abort_if(!config_cache('federation.activitypub.enabled'), 404);
-
-		$obj = [
-			'@context' => 'https://www.w3.org/ns/activitystreams',
-			'id'       => $request->getUri(),
-			'type'     => 'OrderedCollectionPage',
-			'totalItems' => 0,
-			'orderedItems' => []
-		];
-
-		return response()->json($obj);
-	}
+    public function nodeinfoWellKnown()
+    {
+        abort_if(! config('federation.nodeinfo.enabled'), 404);
+
+        return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES)
+            ->header('Access-Control-Allow-Origin', '*');
+    }
+
+    public function nodeinfo()
+    {
+        abort_if(! config('federation.nodeinfo.enabled'), 404);
+
+        return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES)
+            ->header('Access-Control-Allow-Origin', '*');
+    }
+
+    public function webfinger(Request $request)
+    {
+        if (! config('federation.webfinger.enabled') ||
+            ! $request->has('resource') ||
+            ! $request->filled('resource')
+        ) {
+            return response('', 400);
+        }
+
+        $resource = $request->input('resource');
+        $domain = config('pixelfed.domain.app');
+
+        // Instance Actor
+        if (
+            config('federation.activitypub.sharedInbox') &&
+            $resource == 'acct:'.$domain.'@'.$domain
+        ) {
+            $res = [
+                'subject' => 'acct:'.$domain.'@'.$domain,
+                'aliases' => [
+                    'https://'.$domain.'/i/actor',
+                ],
+                'links' => [
+                    [
+                        'rel' => 'http://webfinger.net/rel/profile-page',
+                        'type' => 'text/html',
+                        'href' => 'https://'.$domain.'/site/kb/instance-actor',
+                    ],
+                    [
+                        'rel' => 'self',
+                        'type' => 'application/activity+json',
+                        'href' => 'https://'.$domain.'/i/actor',
+                    ],
+                    [
+                        'rel' => 'http://ostatus.org/schema/1.0/subscribe',
+                        'template' => 'https://'.$domain.'/authorize_interaction?uri={uri}',
+                    ],
+                ],
+            ];
+
+            return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
+        }
+
+        if (str_starts_with($resource, 'https://')) {
+            if (str_starts_with($resource, 'https://'.$domain.'/users/')) {
+                $username = str_replace('https://'.$domain.'/users/', '', $resource);
+                if (strlen($username) > 15) {
+                    return response('', 400);
+                }
+                $stripped = str_replace(['_', '.', '-'], '', $username);
+                if (! ctype_alnum($stripped)) {
+                    return response('', 400);
+                }
+                $key = 'federation:webfinger:sha256:url-username:'.$username;
+                if ($cached = Cache::get($key)) {
+                    return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
+                }
+                $profile = Profile::whereUsername($username)->first();
+                if (! $profile || $profile->status !== null || $profile->domain) {
+                    return response('', 400);
+                }
+                $webfinger = (new Webfinger($profile))->generate();
+                Cache::put($key, $webfinger, 1209600);
+
+                return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES)
+                    ->header('Access-Control-Allow-Origin', '*');
+            } else {
+                return response('', 400);
+            }
+        }
+        $hash = hash('sha256', $resource);
+        $key = 'federation:webfinger:sha256:'.$hash;
+        if ($cached = Cache::get($key)) {
+            return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
+        }
+        if (strpos($resource, $domain) == false) {
+            return response('', 400);
+        }
+        $parsed = Nickname::normalizeProfileUrl($resource);
+        if (empty($parsed) || $parsed['domain'] !== $domain) {
+            return response('', 400);
+        }
+        $username = $parsed['username'];
+        $profile = Profile::whereUsername($username)->first();
+        if (! $profile || $profile->status !== null || $profile->domain) {
+            return response('', 400);
+        }
+        $webfinger = (new Webfinger($profile))->generate();
+        Cache::put($key, $webfinger, 1209600);
+
+        return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES)
+            ->header('Access-Control-Allow-Origin', '*');
+    }
+
+    public function hostMeta(Request $request)
+    {
+        abort_if(! config('federation.webfinger.enabled'), 404);
+
+        $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>';
+
+        return response($xml)->header('Content-Type', 'application/xrd+xml');
+    }
+
+    public function userOutbox(Request $request, $username)
+    {
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
+
+        if (! $request->wantsJson()) {
+            return redirect('/'.$username);
+        }
+
+        $id = AccountService::usernameToId($username);
+        abort_if(! $id, 404);
+        $account = AccountService::get($id);
+        abort_if(! $account || ! isset($account['statuses_count']), 404);
+        $res = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id' => 'https://'.config('pixelfed.domain.app').'/users/'.$username.'/outbox',
+            'type' => 'OrderedCollection',
+            'totalItems' => $account['statuses_count'] ?? 0,
+        ];
+
+        return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
+    }
+
+    public function userInbox(Request $request, $username)
+    {
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
+        abort_if(! config('federation.activitypub.inbox'), 404);
+
+        $headers = $request->headers->all();
+        $payload = $request->getContent();
+        if (! $payload || empty($payload)) {
+            return;
+        }
+        $obj = json_decode($payload, true, 8);
+        if (! isset($obj['id'])) {
+            return;
+        }
+        $domain = parse_url($obj['id'], PHP_URL_HOST);
+        if (in_array($domain, InstanceService::getBannedDomains())) {
+            return;
+        }
+
+        if (isset($obj['type']) && $obj['type'] === 'Delete') {
+            if (isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
+                if ($obj['object']['type'] === 'Person') {
+                    if (Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
+                        dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
+
+                        return;
+                    }
+                }
+
+                if ($obj['object']['type'] === 'Tombstone') {
+                    if (Status::whereObjectUrl($obj['object']['id'])->exists()) {
+                        dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
+
+                        return;
+                    }
+                }
+
+                if ($obj['object']['type'] === 'Story') {
+                    dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
+
+                    return;
+                }
+            }
+
+            return;
+        } elseif (isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
+            dispatch(new InboxValidator($username, $headers, $payload))->onQueue('follow');
+        } else {
+            dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
+        }
+
+    }
+
+    public function sharedInbox(Request $request)
+    {
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
+        abort_if(! config('federation.activitypub.sharedInbox'), 404);
+
+        $headers = $request->headers->all();
+        $payload = $request->getContent();
+
+        if (! $payload || empty($payload)) {
+            return;
+        }
+
+        $obj = json_decode($payload, true, 8);
+        if (! isset($obj['id'])) {
+            return;
+        }
+
+        $domain = parse_url($obj['id'], PHP_URL_HOST);
+        if (in_array($domain, InstanceService::getBannedDomains())) {
+            return;
+        }
+
+        if (isset($obj['type']) && $obj['type'] === 'Delete') {
+            if (isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
+                if ($obj['object']['type'] === 'Person') {
+                    if (Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
+                        dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
+
+                        return;
+                    }
+                }
+
+                if ($obj['object']['type'] === 'Tombstone') {
+                    if (Status::whereObjectUrl($obj['object']['id'])->exists()) {
+                        dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
+
+                        return;
+                    }
+                }
+
+                if ($obj['object']['type'] === 'Story') {
+                    dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
+
+                    return;
+                }
+            }
+
+            return;
+        } elseif (isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
+            dispatch(new InboxWorker($headers, $payload))->onQueue('follow');
+        } else {
+            dispatch(new InboxWorker($headers, $payload))->onQueue('shared');
+        }
+
+    }
+
+    public function userFollowing(Request $request, $username)
+    {
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
+
+        $id = AccountService::usernameToId($username);
+        abort_if(! $id, 404);
+        $account = AccountService::get($id);
+        abort_if(! $account || ! isset($account['following_count']), 404);
+        $obj = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id' => $request->getUri(),
+            'type' => 'OrderedCollection',
+            'totalItems' => $account['following_count'] ?? 0,
+        ];
+
+        return response()->json($obj)->header('Content-Type', 'application/activity+json');
+    }
+
+    public function userFollowers(Request $request, $username)
+    {
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
+        $id = AccountService::usernameToId($username);
+        abort_if(! $id, 404);
+        $account = AccountService::get($id);
+        abort_if(! $account || ! isset($account['followers_count']), 404);
+        $obj = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id' => $request->getUri(),
+            'type' => 'OrderedCollection',
+            'totalItems' => $account['followers_count'] ?? 0,
+        ];
+
+        return response()->json($obj)->header('Content-Type', 'application/activity+json');
+    }
 }

+ 671 - 0
app/Http/Controllers/GroupController.php

@@ -0,0 +1,671 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Instance;
+use App\Models\Group;
+use App\Models\GroupBlock;
+use App\Models\GroupCategory;
+use App\Models\GroupInvitation;
+use App\Models\GroupLike;
+use App\Models\GroupLimit;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupReport;
+use App\Profile;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\HashidService;
+use App\Services\StatusService;
+use App\Status;
+use App\User;
+use Illuminate\Http\Request;
+use Storage;
+
+class GroupController extends GroupFederationController
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+        abort_unless(config('groups.enabled'), 404);
+    }
+
+    public function index(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+
+        return view('layouts.spa');
+    }
+
+    public function home(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+
+        return view('layouts.spa');
+    }
+
+    public function show(Request $request, $id, $path = false)
+    {
+        $group = Group::find($id);
+
+        if (! $group || $group->status) {
+            return response()->view('groups.unavailable')->setStatusCode(404);
+        }
+
+        if ($request->wantsJson()) {
+            return $this->showGroupObject($group);
+        }
+
+        return view('layouts.spa', compact('id', 'path'));
+    }
+
+    public function showStatus(Request $request, $gid, $sid)
+    {
+        $group = Group::find($gid);
+        $pid = optional($request->user())->profile_id ?? false;
+
+        if (! $group || $group->status) {
+            return response()->view('groups.unavailable')->setStatusCode(404);
+        }
+
+        if ($group->is_private) {
+            abort_if(! $request->user(), 404);
+            abort_if(! $group->isMember($pid), 404);
+        }
+
+        $gp = GroupPost::whereGroupId($gid)
+            ->findOrFail($sid);
+
+        return view('layouts.spa', compact('group', 'gp'));
+    }
+
+    public function getGroup(Request $request, $id)
+    {
+        $group = Group::whereNull('status')->findOrFail($id);
+        $pid = optional($request->user())->profile_id ?? false;
+
+        $group = $this->toJson($group, $pid);
+
+        return response()->json($group, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    }
+
+    public function showStatusLikes(Request $request, $id, $sid)
+    {
+        $group = Group::findOrFail($id);
+        $user = $request->user();
+        $pid = $user->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        $status = GroupPost::whereGroupId($id)->findOrFail($sid);
+        $likes = GroupLike::whereStatusId($sid)
+            ->cursorPaginate(10)
+            ->map(function ($l) use ($group) {
+                $account = AccountService::get($l->profile_id);
+                $account['url'] = "/groups/{$group->id}/user/{$account['id']}";
+
+                return $account;
+            })
+            ->filter(function ($l) {
+                return $l && isset($l['id']);
+            })
+            ->values();
+
+        return $likes;
+    }
+
+    public function groupSettings(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        return view('groups.settings', compact('group'));
+    }
+
+    public function joinGroup(Request $request, $id)
+    {
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if($group->isMember($pid), 404);
+
+        if (! $request->user()->is_admin) {
+            abort_if(GroupService::getRejoinTimeout($group->id, $pid), 422, 'Cannot re-join this group for 24 hours after leaving or cancelling a request to join');
+        }
+
+        $member = new GroupMember;
+        $member->group_id = $group->id;
+        $member->profile_id = $pid;
+        $member->role = 'member';
+        $member->local_group = true;
+        $member->local_profile = true;
+        $member->join_request = $group->is_private;
+        $member->save();
+
+        GroupService::delSelf($group->id, $pid);
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:joined',
+            null,
+            GroupMember::class,
+            $member->id
+        );
+
+        $group = $this->toJson($group, $pid);
+
+        return $group;
+    }
+
+    public function updateGroup(Request $request, $id)
+    {
+        $this->validate($request, [
+            'description' => 'nullable|max:500',
+            'membership' => 'required|in:all,local,private',
+            'avatar' => 'nullable',
+            'header' => 'nullable',
+            'discoverable' => 'required',
+            'activitypub' => 'required',
+            'is_nsfw' => 'required',
+            'category' => 'required|string|in:'.implode(',', GroupService::categories()),
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $group = Group::whereProfileId($pid)->findOrFail($id);
+        $member = GroupMember::whereGroupId($group->id)->whereProfileId($pid)->firstOrFail();
+
+        abort_if($member->role != 'founder', 403, 'Invalid group permission');
+
+        $metadata = $group->metadata;
+        $len = $group->is_private ? 12 : 4;
+
+        if ($request->hasFile('avatar')) {
+            $avatar = $request->file('avatar');
+
+            if ($avatar) {
+                if (isset($metadata['avatar']) &&
+                    isset($metadata['avatar']['path']) &&
+                    Storage::exists($metadata['avatar']['path'])
+                ) {
+                    Storage::delete($metadata['avatar']['path']);
+                }
+
+                $fileName = 'avatar_'.strtolower(str_random($len)).'.'.$avatar->extension();
+                $path = $avatar->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
+                $url = url(Storage::url($path));
+                $metadata['avatar'] = [
+                    'path' => $path,
+                    'url' => $url,
+                    'updated_at' => now(),
+                ];
+            }
+        }
+
+        if ($request->hasFile('header')) {
+            $header = $request->file('header');
+
+            if ($header) {
+                if (isset($metadata['header']) &&
+                    isset($metadata['header']['path']) &&
+                    Storage::exists($metadata['header']['path'])
+                ) {
+                    Storage::delete($metadata['header']['path']);
+                }
+
+                $fileName = 'header_'.strtolower(str_random($len)).'.'.$header->extension();
+                $path = $header->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
+                $url = url(Storage::url($path));
+                $metadata['header'] = [
+                    'path' => $path,
+                    'url' => $url,
+                    'updated_at' => now(),
+                ];
+            }
+        }
+
+        $cat = GroupService::categoryById($group->category_id);
+        if ($request->category !== $cat['name']) {
+            $group->category_id = GroupCategory::whereName($request->category)->first()->id;
+        }
+
+        $changes = null;
+        $group->description = e($request->input('description', null));
+        $group->is_private = $request->input('membership') == 'private';
+        $group->local_only = $request->input('membership') == 'local';
+        $group->activitypub = $request->input('activitypub') == 'true';
+        $group->discoverable = $request->input('discoverable') == 'true';
+        $group->is_nsfw = $request->input('is_nsfw') == 'true';
+        $group->metadata = $metadata;
+        if ($group->isDirty()) {
+            $changes = $group->getDirty();
+        }
+        $group->save();
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:settings:updated',
+            $changes
+        );
+
+        GroupService::del($group->id);
+
+        $res = $this->toJson($group, $pid);
+
+        return $res;
+    }
+
+    protected function toJson($group, $pid = false)
+    {
+        return GroupService::get($group->id, $pid);
+    }
+
+    public function groupLeave(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($id);
+
+        abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
+
+        abort_if(! $group->isMember($pid), 403, 'Not a member of group.');
+
+        GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
+        GroupService::del($group->id);
+        GroupService::delSelf($group->id, $pid);
+        GroupService::setRejoinTimeout($group->id, $pid);
+
+        return [200];
+    }
+
+    public function cancelJoinRequest(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($id);
+
+        abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
+        abort_if($group->isMember($pid), 422, 'Cannot cancel approved join request, please leave group instead.');
+
+        GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
+        GroupService::del($group->id);
+        GroupService::delSelf($group->id, $pid);
+        GroupService::setRejoinTimeout($group->id, $pid);
+
+        return [200];
+    }
+
+    public function metaBlockSearch(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $type = $request->input('type');
+        $item = $request->input('item');
+
+        switch ($type) {
+            case 'instance':
+                $res = Instance::whereDomain($item)->first();
+                if ($res) {
+                    abort_if(GroupBlock::whereGroupId($group->id)->whereInstanceId($res->id)->exists(), 400);
+                }
+                break;
+
+            case 'user':
+                $res = Profile::whereUsername($item)->first();
+                if ($res) {
+                    abort_if(GroupBlock::whereGroupId($group->id)->whereProfileId($res->id)->exists(), 400);
+                }
+                if ($res->user_id != null) {
+                    abort_if(User::whereIsAdmin(true)->whereId($res->user_id)->exists(), 400);
+                }
+                break;
+        }
+
+        return response()->json((bool) $res, ($res ? 200 : 404));
+    }
+
+    public function reportCreate(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+
+        $id = $request->input('id');
+        $type = $request->input('type');
+        $types = [
+            // original 3
+            'spam',
+            'sensitive',
+            'abusive',
+
+            // new
+            'underage',
+            'violence',
+            'copyright',
+            'impersonation',
+            'scam',
+            'terrorism',
+        ];
+
+        $gp = GroupPost::whereGroupId($group->id)->find($id);
+        abort_if(! $gp, 422, 'Cannot report an invalid or deleted post');
+        abort_if(! in_array($type, $types), 422, 'Invalid report type');
+        abort_if($gp->profile_id === $pid, 422, 'Cannot report your own post');
+        abort_if(
+            GroupReport::whereGroupId($group->id)
+                ->whereProfileId($pid)
+                ->whereItemType(GroupPost::class)
+                ->whereItemId($id)
+                ->exists(),
+            422,
+            'You already reported this'
+        );
+
+        $report = new GroupReport();
+        $report->group_id = $group->id;
+        $report->profile_id = $pid;
+        $report->type = $type;
+        $report->item_type = GroupPost::class;
+        $report->item_id = $id;
+        $report->open = true;
+        $report->save();
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:report:create',
+            [
+                'type' => $type,
+                'report_id' => $report->id,
+                'status_id' => $gp->status_id,
+                'profile_id' => $gp->profile_id,
+                'username' => optional(AccountService::get($gp->profile_id))['acct'],
+                'gpid' => $gp->id,
+                'url' => $gp->url(),
+            ],
+            GroupReport::class,
+            $report->id
+        );
+
+        return response([200]);
+    }
+
+    public function reportAction(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $this->validate($request, [
+            'action' => 'required|in:cw,delete,ignore',
+            'id' => 'required|string',
+        ]);
+
+        $action = $request->input('action');
+        $id = $request->input('id');
+
+        $report = GroupReport::whereGroupId($group->id)
+            ->findOrFail($id);
+        $status = Status::findOrFail($report->item_id);
+        $gp = GroupPost::whereGroupId($group->id)
+            ->whereStatusId($status->id)
+            ->firstOrFail();
+
+        switch ($action) {
+            case 'cw':
+                $status->is_nsfw = true;
+                $status->save();
+                StatusService::del($status->id);
+
+                GroupReport::whereGroupId($group->id)
+                    ->whereItemType($report->item_type)
+                    ->whereItemId($report->item_id)
+                    ->update(['open' => false]);
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:moderation:action',
+                    [
+                        'type' => 'cw',
+                        'report_id' => $report->id,
+                        'status_id' => $status->id,
+                        'profile_id' => $status->profile_id,
+                        'status_url' => $gp->url(),
+                    ],
+                    GroupReport::class,
+                    $report->id
+                );
+
+                return response()->json([200]);
+                break;
+
+            case 'ignore':
+                GroupReport::whereGroupId($group->id)
+                    ->whereItemType($report->item_type)
+                    ->whereItemId($report->item_id)
+                    ->update(['open' => false]);
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:moderation:action',
+                    [
+                        'type' => 'ignore',
+                        'report_id' => $report->id,
+                        'status_id' => $status->id,
+                        'profile_id' => $status->profile_id,
+                        'status_url' => $gp->url(),
+                    ],
+                    GroupReport::class,
+                    $report->id
+                );
+
+                return response()->json([200]);
+                break;
+        }
+    }
+
+    public function getMemberInteractionLimits(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $profile_id = $request->input('profile_id');
+        abort_if(! $group->isMember($profile_id), 404);
+        $limits = GroupService::getInteractionLimits($group->id, $profile_id);
+
+        return response()->json($limits);
+    }
+
+    public function updateMemberInteractionLimits(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(! $group->isMember($pid), 404);
+        abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $this->validate($request, [
+            'profile_id' => 'required|exists:profiles,id',
+            'can_post' => 'required',
+            'can_comment' => 'required',
+            'can_like' => 'required',
+        ]);
+
+        $member = $request->input('profile_id');
+        $can_post = $request->input('can_post');
+        $can_comment = $request->input('can_comment');
+        $can_like = $request->input('can_like');
+        $account = AccountService::get($member);
+
+        abort_if(! $account, 422, 'Invalid profile');
+        abort_if(! $group->isMember($member), 422, 'Invalid profile');
+
+        $limit = GroupLimit::firstOrCreate([
+            'profile_id' => $member,
+            'group_id' => $group->id,
+        ]);
+
+        if ($limit->wasRecentlyCreated) {
+            abort_if(GroupLimit::whereGroupId($group->id)->count() >= 25, 422, 'limit_reached');
+        }
+
+        $previousLimits = $limit->limits;
+
+        $limit->limits = [
+            'can_post' => $can_post,
+            'can_comment' => $can_comment,
+            'can_like' => $can_like,
+        ];
+        $limit->save();
+
+        GroupService::clearInteractionLimits($group->id, $member);
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:member-limits:updated',
+            [
+                'profile_id' => $account['id'],
+                'username' => $account['username'],
+                'previousLimits' => $previousLimits,
+                'newLimits' => $limit->limits,
+            ],
+            GroupLimit::class,
+            $limit->id
+        );
+
+        return $request->all();
+    }
+
+    public function showProfile(Request $request, $id, $pid)
+    {
+        $group = Group::find($id);
+
+        if (! $group || $group->status) {
+            return response()->view('groups.unavailable')->setStatusCode(404);
+        }
+
+        return view('layouts.spa');
+    }
+
+    public function showProfileByUsername(Request $request, $id, $pid)
+    {
+        abort_if(! $request->user(), 404);
+        if (! $request->user()) {
+            return redirect("/{$pid}");
+        }
+
+        $group = Group::find($id);
+        $cid = $request->user()->profile_id;
+
+        if (! $group || $group->status) {
+            return response()->view('groups.unavailable')->setStatusCode(404);
+        }
+
+        if (! $group->isMember($cid)) {
+            return redirect("/{$pid}");
+        }
+
+        $profile = Profile::whereUsername($pid)->first();
+
+        if (! $group->isMember($profile->id)) {
+            return redirect("/{$pid}");
+        }
+
+        if ($profile) {
+            $url = url("/groups/{$id}/user/{$profile->id}");
+
+            return redirect($url);
+        }
+
+        abort(404, 'Invalid username');
+    }
+
+    public function groupInviteLanding(Request $request, $id)
+    {
+        abort(404, 'Not yet implemented');
+        $group = Group::findOrFail($id);
+
+        return view('groups.invite', compact('group'));
+    }
+
+    public function groupShortLinkRedirect(Request $request, $hid)
+    {
+        $gid = HashidService::decode($hid);
+        $group = Group::findOrFail($gid);
+
+        return redirect($group->url());
+    }
+
+    public function groupInviteClaim(Request $request, $id)
+    {
+        $group = GroupService::get($id);
+        abort_if(! $group || empty($group), 404);
+
+        return view('groups.invite-claim', compact('group'));
+    }
+
+    public function groupMemberInviteCheck(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($id);
+        abort_if($group->isMember($pid), 422, 'Already a member');
+
+        $exists = GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists();
+
+        return response()->json([
+            'gid' => $id,
+            'can_join' => (bool) $exists,
+        ]);
+    }
+
+    public function groupMemberInviteAccept(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($id);
+        abort_if($group->isMember($pid), 422, 'Already a member');
+
+        abort_if(! GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(), 422);
+
+        $gm = new GroupMember;
+        $gm->group_id = $id;
+        $gm->profile_id = $pid;
+        $gm->role = 'member';
+        $gm->local_group = $group->local;
+        $gm->local_profile = true;
+        $gm->join_request = false;
+        $gm->save();
+
+        GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->delete();
+        GroupService::del($id);
+        GroupService::delSelf($id, $pid);
+
+        return ['next_url' => $group->url()];
+    }
+
+    public function groupMemberInviteDecline(Request $request, $id)
+    {
+        abort_if(! $request->user(), 404);
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($id);
+        abort_if($group->isMember($pid), 422, 'Already a member');
+
+        return ['next_url' => '/'];
+    }
+}

+ 107 - 0
app/Http/Controllers/GroupFederationController.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\InstanceActor;
+use App\Services\MediaService;
+use App\Status;
+use App\Util\Lexer\Autolink;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+
+class GroupFederationController extends Controller
+{
+    public function getGroupObject(Request $request, $id)
+    {
+        $group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($id);
+        $res = $this->showGroupObject($group);
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    }
+
+    public function showGroupObject($group)
+    {
+        return Cache::remember('ap:groups:object:'.$group->id, 3600, function () use ($group) {
+            return [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'id' => $group->url(),
+                'inbox' => $group->permalink('/inbox'),
+                'name' => $group->name,
+                'outbox' => $group->permalink('/outbox'),
+                'summary' => $group->description,
+                'type' => 'Group',
+                'attributedTo' => [
+                    'type' => 'Person',
+                    'id' => $group->admin->permalink(),
+                ],
+                // 'endpoints' => [
+                // 	'sharedInbox' => config('app.url') . '/f/inbox'
+                // ],
+                'preferredUsername' => 'gid_'.$group->id,
+                'publicKey' => [
+                    'id' => $group->permalink('#main-key'),
+                    'owner' => $group->permalink(),
+                    'publicKeyPem' => InstanceActor::first()->public_key,
+                ],
+                'url' => $group->permalink(),
+            ];
+
+            if ($group->metadata && isset($group->metadata['avatar'])) {
+                $res['icon'] = [
+                    'type' => 'Image',
+                    'url' => $group->metadata['avatar']['url'],
+                ];
+            }
+
+            if ($group->metadata && isset($group->metadata['header'])) {
+                $res['image'] = [
+                    'type' => 'Image',
+                    'url' => $group->metadata['header']['url'],
+                ];
+            }
+            ksort($res);
+
+            return $res;
+        });
+    }
+
+    public function getStatusObject(Request $request, $gid, $sid)
+    {
+        $group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($gid);
+        $gp = GroupPost::whereGroupId($gid)->findOrFail($sid);
+        $status = Status::findOrFail($gp->status_id);
+        // permission check
+        $content = $status->caption ? Autolink::create()->autolink($status->caption) : null;
+        $res = [
+            '@context' => 'https://www.w3.org/ns/activitystreams',
+            'id' => $gp->url(),
+
+            'type' => 'Note',
+
+            'summary' => null,
+            'content' => $content,
+            'inReplyTo' => null,
+
+            'published' => $status->created_at->toAtomString(),
+            'url' => $gp->url(),
+            'attributedTo' => $status->profile->permalink(),
+            'to' => [
+                'https://www.w3.org/ns/activitystreams#Public',
+                $group->permalink('/followers'),
+            ],
+            'cc' => [],
+            'sensitive' => (bool) $status->is_nsfw,
+            'attachment' => MediaService::activitypub($status->id),
+            'target' => [
+                'type' => 'Collection',
+                'id' => $group->permalink('/wall'),
+                'attributedTo' => $group->permalink(),
+            ],
+        ];
+
+        // ksort($res);
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+    }
+}

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

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

+ 83 - 0
app/Http/Controllers/Groups/CreateGroupsController.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupMember;
+
+class CreateGroupsController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function checkCreatePermission(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $pid = $request->user()->profile_id;
+        $config = GroupService::config();
+        if($request->user()->is_admin) {
+            $allowed = true;
+        } else {
+            $max = $config['limits']['user']['create']['max'];
+            $allowed = Group::whereProfileId($pid)->count() <= $max;
+        }
+
+        return ['permission' => (bool) $allowed];
+    }
+
+    public function storeGroup(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+
+        $this->validate($request, [
+            'name' => 'required',
+            'description' => 'nullable|max:500',
+            'membership' => 'required|in:public,private,local'
+        ]);
+
+        $pid = $request->user()->profile_id;
+
+        $config = GroupService::config();
+        abort_if($config['limits']['user']['create']['new'] == false && $request->user()->is_admin == false, 422, 'Invalid operation');
+        $max = $config['limits']['user']['create']['max'];
+        // abort_if(Group::whereProfileId($pid)->count() <= $max, 422, 'Group limit reached');
+
+        $group = new Group;
+        $group->profile_id = $pid;
+        $group->name = $request->input('name');
+        $group->description = $request->input('description', null);
+        $group->is_private = $request->input('membership') == 'private';
+        $group->local_only = $request->input('membership') == 'local';
+        $group->metadata = $request->input('configuration');
+        $group->save();
+
+        GroupService::log($group->id, $pid, 'group:created');
+
+        $member = new GroupMember;
+        $member->group_id = $group->id;
+        $member->profile_id = $pid;
+        $member->role = 'founder';
+        $member->local_group = true;
+        $member->local_profile = true;
+        $member->save();
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:joined',
+            null,
+            GroupMember::class,
+            $member->id
+        );
+
+        return [
+            'id' => $group->id,
+            'url' => $group->url()
+        ];
+    }
+}

+ 353 - 0
app/Http/Controllers/Groups/GroupsAdminController.php

@@ -0,0 +1,353 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Instance;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupBlock;
+use App\Models\GroupCategory;
+use App\Models\GroupInteraction;
+use App\Models\GroupPost;
+use App\Models\GroupMember;
+use App\Models\GroupReport;
+use App\Services\Groups\GroupAccountService;
+use App\Services\Groups\GroupPostService;
+
+class GroupsAdminController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function getAdminTabs(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+        abort_if($pid !== $group->profile_id, 404);
+
+        $reqs = GroupMember::whereGroupId($group->id)->whereJoinRequest(true)->count();
+        $mods = GroupReport::whereGroupId($group->id)->whereOpen(true)->count();
+        $tabs = [
+            'moderation_count' => $mods > 99 ? '99+' : $mods,
+            'request_count' => $reqs > 99 ? '99+' : $reqs
+        ];
+
+        return response()->json($tabs);
+    }
+
+    public function getInteractionLogs(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $logs = GroupInteraction::whereGroupId($id)
+            ->latest()
+            ->paginate(10)
+            ->map(function($log) use($group) {
+                return [
+                    'id' => $log->id,
+                    'profile' => GroupAccountService::get($group->id, $log->profile_id),
+                    'type' => $log->type,
+                    'metadata' => $log->metadata,
+                    'created_at' => $log->created_at->format('c')
+                ];
+            });
+
+        return response()->json($logs, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function getBlocks(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $blocks = [
+            'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->take(3)->pluck('name'),
+            'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->take(3)->pluck('name'),
+            'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->take(3)->pluck('name')
+        ];
+
+        return response()->json($blocks, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function exportBlocks(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $blocks = [
+            'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->pluck('name'),
+            'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->pluck('name'),
+            'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->pluck('name')
+        ];
+
+        $blocks['_created_at'] = now()->format('c');
+        $blocks['_version'] = '1.0.0';
+        ksort($blocks);
+
+        return response()->streamDownload(function() use($blocks) {
+            echo json_encode($blocks, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+        });
+    }
+
+    public function addBlock(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $this->validate($request, [
+            'item' => 'required',
+            'type' => 'required|in:instance,user,moderate'
+        ]);
+
+        $item = $request->input('item');
+        $type = $request->input('type');
+
+        switch($type) {
+            case 'instance':
+                $instance = Instance::whereDomain($item)->first();
+                abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
+                $gb = new GroupBlock;
+                $gb->group_id = $group->id;
+                $gb->admin_id = $pid;
+                $gb->instance_id = $instance->id;
+                $gb->name = $instance->domain;
+                $gb->is_user = false;
+                $gb->moderated = false;
+                $gb->save();
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:block:instance',
+                    [
+                        'domain' => $instance->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                return [200];
+            break;
+
+            case 'user':
+                $profile = Profile::whereUsername($item)->first();
+                abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid');
+                $gb = new GroupBlock;
+                $gb->group_id = $group->id;
+                $gb->admin_id = $pid;
+                $gb->profile_id = $profile->id;
+                $gb->name = $profile->username;
+                $gb->is_user = true;
+                $gb->moderated = false;
+                $gb->save();
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:block:user',
+                    [
+                        'username' => $profile->username,
+                        'domain' => $profile->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                return [200];
+            break;
+
+            case 'moderate':
+                $instance = Instance::whereDomain($item)->first();
+                abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
+                $gb = new GroupBlock;
+                $gb->group_id = $group->id;
+                $gb->admin_id = $pid;
+                $gb->instance_id = $instance->id;
+                $gb->name = $instance->domain;
+                $gb->is_user = false;
+                $gb->moderated = true;
+                $gb->save();
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:moderate:instance',
+                    [
+                        'domain' => $instance->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                return [200];
+            break;
+
+            default:
+                return response()->json([], 422, []);
+            break;
+        }
+    }
+
+    public function undoBlock(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $this->validate($request, [
+            'item' => 'required',
+            'type' => 'required|in:instance,user,moderate'
+        ]);
+
+        $item = $request->input('item');
+        $type = $request->input('type');
+
+        switch($type) {
+            case 'instance':
+                $instance = Instance::whereDomain($item)->first();
+                abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
+
+                $gb = GroupBlock::whereGroupId($group->id)
+                    ->whereInstanceId($instance->id)
+                    ->whereModerated(false)
+                    ->first();
+
+                abort_if(!$gb, 422, 'Invalid group block');
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:unblock:instance',
+                    [
+                        'domain' => $instance->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                $gb->delete();
+
+                return [200];
+            break;
+
+            case 'user':
+                $profile = Profile::whereUsername($item)->first();
+                abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid');
+
+                $gb = GroupBlock::whereGroupId($group->id)
+                    ->whereProfileId($profile->id)
+                    ->whereIsUser(true)
+                    ->first();
+
+                abort_if(!$gb, 422, 'Invalid group block');
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:unblock:user',
+                    [
+                        'username' => $profile->username,
+                        'domain' => $profile->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                $gb->delete();
+
+                return [200];
+            break;
+
+            case 'moderate':
+                $instance = Instance::whereDomain($item)->first();
+                abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
+
+                $gb = GroupBlock::whereGroupId($group->id)
+                    ->whereInstanceId($instance->id)
+                    ->whereModerated(true)
+                    ->first();
+
+                abort_if(!$gb, 422, 'Invalid group block');
+
+                GroupService::log(
+                    $group->id,
+                    $pid,
+                    'group:admin:moderate:instance',
+                    [
+                        'domain' => $instance->domain
+                    ],
+                    GroupBlock::class,
+                    $gb->id
+                );
+
+                $gb->delete();
+
+                return [200];
+            break;
+
+            default:
+                return response()->json([], 422, []);
+            break;
+        }
+    }
+
+    public function getReportList(Request $request, $id)
+    {
+        abort_if(!$request->user(), 404);
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $scope = $request->input('scope', 'open');
+
+        $list = GroupReport::selectRaw('id, profile_id, item_type, item_id, type, created_at, count(*) as total')
+            ->whereGroupId($group->id)
+            ->groupBy('item_id')
+            ->when($scope == 'open', function($query, $scope) {
+                return $query->whereOpen(true);
+            })
+            ->latest()
+            ->simplePaginate(10)
+            ->map(function($report) use($group) {
+                $res = [
+                    'id' => (string) $report->id,
+                    'profile' => GroupAccountService::get($group->id, $report->profile_id),
+                    'type' => $report->type,
+                    'created_at' => $report->created_at->format('c'),
+                    'total_count' => $report->total
+                ];
+
+                if($report->item_type === GroupPost::class) {
+                    $res['status'] = GroupPostService::get($group->id, $report->item_id);
+                }
+
+                return $res;
+            });
+        return response()->json($list, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+}

+ 84 - 0
app/Http/Controllers/Groups/GroupsApiController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupMember;
+use App\Services\Groups\GroupAccountService;
+
+class GroupsApiController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    protected function toJson($group, $pid = false)
+    {
+        return GroupService::get($group->id, $pid);
+    }
+
+    public function getConfig(Request $request)
+    {
+        return GroupService::config();
+    }
+
+    public function getGroupAccount(Request $request, $gid, $pid)
+    {
+        $res = GroupAccountService::get($gid, $pid);
+
+        return response()->json($res);
+    }
+
+    public function getGroupCategories(Request $request)
+    {
+        $res = GroupService::categories();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function getGroupsByCategory(Request $request)
+    {
+        $name = $request->input('name');
+        $category = GroupCategory::whereName($name)->firstOrFail();
+        $groups = Group::whereCategoryId($category->id)
+            ->simplePaginate(6)
+            ->map(function($group) {
+                return GroupService::get($group->id);
+            })
+            ->filter(function($group) {
+                return $group;
+            })
+            ->values();
+        return $groups;
+    }
+
+    public function getRecommendedGroups(Request $request)
+    {
+        return [];
+    }
+
+    public function getSelfGroups(Request $request)
+    {
+        $selfOnly = $request->input('self') == true;
+        $memberOnly = $request->input('member') == true;
+        $pid = $request->user()->profile_id;
+        $res = GroupMember::whereProfileId($request->user()->profile_id)
+            ->when($selfOnly, function($q, $selfOnly) {
+                return $q->whereRole('founder');
+            })
+            ->when($memberOnly, function($q, $memberOnly) {
+                return $q->whereRole('member');
+            })
+            ->simplePaginate(4)
+            ->map(function($member) use($pid) {
+                $group = $member->group;
+                return $this->toJson($group, $pid);
+        });
+
+        return response()->json($res);
+    }
+}

+ 361 - 0
app/Http/Controllers/Groups/GroupsCommentController.php

@@ -0,0 +1,361 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupCommentService;
+use App\Services\Groups\GroupMediaService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupsLikeService;
+use App\Models\Group;
+use App\Models\GroupLike;
+use App\Models\GroupMedia;
+use App\Models\GroupPost;
+use App\Models\GroupComment;
+use Purify;
+use App\Util\Lexer\Autolink;
+use App\Jobs\GroupsPipeline\ImageResizePipeline;
+use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
+use App\Jobs\GroupsPipeline\NewPostPipeline;
+use App\Jobs\GroupsPipeline\NewCommentPipeline;
+use App\Jobs\GroupsPipeline\DeleteCommentPipeline;
+
+class GroupsCommentController extends Controller
+{
+    public function getComments(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'sid' => 'required',
+            'cid' => 'sometimes',
+            'limit' => 'nullable|integer|min:3|max:10'
+        ]);
+
+        $pid = optional($request->user())->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+        $cid = $request->has('cid') && $request->input('cid') == 1;
+        $limit = $request->input('limit', 3);
+        $maxId = $request->input('max_id', 0);
+
+        $group = Group::findOrFail($gid);
+
+        abort_if($group->is_private && !$group->isMember($pid), 403, 'Not a member of group.');
+
+        $status = $cid ? GroupComment::findOrFail($sid) : GroupPost::findOrFail($sid);
+
+        abort_if($status->group_id != $group->id, 400, 'Invalid group');
+
+        $replies = GroupComment::whereGroupId($group->id)
+            ->whereStatusId($status->id)
+            ->orderByDesc('id')
+            ->when($maxId, function($query, $maxId) {
+                return $query->where('id', '<', $maxId);
+            })
+            ->take($limit)
+            ->get()
+            ->map(function($gp) use($pid) {
+                $status = GroupCommentService::get($gp['group_id'], $gp['id']);
+                $status['reply_count'] = $gp['reply_count'];
+                $status['url'] = $gp->url();
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
+                $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$gp['profile_id']}");
+                return $status;
+            });
+
+        return $replies->toArray();
+    }
+
+    public function storeComment(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required|exists:groups,id',
+            'sid' => 'required|exists:group_posts,id',
+            'cid' => 'sometimes',
+            'content' => 'required|string|min:1|max:1500'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+        $cid = $request->input('cid');
+        $limit = $request->input('limit', 3);
+        $caption = e($request->input('content'));
+
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+        abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time');
+
+
+        $parent = $cid == 1 ?
+            GroupComment::findOrFail($sid) :
+            GroupPost::whereGroupId($gid)->findOrFail($sid);
+        // $autolink = Purify::clean(Autolink::create()->autolink($caption));
+        // $autolink = str_replace('/discover/tags/', '/groups/' . $gid . '/topics/', $autolink);
+
+        $status = new GroupComment;
+        $status->group_id = $group->id;
+        $status->profile_id = $pid;
+        $status->status_id = $parent->id;
+        $status->caption = Purify::clean($caption);
+        $status->visibility = 'public';
+        $status->is_nsfw = false;
+        $status->local = true;
+        $status->save();
+
+        NewCommentPipeline::dispatch($parent, $status)->onQueue('groups');
+        // todo: perform in job
+        $parent->reply_count = $parent->reply_count ? $parent->reply_count + $parent->reply_count : 1;
+        $parent->save();
+        GroupPostService::del($parent->group_id, $parent->id);
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:comment:created',
+            [
+                'type' => 'group:post:comment',
+                'status_id' => $status->id
+            ],
+            GroupPost::class,
+            $status->id
+        );
+
+        //GroupCommentPipeline::dispatch($parent, $status, $gp);
+        //NewStatusPipeline::dispatch($status, $gp);
+        //GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
+
+        // todo: perform in job
+        $s = GroupCommentService::get($status->group_id, $status->id);
+
+        $s['pf_type'] = 'text';
+        $s['visibility'] = 'public';
+        $s['url'] = $status->url();
+
+        return $s;
+    }
+
+    public function storeCommentPhoto(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required|exists:groups,id',
+            'sid' => 'required|exists:group_posts,id',
+            'photo' => 'required|image'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+        $limit = $request->input('limit', 3);
+        $caption = $request->input('content');
+
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+        abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time');
+        $parent = GroupPost::whereGroupId($gid)->findOrFail($sid);
+
+        $status = new GroupComment;
+        $status->status_id = $parent->id;
+        $status->group_id = $group->id;
+        $status->profile_id = $pid;
+        $status->caption = Purify::clean($caption);
+        $status->visibility = 'draft';
+        $status->is_nsfw = false;
+        $status->save();
+
+        $photo = $request->file('photo');
+        $storagePath = GroupMediaService::path($group->id, $pid, $status->id);
+        $storagePath = 'public/g/' . $group->id . '/p/' . $parent->id;
+        $path = $photo->storePublicly($storagePath);
+
+        $media = new GroupMedia();
+        $media->group_id = $group->id;
+        $media->status_id = $status->id;
+        $media->profile_id = $request->user()->profile_id;
+        $media->media_path = $path;
+        $media->size = $photo->getSize();
+        $media->mime = $photo->getMimeType();
+        $media->save();
+
+        ImageResizePipeline::dispatchSync($media);
+        ImageS3UploadPipeline::dispatchSync($media);
+
+        // $gp = new GroupPost;
+        // $gp->group_id = $group->id;
+        // $gp->profile_id = $pid;
+        // $gp->type = 'reply:photo';
+        // $gp->status_id = $status->id;
+        // $gp->in_reply_to_id = $parent->id;
+        // $gp->save();
+
+        // GroupService::log(
+        //  $group->id,
+        //  $pid,
+        //  'group:comment:created',
+        //  [
+        //      'type' => $gp->type,
+        //      'status_id' => $status->id
+        //  ],
+        //  GroupPost::class,
+        //  $gp->id
+        // );
+
+        // todo: perform in job
+        // $parent->reply_count = Status::whereInReplyToId($parent->id)->count();
+        // $parent->save();
+        // StatusService::del($parent->id);
+        // GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
+
+        // delay response while background job optimizes media
+        // sleep(5);
+
+        // todo: perform in job
+        $s = GroupCommentService::get($status->group_id, $status->id);
+
+        // $s['pf_type'] = 'text';
+        // $s['visibility'] = 'public';
+        // $s['url'] = $gp->url();
+
+        return $s;
+    }
+
+    public function deleteComment(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+          'id'  => 'required|integer|min:1',
+          'gid' => 'required|integer|min:1'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $gp = GroupComment::whereGroupId($group->id)->findOrFail($request->input('id'));
+        abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403);
+
+        $parent = GroupPost::find($gp->status_id);
+        abort_if(!$parent, 422, 'Invalid parent');
+
+        DeleteCommentPipeline::dispatch($parent, $gp)->onQueue('groups');
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:status:deleted',
+            [
+                'type' => $gp->type,
+                'status_id' => $gp->id,
+            ],
+            GroupComment::class,
+            $gp->id
+        );
+        $gp->delete();
+
+        if($request->wantsJson()) {
+            return response()->json(['Status successfully deleted.']);
+        } else {
+            return redirect('/groups/feed');
+        }
+    }
+
+    public function likePost(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'sid' => 'required'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+
+        $group = GroupService::get($gid);
+        abort_if(!$group || $gid != $group['id'], 422, 'Invalid group');
+        abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
+        abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
+        $gp = GroupCommentService::get($gid, $sid);
+        abort_if(!$gp, 422, 'Invalid status');
+        $count = $gp['favourites_count'] ?? 0;
+
+        $like = GroupLike::firstOrCreate([
+            'group_id' => $gid,
+            'profile_id' => $pid,
+            'comment_id' => $sid,
+        ]);
+
+        if($like->wasRecentlyCreated) {
+            // update parent post like count
+            $parent = GroupComment::find($sid);
+            abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status');
+            $parent->likes_count = $parent->likes_count + 1;
+            $parent->save();
+            GroupsLikeService::add($pid, $sid);
+            // invalidate cache
+            GroupCommentService::del($gid, $sid);
+            $count++;
+            GroupService::log(
+                $gid,
+                $pid,
+                'group:like',
+                null,
+                GroupLike::class,
+                $like->id
+            );
+        }
+
+        $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
+
+        return $response;
+    }
+
+    public function unlikePost(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'sid' => 'required'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+
+        $group = GroupService::get($gid);
+        abort_if(!$group || $gid != $group['id'], 422, 'Invalid group');
+        abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
+        abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
+        $gp = GroupCommentService::get($gid, $sid);
+        abort_if(!$gp, 422, 'Invalid status');
+        $count = $gp['favourites_count'] ?? 0;
+
+        $like = GroupLike::where([
+            'group_id' => $gid,
+            'profile_id' => $pid,
+            'comment_id' => $sid,
+        ])->first();
+
+        if($like) {
+            $like->delete();
+            $parent = GroupComment::find($sid);
+            abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status');
+            $parent->likes_count = $parent->likes_count - 1;
+            $parent->save();
+            GroupsLikeService::remove($pid, $sid);
+            // invalidate cache
+            GroupCommentService::del($gid, $sid);
+            $count--;
+        }
+
+        $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count];
+
+        return $response;
+    }
+}

+ 57 - 0
app/Http/Controllers/Groups/GroupsDiscoverController.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+
+class GroupsDiscoverController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function getDiscoverPopular(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $groups = Group::orderByDesc('member_count')
+            ->take(12)
+            ->pluck('id')
+            ->map(function($id) {
+                return GroupService::get($id);
+            })
+            ->filter(function($id) {
+                return $id;
+            })
+            ->take(6)
+            ->values();
+        return $groups;
+    }
+
+    public function getDiscoverNew(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $groups = Group::latest()
+            ->take(12)
+            ->pluck('id')
+            ->map(function($id) {
+                return GroupService::get($id);
+            })
+            ->filter(function($id) {
+                return $id;
+            })
+            ->take(6)
+            ->values();
+        return $groups;
+    }
+}

+ 188 - 0
app/Http/Controllers/Groups/GroupsFeedController.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\UserFilterService;
+use App\Services\Groups\GroupFeedService;
+use App\Services\Groups\GroupPostService;
+use App\Services\RelationshipService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupInvitation;
+
+class GroupsFeedController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function getSelfFeed(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit', 5);
+        $page = $request->input('page');
+        $initial = $request->has('initial');
+
+        if($initial) {
+            $res = Cache::remember('groups:self:feed:' . $pid, 900, function() use($pid) {
+                return $this->getSelfFeedV0($pid, 5, null);
+            });
+        } else {
+            abort_if($page && $page > 5, 422);
+            $res = $this->getSelfFeedV0($pid, $limit, $page);
+        }
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    protected function getSelfFeedV0($pid, $limit, $page)
+    {
+        return GroupPost::join('group_members', 'group_posts.group_id', 'group_members.group_id')
+            ->select('group_posts.*', 'group_members.group_id', 'group_members.profile_id')
+            ->where('group_members.profile_id', $pid)
+            ->whereIn('group_posts.type', ['text', 'photo', 'video'])
+            ->orderByDesc('group_posts.id')
+            ->limit($limit)
+            // ->pluck('group_posts.status_id')
+            ->simplePaginate($limit)
+            ->map(function($gp) use($pid) {
+                $status = GroupPostService::get($gp['group_id'], $gp['id']);
+
+                if(!$status) {
+                    return false;
+                }
+
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
+                $status['favourites_count'] = GroupsLikeService::count($gp['id']);
+                $status['pf_type'] = $gp['type'];
+                $status['visibility'] = 'public';
+                $status['url'] = url("/groups/{$gp['group_id']}/p/{$gp['id']}");
+                $status['group'] = GroupService::get($gp['group_id']);
+                $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
+
+                return $status;
+        });
+    }
+
+    public function getGroupProfileFeed(Request $request, $id, $pid)
+    {
+        abort_if(!$request->user(), 404);
+        $cid = $request->user()->profile_id;
+
+        $group = Group::findOrFail($id);
+        abort_if(!$group->isMember($pid), 404);
+
+        $feed = GroupPost::whereGroupId($id)
+            ->whereProfileId($pid)
+            ->latest()
+            ->paginate(3)
+            ->map(function($gp) use($pid) {
+                $status = GroupPostService::get($gp['group_id'], $gp['id']);
+                if(!$status) {
+                    return false;
+                }
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
+                $status['favourites_count'] = GroupsLikeService::count($gp['id']);
+                $status['pf_type'] = $gp['type'];
+                $status['visibility'] = 'public';
+                $status['url'] = $gp->url();
+
+                // if($gp['type'] == 'poll') {
+                //     $status['poll'] = PollService::get($status['id']);
+                // }
+
+                $status['account']['url'] = "/groups/{$gp['group_id']}/user/{$status['account']['id']}";
+
+                return $status;
+            })
+            ->filter(function($status) {
+                return $status;
+            });
+
+        return $feed;
+    }
+
+    public function getGroupFeed(Request $request, $id)
+    {
+        $group = Group::findOrFail($id);
+        $user = $request->user();
+        $pid = optional($user)->profile_id ?? false;
+        abort_if(!$group->isMember($pid), 404);
+        $max = $request->input('max_id');
+        $limit = $request->limit ?? 3;
+        $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
+
+        // $posts = GroupPost::whereGroupId($group->id)
+        //  ->when($maxId, function($q, $maxId) {
+        //      return $q->where('status_id', '<', $maxId);
+        //  })
+        //  ->whereNull('in_reply_to_id')
+        //  ->orderByDesc('status_id')
+        //  ->simplePaginate($limit)
+        //  ->map(function($gp) use($pid) {
+        //      $status = StatusService::get($gp['status_id'], false);
+        //      if(!$status) {
+        //          return false;
+        //      }
+        //      $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']);
+        //      $status['favourites_count'] = LikeService::count($gp['status_id']);
+        //      $status['pf_type'] = $gp['type'];
+        //      $status['visibility'] = 'public';
+        //      $status['url'] = $gp->url();
+
+        //      if($gp['type'] == 'poll') {
+        //          $status['poll'] = PollService::get($status['id']);
+        //      }
+
+        //      $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
+
+        //      return $status;
+        //  })->filter(function($status) {
+        //      return $status;
+        //  });
+        // return $posts;
+
+        Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() use($id) {
+            if(GroupFeedService::count($id) == 0) {
+                GroupFeedService::warmCache($id, true, 400);
+            }
+        });
+
+        if ($max) {
+            $feed = GroupFeedService::getRankedMaxId($id, $max, $limit);
+        } else {
+            $feed = GroupFeedService::get($id, 0, $limit);
+        }
+
+        $res = collect($feed)
+        ->map(function($k) use($user, $id) {
+            $status = GroupPostService::get($id, $k);
+            if($status && $user) {
+                $pid = $user->profile_id;
+                $sid = $status['account']['id'];
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $status['id']);
+                $status['favourites_count'] = GroupsLikeService::count($status['id']);
+                $status['relationship'] = $pid == $sid ? [] : RelationshipService::get($pid, $sid);
+            }
+            return $status;
+        })
+        ->filter(function($s) use($filtered) {
+            return $s && in_array($s['account']['id'], $filtered) == false;
+        })
+        ->values()
+        ->toArray();
+
+        return $res;
+    }
+}

+ 214 - 0
app/Http/Controllers/Groups/GroupsMemberController.php

@@ -0,0 +1,214 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupMember;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\Groups\GroupAccountService;
+use App\Services\Groups\GroupHashtagService;
+use App\Jobs\GroupsPipeline\MemberJoinApprovedPipeline;
+use App\Jobs\GroupsPipeline\MemberJoinRejectedPipeline;
+
+class GroupsMemberController extends Controller
+{
+    public function getGroupMembers(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'limit' => 'nullable|integer|min:3|max:10'
+        ]);
+
+        abort_if(!$request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $members = GroupMember::whereGroupId($gid)
+            ->whereJoinRequest(false)
+            ->simplePaginate(10)
+            ->map(function($member) use($pid) {
+                $account = AccountService::get($member['profile_id']);
+                $account['role'] = $member['role'];
+                $account['joined'] = $member['created_at'];
+                $account['following'] = $pid != $member['profile_id'] ?
+                    FollowerService::follows($pid, $member['profile_id']) :
+                    null;
+                $account['url'] = url("/groups/{$member->group_id}/user/{$member['profile_id']}");
+                return $account;
+            });
+
+        return response()->json($members->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function getGroupMemberJoinRequests(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $id = $request->input('gid');
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        return GroupMember::whereGroupId($group->id)
+            ->whereJoinRequest(true)
+            ->whereNull('rejected_at')
+            ->paginate(10)
+            ->map(function($member) {
+                return AccountService::get($member->profile_id);
+            });
+    }
+
+    public function handleGroupMemberJoinRequest(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $id = $request->input('gid');
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+        $mid = $request->input('pid');
+        abort_if($group->isMember($mid), 404);
+
+        $this->validate($request, [
+            'gid' => 'required',
+            'pid' => 'required',
+            'action' => 'required|in:approve,reject'
+        ]);
+
+        $action = $request->input('action');
+
+        $member = GroupMember::whereGroupId($group->id)
+            ->whereProfileId($mid)
+            ->firstOrFail();
+
+        if($action == 'approve') {
+            MemberJoinApprovedPipeline::dispatch($member)->onQueue('groups');
+        } else if ($action == 'reject') {
+            MemberJoinRejectedPipeline::dispatch($member)->onQueue('groups');
+        }
+
+        return $request->all();
+    }
+
+    public function getGroupMember(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'pid' => 'required'
+        ]);
+
+        abort_if(!$request->user(), 404);
+        $gid = $request->input('gid');
+        $group = Group::findOrFail($gid);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $member_id = $request->input('pid');
+        $member = GroupMember::whereGroupId($gid)
+            ->whereProfileId($member_id)
+            ->firstOrFail();
+
+        $account = GroupAccountService::get($group->id, $member['profile_id']);
+        $account['role'] = $member['role'];
+        $account['joined'] = $member['created_at'];
+        $account['following'] = $pid != $member['profile_id'] ?
+            FollowerService::follows($pid, $member['profile_id']) :
+            null;
+        $account['url'] = url("/groups/{$gid}/user/{$member_id}");
+
+        return response()->json($account, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function getGroupMemberCommonIntersections(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $cid = $request->user()->profile_id;
+
+        // $this->validate($request, [
+        //  'gid' => 'required',
+        //  'pid' => 'required'
+        // ]);
+
+        $gid = $request->input('gid');
+        $pid = $request->input('pid');
+
+        if($pid === $cid) {
+            return [];
+        }
+
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($cid), 404);
+        abort_if(!$group->isMember($pid), 404);
+
+        $self = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr')
+            ->whereProfileId($cid)
+            ->groupBy('hashtag_id')
+            ->orderByDesc('countr')
+            ->take(20)
+            ->pluck('hashtag_id');
+        $user = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr')
+            ->whereProfileId($pid)
+            ->groupBy('hashtag_id')
+            ->orderByDesc('countr')
+            ->take(20)
+            ->pluck('hashtag_id');
+
+        $topics = $self->intersect($user)
+            ->values()
+            ->shuffle()
+            ->take(3)
+            ->map(function($id) use($group) {
+                $tag = GroupHashtagService::get($id);
+                $tag['url'] = url("/groups/{$group->id}/topics/{$tag['slug']}?src=upt");
+                return $tag;
+            });
+
+        // $friends = DB::table('followers as u')
+        //  ->join('followers as s', 'u.following_id', '=', 's.following_id')
+        //  ->where('s.profile_id', $cid)
+        //  ->where('u.profile_id', $pid)
+        //  ->inRandomOrder()
+        //  ->take(10)
+        //  ->pluck('s.following_id')
+        //  ->map(function($id) use($gid) {
+        //      $res = AccountService::get($id);
+        //      $res['url'] = url("/groups/{$gid}/user/{$id}");
+        //      return $res;
+        //  });
+        $mutualGroups = GroupService::mutualGroups($cid, $pid, [$gid]);
+
+        $mutualFriends = collect(FollowerService::mutualIds($cid, $pid))
+            ->map(function($id) use($gid) {
+                $res = AccountService::get($id);
+                if(GroupService::isMember($gid, $id)) {
+                    $res['url'] = url("/groups/{$gid}/user/{$id}");
+                } else if(!$res['local']) {
+                    $res['url'] = url("/i/web/profile/_/{$id}");
+                }
+                return $res;
+            });
+        $mutualFriendsCount = FollowerService::mutualCount($cid, $pid);
+
+        $res = [
+            'groups_count' => $mutualGroups['count'],
+            'groups' => $mutualGroups['groups'],
+            'topics' => $topics,
+            'friends_count' => $mutualFriendsCount,
+            'friends' => $mutualFriends,
+        ];
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+}

+ 31 - 0
app/Http/Controllers/Groups/GroupsMetaController.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+
+class GroupsMetaController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function deleteGroup(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $id = $request->input('gid');
+        $group = Group::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
+
+        $group->status = "delete";
+        $group->save();
+        GroupService::del($group->id);
+        return [200];
+    }
+}

+ 55 - 0
app/Http/Controllers/Groups/GroupsNotificationsController.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\StatusService;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Notification;
+
+class GroupsNotificationsController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function selfGlobalNotifications(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $pid = $request->user()->profile_id;
+
+        $res = Notification::whereProfileId($pid)
+            ->where('action', 'like', 'group%')
+            ->latest()
+            ->paginate(10)
+            ->map(function($n) {
+                $res = [
+                    'id' => $n->id,
+                    'type' => $n->action,
+                    'account' => AccountService::get($n->actor_id),
+                    'object' => [
+                        'id' => $n->item_id,
+                        'type' => last(explode('\\', $n->item_type)),
+                    ],
+                    'created_at' => $n->created_at->format('c')
+                ];
+
+                if($res['object']['type'] == 'Status' || in_array($n->action, ['group:comment'])) {
+                    $res['status'] = StatusService::get($n->item_id, false);
+                    $res['group'] = GroupService::get($res['status']['gid']);
+                }
+
+                if($res['object']['type'] == 'Group') {
+                    $res['group'] = GroupService::get($n->item_id);
+                }
+
+                return $res;
+            });
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+}

+ 420 - 0
app/Http/Controllers/Groups/GroupsPostController.php

@@ -0,0 +1,420 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupFeedService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupMediaService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupHashtag;
+use App\Models\GroupPost;
+use App\Models\GroupLike;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+use App\Models\GroupMedia;
+use App\Jobs\GroupsPipeline\ImageResizePipeline;
+use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
+use App\Jobs\GroupsPipeline\NewPostPipeline;
+
+class GroupsPostController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function storePost(Request $request)
+    {
+        $this->validate($request, [
+            'group_id' => 'required|exists:groups,id',
+            'caption' => 'sometimes|string|max:'.config_cache('pixelfed.max_caption_length', 500),
+            'pollOptions' => 'sometimes|array|min:1|max:4'
+        ]);
+
+        $group = Group::findOrFail($request->input('group_id'));
+        $pid = $request->user()->profile_id;
+        $caption = $request->input('caption');
+        $type = $request->input('type', 'text');
+
+        abort_if(!GroupService::canPost($group->id, $pid), 422, 'You cannot create new posts at this time');
+
+        if($type == 'text') {
+            abort_if(strlen(e($caption)) == 0, 403);
+        }
+
+        $gp = new GroupPost;
+        $gp->group_id = $group->id;
+        $gp->profile_id = $pid;
+        $gp->caption = e($caption);
+        $gp->type = $type;
+        $gp->visibility = 'draft';
+        $gp->save();
+
+        $status = $gp;
+
+        NewPostPipeline::dispatchSync($gp);
+
+        // NewStatusPipeline::dispatch($status, $gp);
+
+        if($type == 'poll') {
+            // Polls not supported yet
+            // $poll = new Poll;
+            // $poll->status_id = $status->id;
+            // $poll->profile_id = $status->profile_id;
+            // $poll->poll_options = $request->input('pollOptions');
+            // $poll->expires_at = now()->addMinutes($request->input('expiry'));
+            // $poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+            //     return 0;
+            // })->toArray();
+            // $poll->save();
+            // sleep(5);
+        }
+        if($type == 'photo') {
+            $photo = $request->file('photo');
+            $storagePath = GroupMediaService::path($group->id, $pid, $status->id);
+            // $storagePath = 'public/g/' . $group->id . '/p/' . $status->id;
+            $path = $photo->storePublicly($storagePath);
+            // $hash = \hash_file('sha256', $photo);
+
+            $media = new GroupMedia();
+            $media->group_id = $group->id;
+            $media->status_id = $status->id;
+            $media->profile_id = $request->user()->profile_id;
+            $media->media_path = $path;
+            $media->size = $photo->getSize();
+            $media->mime = $photo->getMimeType();
+            $media->save();
+
+            // Bus::chain([
+            //     new ImageResizePipeline($media),
+            //     new ImageS3UploadPipeline($media),
+            // ])->dispatch($media);
+
+            ImageResizePipeline::dispatchSync($media);
+            ImageS3UploadPipeline::dispatchSync($media);
+            // ImageOptimize::dispatch($media);
+            // delay response while background job optimizes media
+            // sleep(5);
+        }
+        if($type == 'video') {
+            $video = $request->file('video');
+            $storagePath = 'public/g/' . $group->id . '/p/' . $status->id;
+            $path = $video->storePublicly($storagePath);
+            $hash = \hash_file('sha256', $video);
+
+            $media = new Media();
+            $media->status_id = $status->id;
+            $media->profile_id = $request->user()->profile_id;
+            $media->user_id = $request->user()->id;
+            $media->media_path = $path;
+            $media->original_sha256 = $hash;
+            $media->size = $video->getSize();
+            $media->mime = $video->getMimeType();
+            $media->save();
+
+            VideoThumbnail::dispatch($media);
+            sleep(15);
+        }
+
+        GroupService::log(
+            $group->id,
+            $pid,
+            'group:status:created',
+            [
+                'type' => $gp->type,
+                'status_id' => $status->id
+            ],
+            GroupPost::class,
+            $gp->id
+        );
+
+        $s = GroupPostService::get($status->group_id, $status->id);
+        GroupFeedService::add($group->id, $gp->id);
+        Cache::forget('groups:self:feed:' . $pid);
+
+        $s['pf_type'] = $type;
+        $s['visibility'] = 'public';
+        $s['url'] = $gp->url();
+
+        if($type == 'poll') {
+            $s['poll'] = PollService::get($status->id);
+        }
+
+        $group->last_active_at = now();
+        $group->save();
+
+        return $s;
+    }
+
+    public function deletePost(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+          'id'  => 'required|integer|min:1',
+          'gid' => 'required|integer|min:1'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $gp = GroupPost::whereGroupId($status->group_id)->findOrFail($request->input('id'));
+        abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403);
+        $cached = GroupPostService::get($status->group_id, $status->id);
+
+        if($cached) {
+            $cached = collect($cached)->filter(function($r, $k) {
+                return in_array($k, [
+                    'id',
+                    'sensitive',
+                    'pf_type',
+                    'media_attachments',
+                    'content_text',
+                    'created_at'
+                ]);
+            });
+        }
+
+        GroupService::log(
+            $status->group_id,
+            $request->user()->profile_id,
+            'group:status:deleted',
+            [
+                'type' => $gp->type,
+                'status_id' => $status->id,
+                'original' => $cached
+            ],
+            GroupPost::class,
+            $gp->id
+        );
+
+        $user = $request->user();
+
+        // if($status->profile_id != $user->profile->id &&
+        //  $user->is_admin == true &&
+        //  $status->uri == null
+        // ) {
+        //  $media = $status->media;
+
+        //  $ai = new AccountInterstitial;
+        //  $ai->user_id = $status->profile->user_id;
+        //  $ai->type = 'post.removed';
+        //  $ai->view = 'account.moderation.post.removed';
+        //  $ai->item_type = 'App\Status';
+        //  $ai->item_id = $status->id;
+        //  $ai->has_media = (bool) $media->count();
+        //  $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
+        //  $ai->meta = json_encode([
+        //      'caption' => $status->caption,
+        //      'created_at' => $status->created_at,
+        //      'type' => $status->type,
+        //      'url' => $status->url(),
+        //      'is_nsfw' => $status->is_nsfw,
+        //      'scope' => $status->scope,
+        //      'reblog' => $status->reblog_of_id,
+        //      'likes_count' => $status->likes_count,
+        //      'reblogs_count' => $status->reblogs_count,
+        //  ]);
+        //  $ai->save();
+
+        //  $u = $status->profile->user;
+        //  $u->has_interstitial = true;
+        //  $u->save();
+        // }
+
+        if($status->in_reply_to_id) {
+            $parent = GroupPost::find($status->in_reply_to_id);
+            if($parent) {
+                $parent->reply_count = GroupPost::whereInReplyToId($parent->id)->count();
+                $parent->save();
+                GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
+            }
+        }
+
+        GroupPostService::del($group->id, $gp->id);
+        GroupFeedService::del($group->id, $gp->id);
+        if ($status->profile_id == $user->profile->id || $user->is_admin == true) {
+            // Cache::forget('profile:status_count:'.$status->profile_id);
+            StatusDelete::dispatch($status);
+        }
+
+        if($request->wantsJson()) {
+            return response()->json(['Status successfully deleted.']);
+        } else {
+            return redirect($user->url());
+        }
+    }
+
+    public function likePost(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'sid' => 'required'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+
+        $group = GroupService::get($gid);
+        abort_if(!$group, 422, 'Invalid group');
+        abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
+        abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
+        $gp = GroupPostService::get($gid, $sid);
+        abort_if(!$gp, 422, 'Invalid status');
+        $count = $gp['favourites_count'] ?? 0;
+
+        $like = GroupLike::firstOrCreate([
+            'group_id' => $gid,
+            'profile_id' => $pid,
+            'status_id' => $sid,
+        ]);
+
+        if($like->wasRecentlyCreated) {
+            // update parent post like count
+            $parent = GroupPost::whereGroupId($gid)->find($sid);
+            abort_if(!$parent, 422, 'Invalid status');
+            $parent->likes_count = $parent->likes_count + 1;
+            $parent->save();
+            GroupsLikeService::add($pid, $sid);
+            // invalidate cache
+            GroupPostService::del($gid, $sid);
+            $count++;
+            GroupService::log(
+                $gid,
+                $pid,
+                'group:like',
+                null,
+                GroupLike::class,
+                $like->id
+            );
+        }
+        // if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) {
+        //     $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail();
+        //     // UnlikePipeline::dispatch($like);
+        //     $count = $gp->likes_count - 1;
+        //     $action = 'group:unlike';
+        // } else {
+        //     $count = $gp->likes_count;
+        //     $like = GroupLike::firstOrCreate([
+        //         'group_id' => $gid,
+        //         'profile_id' => $pid,
+        //         'status_id' => $sid
+        //     ]);
+        //     if($like->wasRecentlyCreated == true) {
+        //         $count++;
+        //         $gp->likes_count = $count;
+        //         $like->save();
+        //         $gp->save();
+        //         // LikePipeline::dispatch($like);
+        //         $action = 'group:like';
+        //     }
+        // }
+
+
+        // Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id);
+        // StatusService::del($status->id);
+
+        $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
+
+        return $response;
+    }
+
+    public function unlikePost(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'sid' => 'required'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $sid = $request->input('sid');
+
+        $group = GroupService::get($gid);
+        abort_if(!$group, 422, 'Invalid group');
+        abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
+        abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
+        $gp = GroupPostService::get($gid, $sid);
+        abort_if(!$gp, 422, 'Invalid status');
+        $count = $gp['favourites_count'] ?? 0;
+
+        $like = GroupLike::where([
+            'group_id' => $gid,
+            'profile_id' => $pid,
+            'status_id' => $sid,
+        ])->first();
+
+        if($like) {
+            $like->delete();
+            $parent = GroupPost::whereGroupId($gid)->find($sid);
+            abort_if(!$parent, 422, 'Invalid status');
+            $parent->likes_count = $parent->likes_count - 1;
+            $parent->save();
+            GroupsLikeService::remove($pid, $sid);
+            // invalidate cache
+            GroupPostService::del($gid, $sid);
+            $count--;
+        }
+
+        $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count];
+
+        return $response;
+    }
+
+    public function getGroupMedia(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'type' => 'required|in:photo,video'
+        ]);
+
+        abort_if(!$request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $type = $request->input('type');
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $media = GroupPost::whereGroupId($gid)
+            ->whereType($type)
+            ->latest()
+            ->simplePaginate(20)
+            ->map(function($gp) use($pid) {
+                $status = GroupPostService::get($gp['group_id'], $gp['id']);
+                if(!$status) {
+                    return false;
+                }
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
+                $status['favourites_count'] = GroupsLikeService::count($gp['id']);
+                $status['pf_type'] = $gp['type'];
+                $status['visibility'] = 'public';
+                $status['url'] = $gp->url();
+
+                // if($gp['type'] == 'poll') {
+                //     $status['poll'] = PollService::get($status['id']);
+                // }
+
+                return $status;
+            })->filter(function($status) {
+                return $status;
+            });
+
+        return response()->json($media->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+}

+ 221 - 0
app/Http/Controllers/Groups/GroupsSearchController.php

@@ -0,0 +1,221 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+use App\Util\ActivityPub\Helpers;
+use App\Services\Groups\GroupActivityPubService;
+
+class GroupsSearchController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function inviteFriendsToGroup(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $this->validate($request, [
+            'uids' => 'required',
+            'g' => 'required',
+        ]);
+        $uid = $request->input('uids');
+        $gid = $request->input('g');
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 404);
+        abort_if(
+            GroupInvitation::whereGroupId($group->id)
+                ->whereFromProfileId($pid)
+                ->count() >= 20,
+            422,
+            'Invite limit reached'
+        );
+
+        $profiles = collect($uid)
+            ->map(function($u) {
+                return Profile::find($u);
+            })
+            ->filter(function($u) use($pid) {
+                return $u &&
+                    $u->id != $pid &&
+                    isset($u->id) &&
+                    Follower::whereFollowingId($pid)
+                        ->whereProfileId($u->id)
+                        ->exists();
+            })
+            ->filter(function($u) use($group, $pid) {
+                return GroupInvitation::whereGroupId($group->id)
+                    ->whereFromProfileId($pid)
+                    ->whereToProfileId($u->id)
+                    ->exists() == false;
+            })
+            ->each(function($u) use($gid, $pid) {
+                $gi = new GroupInvitation;
+                $gi->group_id = $gid;
+                $gi->from_profile_id = $pid;
+                $gi->to_profile_id = $u->id;
+                $gi->to_local = true;
+                $gi->from_local = $u->domain == null;
+                $gi->save();
+                // GroupMemberInvite::dispatch($gi);
+            });
+        return [200];
+    }
+
+    public function searchFriendsToInvite(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $this->validate($request, [
+            'q' => 'required|min:2|max:40',
+            'g' => 'required',
+            'v' => 'required|in:0.2'
+        ]);
+        $q = $request->input('q');
+        $gid = $request->input('g');
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 404);
+
+        $res = Profile::where('username', 'like', "%{$q}%")
+            ->whereNull('profiles.domain')
+            ->join('followers', 'profiles.id', '=', 'followers.profile_id')
+            ->where('followers.following_id', $pid)
+            ->take(10)
+            ->get()
+            ->filter(function($p) use($group) {
+                return $group->isMember($p->profile_id) == false;
+            })
+            ->filter(function($p) use($group, $pid) {
+                return GroupInvitation::whereGroupId($group->id)
+                    ->whereFromProfileId($pid)
+                    ->whereToProfileId($p->profile_id)
+                    ->exists() == false;
+            })
+            ->map(function($gm) use ($gid) {
+                $a = AccountService::get($gm->profile_id);
+                return [
+                    'id' => (string) $gm->profile_id,
+                    'username' => $a['acct'],
+                    'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search")
+                ];
+            })
+            ->values();
+
+        return $res;
+    }
+
+    public function searchGlobalResults(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $this->validate($request, [
+            'q' => 'required|min:2|max:140',
+            'v' => 'required|in:0.2'
+        ]);
+        $q = $request->input('q');
+
+        if(str_starts_with($q, 'https://')) {
+            $res = Helpers::getSignedFetch($q);
+            if($res && $res = json_decode($res, true)) {
+
+            }
+            if($res && isset($res['type']) && in_array($res['type'], ['Group', 'Note', 'Page'])) {
+                if($res['type'] === 'Group') {
+                    return GroupActivityPubService::fetchGroup($q, true);
+                }
+                $resp = GroupActivityPubService::fetchGroupPost($q, true);
+                $resp['name'] = 'Group Post';
+                $resp['url'] = '/groups/' . $resp['group_id'] . '/p/' . $resp['id'];
+                return [$resp];
+            }
+        }
+        return Group::whereNull('status')
+            ->where('name', 'like', '%' . $q . '%')
+            ->orderBy('id')
+            ->take(10)
+            ->pluck('id')
+            ->map(function($group) {
+                return GroupService::get($group);
+            });
+    }
+
+    public function searchLocalAutocomplete(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+        $this->validate($request, [
+            'q' => 'required|min:2|max:40',
+            'g' => 'required',
+            'v' => 'required|in:0.2'
+        ]);
+        $q = $request->input('q');
+        $gid = $request->input('g');
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 404);
+
+        $res = GroupMember::whereGroupId($gid)
+            ->join('profiles', 'group_members.profile_id', '=', 'profiles.id')
+            ->where('profiles.username', 'like', "%{$q}%")
+            ->take(10)
+            ->get()
+            ->map(function($gm) use ($gid) {
+                $a = AccountService::get($gm->profile_id);
+                return [
+                    'username' => $a['username'],
+                    'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search")
+                ];
+            });
+        return $res;
+    }
+
+    public function searchAddRecent(Request $request)
+    {
+        $this->validate($request, [
+            'q' => 'required|min:2|max:40',
+            'g' => 'required',
+        ]);
+        $q = $request->input('q');
+        $gid = $request->input('g');
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 404);
+
+        $key = 'groups:search:recent:'.$gid.':pid:'.$pid;
+        $ttl = now()->addDays(14);
+        $res = Cache::get($key);
+        if(!$res) {
+            $val = json_encode([$q]);
+        } else {
+            $ex = collect(json_decode($res))
+                ->prepend($q)
+                ->unique('value')
+                ->slice(0, 3)
+                ->values()
+                ->all();
+            $val = json_encode($ex);
+        }
+        Cache::put($key, $val, $ttl);
+        return 200;
+    }
+
+    public function searchGetRecent(Request $request)
+    {
+        $gid = $request->input('g');
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        abort_if(!$group->isMember($pid), 404);
+        $key = 'groups:search:recent:'.$gid.':pid:'.$pid;
+        return Cache::get($key);
+    }
+}

+ 133 - 0
app/Http/Controllers/Groups/GroupsTopicController.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupHashtag;
+use App\Models\GroupInvitation;
+use App\Models\GroupMember;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupPost;
+
+class GroupsTopicController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function groupTopics(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+        ]);
+
+        abort_if(!$request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $posts = GroupPostHashtag::join('group_hashtags', 'group_hashtags.id', '=', 'group_post_hashtags.hashtag_id')
+            ->selectRaw('group_hashtags.*, group_post_hashtags.*, count(group_post_hashtags.hashtag_id) as ht_count')
+            ->where('group_post_hashtags.group_id', $gid)
+            ->orderByDesc('ht_count')
+            ->limit(10)
+            ->pluck('group_post_hashtags.hashtag_id', 'ht_count')
+            ->map(function($id, $key) use ($gid) {
+                $tag = GroupHashtag::find($id);
+                return [
+                    'hid' => $id,
+                    'name' => $tag->name,
+                    'url' => url("/groups/{$gid}/topics/{$tag->slug}"),
+                    'count' => $key
+                ];
+            })->values();
+
+        return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function groupTopicTag(Request $request)
+    {
+        $this->validate($request, [
+            'gid' => 'required',
+            'name' => 'required'
+        ]);
+
+        abort_if(!$request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $gid = $request->input('gid');
+        $limit = $request->input('limit', 3);
+        $group = Group::findOrFail($gid);
+
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+
+        $name = $request->input('name');
+        $hashtag = GroupHashtag::whereName($name)->first();
+
+        if(!$hashtag) {
+            return [];
+        }
+
+        // $posts = GroupPost::whereGroupId($gid)
+        //  ->select('status_hashtags.*', 'group_posts.*')
+        //  ->where('status_hashtags.hashtag_id', $hashtag->id)
+        //  ->join('status_hashtags', 'group_posts.status_id', '=', 'status_hashtags.status_id')
+        //  ->orderByDesc('group_posts.status_id')
+        //  ->simplePaginate($limit)
+        //  ->map(function($gp) use($pid) {
+        //      $status = StatusService::get($gp['status_id'], false);
+        //      if(!$status) {
+        //          return false;
+        //      }
+        //      $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']);
+        //      $status['favourites_count'] = LikeService::count($gp['status_id']);
+        //      $status['pf_type'] = $gp['type'];
+        //      $status['visibility'] = 'public';
+        //      $status['url'] = $gp->url();
+        //      return $status;
+        //  });
+
+        $posts = GroupPostHashtag::whereGroupId($gid)
+            ->whereHashtagId($hashtag->id)
+            ->orderByDesc('id')
+            ->simplePaginate($limit)
+            ->map(function($gp) use($pid) {
+                $status = GroupPostService::get($gp['group_id'], $gp['status_id']);
+                if(!$status) {
+                    return false;
+                }
+                $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['status_id']);
+                $status['favourites_count'] = GroupsLikeService::count($gp['status_id']);
+                $status['pf_type'] = $status['pf_type'];
+                $status['visibility'] = 'public';
+                return $status;
+            });
+
+        return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function showTopicFeed(Request $request, $gid, $tag)
+    {
+        abort_if(!$request->user(), 404);
+
+        $pid = $request->user()->profile_id;
+        $group = Group::findOrFail($gid);
+        $gid = $group->id;
+        abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+        return view('groups.topic-feed', compact('gid', 'tag'));
+    }
+}

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

@@ -17,7 +17,7 @@ trait Instagram
 {
 	public function instagram()
 	{
-		if(config_cache('pixelfed.import.instagram.enabled') != true) {
+		if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
 			abort(404, 'Feature not enabled');
 		}
 		return view('settings.import.instagram.home');
@@ -25,6 +25,9 @@ trait Instagram
 
     public function instagramStart(Request $request)
     {	
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
         $completed = ImportJob::whereProfileId(Auth::user()->profile->id)
             ->whereService('instagram')
             ->whereNotNull('completed_at')
@@ -38,6 +41,9 @@ trait Instagram
 
     protected function instagramRedirectOrNew()
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
     	$profile = Auth::user()->profile;
     	$exists = ImportJob::whereProfileId($profile->id)
     		->whereService('instagram')
@@ -61,6 +67,9 @@ trait Instagram
 
     public function instagramStepOne(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
     	$profile = Auth::user()->profile;
     	$job = ImportJob::whereProfileId($profile->id)
     		->whereNull('completed_at')
@@ -72,6 +81,9 @@ trait Instagram
 
     public function instagramStepOneStore(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
         $max = 'max:' . config('pixelfed.import.instagram.limits.size');
     	$this->validate($request, [
     		'media.*' => 'required|mimes:bin,jpeg,png,gif|'.$max,
@@ -114,6 +126,9 @@ trait Instagram
 
     public function instagramStepTwo(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
     	$profile = Auth::user()->profile;
     	$job = ImportJob::whereProfileId($profile->id)
     		->whereNull('completed_at')
@@ -125,6 +140,9 @@ trait Instagram
 
     public function instagramStepTwoStore(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
     	$this->validate($request, [
     		'media' => 'required|file|max:1000'
     	]);
@@ -150,6 +168,9 @@ trait Instagram
 
     public function instagramStepThree(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
     	$profile = Auth::user()->profile;
     	$job = ImportJob::whereProfileId($profile->id)
             ->whereService('instagram')
@@ -162,6 +183,9 @@ trait Instagram
 
     public function instagramStepThreeStore(Request $request, $uuid)
     {
+        if((bool) config_cache('pixelfed.import.instagram.enabled') != true) {
+            abort(404, 'Feature not enabled');
+        }
         $profile = Auth::user()->profile;
 
         try {

+ 14 - 3
app/Http/Controllers/ImportPostController.php

@@ -83,6 +83,17 @@ class ImportPostController extends Controller
         );
     }
 
+    public function formatHashtags($val = false)
+    {
+        if(!$val || !strlen($val)) {
+            return null;
+        }
+
+        $groupedHashtagRegex = '/#\w+(?=#)/';
+
+        return preg_replace($groupedHashtagRegex, '$0 ', $val);
+    }
+
     public function store(Request $request)
     {
         abort_unless(config('import.instagram.enabled'), 404);
@@ -128,11 +139,11 @@ class ImportPostController extends Controller
             $ip->media = $c->map(function($m) {
                 return [
                     'uri' => $m['uri'],
-                    'title' => $m['title'],
+                    'title' => $this->formatHashtags($m['title']),
                     'creation_timestamp' => $m['creation_timestamp']
                 ];
             })->toArray();
-            $ip->caption = $c->count() > 1 ? $file['title'] : $ip->media[0]['title'];
+            $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
             $ip->filename = last(explode('/', $ip->media[0]['uri']));
             $ip->metadata = $c->map(function($m) {
                 return [
@@ -168,7 +179,7 @@ class ImportPostController extends Controller
                 'required',
                 'file',
                 $mimes,
-                'max:' . config('pixelfed.max_photo_size')
+                'max:' . config_cache('pixelfed.max_photo_size')
             ]
         ]);
 

+ 412 - 430
app/Http/Controllers/InternalApiController.php

@@ -2,442 +2,424 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use App\{
-	AccountInterstitial,
-	Bookmark,
-	DirectMessage,
-	DiscoverCategory,
-	Hashtag,
-	Follower,
-	Like,
-	Media,
-	MediaTag,
-	Notification,
-	Profile,
-	StatusHashtag,
-	Status,
-	User,
-	UserFilter,
-};
-use Auth,Cache;
-use Illuminate\Support\Facades\Redis;
-use Carbon\Carbon;
-use League\Fractal;
-use App\Transformer\Api\{
-	AccountTransformer,
-	StatusTransformer,
-	// StatusMediaContainerTransformer,
-};
-use App\Util\Media\Filter;
-use App\Jobs\StatusPipeline\NewStatusPipeline;
+use App\AccountInterstitial;
+use App\Bookmark;
+use App\DirectMessage;
+use App\DiscoverCategory;
+use App\Follower;
 use App\Jobs\ModPipeline\HandleSpammerPipeline;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
-use Illuminate\Validation\Rule;
-use Illuminate\Support\Str;
-use App\Services\MediaTagService;
+use App\Profile;
+use App\Services\BookmarkService;
+use App\Services\DiscoverService;
 use App\Services\ModLogService;
 use App\Services\PublicTimelineService;
-use App\Services\SnowflakeService;
 use App\Services\StatusService;
 use App\Services\UserFilterService;
-use App\Services\DiscoverService;
-use App\Services\BookmarkService;
+use App\Status; // StatusMediaContainerTransformer,
+use App\Transformer\Api\StatusTransformer;
+use App\User;
+use Auth;
+use Cache;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Validation\Rule;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 
 class InternalApiController extends Controller
 {
-	protected $fractal;
-
-	public function __construct()
-	{
-		$this->middleware('auth');
-		$this->fractal = new Fractal\Manager();
-		$this->fractal->setSerializer(new ArraySerializer());
-	}
-
-	// deprecated v2 compose api
-	public function compose(Request $request)
-	{
-		return redirect('/');
-	}
-
-	// deprecated
-	public function discover(Request $request)
-	{
-		return;
-	}
-
-	public function discoverPosts(Request $request)
-	{
-		$pid = $request->user()->profile_id;
-		$filters = UserFilterService::filters($pid);
-		$forYou = DiscoverService::getForYou();
-		$posts = $forYou->take(50)->map(function($post) {
-			return StatusService::get($post);
-		})
-		->filter(function($post) use($filters) {
-			return $post &&
-				isset($post['account']) &&
-				isset($post['account']['id']) &&
-				!in_array($post['account']['id'], $filters);
-		})
-		->take(12)
-		->values();
-		return response()->json(compact('posts'));
-	}
-
-	public function directMessage(Request $request, $profileId, $threadId)
-	{
-		$profile = Auth::user()->profile;
-
-		if($profileId != $profile->id) {
-			abort(403);
-		}
-
-		$msg = DirectMessage::whereToId($profile->id)
-			->orWhere('from_id',$profile->id)
-			->findOrFail($threadId);
-
-		$thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
-			->whereIn('from_id', [$profile->id,$msg->from_id])
-			->orderBy('created_at', 'asc')
-			->paginate(30);
-
-		return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
-	}
-
-	public function statusReplies(Request $request, int $id)
-	{
-		$this->validate($request, [
-			'limit' => 'nullable|int|min:1|max:6'
-		]);
-		$parent = Status::whereScope('public')->findOrFail($id);
-		$limit = $request->input('limit') ?? 3;
-		$children = Status::whereInReplyToId($parent->id)
-			->orderBy('created_at', 'desc')
-			->take($limit)
-			->get();
-		$resource = new Fractal\Resource\Collection($children, new StatusTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-
-		return response()->json($res);
-	}
-
-	public function stories(Request $request)
-	{
-
-	}
-
-	public function discoverCategories(Request $request)
-	{
-		$categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
-		$res = $categories->map(function($item) {
-			return [
-				'name' => $item->name,
-				'url' => $item->url(),
-				'thumb' => $item->thumb()
-			];
-		});
-		return response()->json($res);
-	}
-
-	public function modAction(Request $request)
-	{
-		abort_unless(Auth::user()->is_admin, 400);
-		$this->validate($request, [
-			'action' => [
-				'required',
-				'string',
-				Rule::in([
-					'addcw',
-					'remcw',
-					'unlist',
-					'spammer'
-				])
-			],
-			'item_id' => 'required|integer|min:1',
-			'item_type' => [
-				'required',
-				'string',
-				Rule::in(['profile', 'status'])
-			]
-		]);
-
-		$action = $request->input('action');
-		$item_id = $request->input('item_id');
-		$item_type = $request->input('item_type');
-
-		$status = Status::findOrFail($item_id);
-		$author = User::whereProfileId($status->profile_id)->first();
-		abort_if($author && $author->is_admin, 422, 'Cannot moderate administrator accounts');
-
-		switch($action) {
-			case 'addcw':
-				$status->is_nsfw = true;
-				$status->save();
-				ModLogService::boot()
-					->user(Auth::user())
-					->objectUid($status->profile->user_id)
-					->objectId($status->id)
-					->objectType('App\Status::class')
-					->action('admin.status.moderate')
-					->metadata([
-						'action' => 'cw',
-						'message' => 'Success!'
-					])
-					->accessLevel('admin')
-					->save();
-
-				if($status->uri == null) {
-					$media = $status->media;
-					$ai = new AccountInterstitial;
-					$ai->user_id = $status->profile->user_id;
-					$ai->type = 'post.cw';
-					$ai->view = 'account.moderation.post.cw';
-					$ai->item_type = 'App\Status';
-					$ai->item_id = $status->id;
-					$ai->has_media = (bool) $media->count();
-					$ai->blurhash = $media->count() ? $media->first()->blurhash : null;
-					$ai->meta = json_encode([
-						'caption' => $status->caption,
-						'created_at' => $status->created_at,
-						'type' => $status->type,
-						'url' => $status->url(),
-						'is_nsfw' => $status->is_nsfw,
-						'scope' => $status->scope,
-						'reblog' => $status->reblog_of_id,
-						'likes_count' => $status->likes_count,
-						'reblogs_count' => $status->reblogs_count,
-					]);
-					$ai->save();
-
-					$u = $status->profile->user;
-					$u->has_interstitial = true;
-					$u->save();
-				}
-			break;
-
-			case 'remcw':
-				$status->is_nsfw = false;
-				$status->save();
-				ModLogService::boot()
-					->user(Auth::user())
-					->objectUid($status->profile->user_id)
-					->objectId($status->id)
-					->objectType('App\Status::class')
-					->action('admin.status.moderate')
-					->metadata([
-						'action' => 'remove_cw',
-						'message' => 'Success!'
-					])
-					->accessLevel('admin')
-					->save();
-				if($status->uri == null) {
-					$ai = AccountInterstitial::whereUserId($status->profile->user_id)
-						->whereType('post.cw')
-						->whereItemId($status->id)
-						->whereItemType('App\Status')
-						->first();
-					$ai->delete();
-				}
-			break;
-
-			case 'unlist':
-				$status->scope = $status->visibility = 'unlisted';
-				$status->save();
-				PublicTimelineService::del($status->id);
-				ModLogService::boot()
-					->user(Auth::user())
-					->objectUid($status->profile->user_id)
-					->objectId($status->id)
-					->objectType('App\Status::class')
-					->action('admin.status.moderate')
-					->metadata([
-						'action' => 'unlist',
-						'message' => 'Success!'
-					])
-					->accessLevel('admin')
-					->save();
-
-				if($status->uri == null) {
-					$media = $status->media;
-					$ai = new AccountInterstitial;
-					$ai->user_id = $status->profile->user_id;
-					$ai->type = 'post.unlist';
-					$ai->view = 'account.moderation.post.unlist';
-					$ai->item_type = 'App\Status';
-					$ai->item_id = $status->id;
-					$ai->has_media = (bool) $media->count();
-					$ai->blurhash = $media->count() ? $media->first()->blurhash : null;
-					$ai->meta = json_encode([
-						'caption' => $status->caption,
-						'created_at' => $status->created_at,
-						'type' => $status->type,
-						'url' => $status->url(),
-						'is_nsfw' => $status->is_nsfw,
-						'scope' => $status->scope,
-						'reblog' => $status->reblog_of_id,
-						'likes_count' => $status->likes_count,
-						'reblogs_count' => $status->reblogs_count,
-					]);
-					$ai->save();
-
-					$u = $status->profile->user;
-					$u->has_interstitial = true;
-					$u->save();
-				}
-			break;
-
-			case 'spammer':
-				HandleSpammerPipeline::dispatch($status->profile);
-				ModLogService::boot()
-					->user(Auth::user())
-					->objectUid($status->profile->user_id)
-					->objectId($status->id)
-					->objectType('App\User::class')
-					->action('admin.status.moderate')
-					->metadata([
-						'action' => 'spammer',
-						'message' => 'Success!'
-					])
-					->accessLevel('admin')
-					->save();
-			break;
-		}
-
-		StatusService::del($status->id, true);
-		return ['msg' => 200];
-	}
-
-	public function composePost(Request $request)
-	{
-		abort(400, 'Endpoint deprecated');
-	}
-
-	public function bookmarks(Request $request)
-	{
-		$pid = $request->user()->profile_id;
-		$res = Bookmark::whereProfileId($pid)
-			->orderByDesc('created_at')
-			->simplePaginate(10)
-			->map(function($bookmark) use($pid) {
-				$status = StatusService::get($bookmark->status_id, false);
-				if(!$status) {
-					return false;
-				}
-				$status['bookmarked_at'] = str_replace('+00:00', 'Z', $bookmark->created_at->format(DATE_RFC3339_EXTENDED));
-
-				if($status) {
-					BookmarkService::add($pid, $status['id']);
-				}
-				return $status;
-			})
-			->filter(function($bookmark) {
-				return $bookmark && isset($bookmark['id']);
-			})
-			->values();
-
-		return response()->json($res);
-	}
-
-	public function accountStatuses(Request $request, $id)
-	{
-		$this->validate($request, [
-			'only_media' => 'nullable',
-			'pinned' => 'nullable',
-			'exclude_replies' => 'nullable',
-			'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-			'limit' => 'nullable|integer|min:1|max:24'
-		]);
-
-		$profile = Profile::whereNull('status')->findOrFail($id);
-
-		$limit = $request->limit ?? 9;
-		$max_id = $request->max_id;
-		$min_id = $request->min_id;
-		$scope = $request->only_media == true ?
-			['photo', 'photo:album', 'video', 'video:album'] :
-			['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
-
-		if($profile->is_private) {
-			if(!Auth::check()) {
-				return response()->json([]);
-			}
-			$pid = Auth::user()->profile->id;
-			$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
-				$following = Follower::whereProfileId($pid)->pluck('following_id');
-				return $following->push($pid)->toArray();
-			});
-			$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
-		} else {
-			if(Auth::check()) {
-				$pid = Auth::user()->profile->id;
-				$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
-					$following = Follower::whereProfileId($pid)->pluck('following_id');
-					return $following->push($pid)->toArray();
-				});
-				$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
-			} else {
-				$visibility = ['public', 'unlisted'];
-			}
-		}
-
-		$dir = $min_id ? '>' : '<';
-		$id = $min_id ?? $max_id;
-		$timeline = Status::select(
-			'id',
-			'uri',
-			'caption',
-			'rendered',
-			'profile_id',
-			'type',
-			'in_reply_to_id',
-			'reblog_of_id',
-			'is_nsfw',
-			'likes_count',
-			'reblogs_count',
-			'scope',
-			'local',
-			'created_at',
-			'updated_at'
-		  )->whereProfileId($profile->id)
-		  ->whereIn('type', $scope)
-		  ->where('id', $dir, $id)
-		  ->whereIn('visibility', $visibility)
-		  ->latest()
-		  ->limit($limit)
-		  ->get();
-
-		$resource = new Fractal\Resource\Collection($timeline, new StatusTransformer());
-		$res = $this->fractal->createData($resource)->toArray();
-
-		return response()->json($res);
-	}
-
-	public function remoteProfile(Request $request, $id)
-	{
-		return redirect('/i/web/profile/' . $id);
-	}
-
-	public function remoteStatus(Request $request, $profileId, $statusId)
-	{
-		return redirect('/i/web/post/' . $statusId);
-	}
-
-	public function requestEmailVerification(Request $request)
-	{
-		$pid = $request->user()->profile_id;
-		$exists = Redis::sismember('email:manual', $pid);
-		return view('account.email.request_verification', compact('exists'));
-	}
-
-	public function requestEmailVerificationStore(Request $request)
-	{
-		$pid = $request->user()->profile_id;
-		Redis::sadd('email:manual', $pid);
-		return redirect('/i/verify-email')->with(['status' => 'Successfully sent manual verification request!']);
-	}
+    protected $fractal;
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
+    }
+
+    // deprecated v2 compose api
+    public function compose(Request $request)
+    {
+        return redirect('/');
+    }
+
+    // deprecated
+    public function discover(Request $request) {}
+
+    public function discoverPosts(Request $request)
+    {
+        $pid = $request->user()->profile_id;
+        $filters = UserFilterService::filters($pid);
+        $forYou = DiscoverService::getForYou();
+        $posts = $forYou->take(50)->map(function ($post) {
+            return StatusService::get($post);
+        })
+            ->filter(function ($post) use ($filters) {
+                return $post &&
+                    isset($post['account']) &&
+                    isset($post['account']['id']) &&
+                    ! in_array($post['account']['id'], $filters);
+            })
+            ->take(12)
+            ->values();
+
+        return response()->json(compact('posts'));
+    }
+
+    public function directMessage(Request $request, $profileId, $threadId)
+    {
+        $profile = Auth::user()->profile;
+
+        if ($profileId != $profile->id) {
+            abort(403);
+        }
+
+        $msg = DirectMessage::whereToId($profile->id)
+            ->orWhere('from_id', $profile->id)
+            ->findOrFail($threadId);
+
+        $thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
+            ->whereIn('from_id', [$profile->id, $msg->from_id])
+            ->orderBy('created_at', 'asc')
+            ->paginate(30);
+
+        return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function statusReplies(Request $request, int $id)
+    {
+        $this->validate($request, [
+            'limit' => 'nullable|int|min:1|max:6',
+        ]);
+        $parent = Status::whereScope('public')->findOrFail($id);
+        $limit = $request->input('limit') ?? 3;
+        $children = Status::whereInReplyToId($parent->id)
+            ->orderBy('created_at', 'desc')
+            ->take($limit)
+            ->get();
+        $resource = new Fractal\Resource\Collection($children, new StatusTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function stories(Request $request) {}
+
+    public function discoverCategories(Request $request)
+    {
+        $categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
+        $res = $categories->map(function ($item) {
+            return [
+                'name' => $item->name,
+                'url' => $item->url(),
+                'thumb' => $item->thumb(),
+            ];
+        });
+
+        return response()->json($res);
+    }
+
+    public function modAction(Request $request)
+    {
+        abort_unless(Auth::user()->is_admin, 400);
+        $this->validate($request, [
+            'action' => [
+                'required',
+                'string',
+                Rule::in([
+                    'addcw',
+                    'remcw',
+                    'unlist',
+                    'spammer',
+                ]),
+            ],
+            'item_id' => 'required|integer|min:1',
+            'item_type' => [
+                'required',
+                'string',
+                Rule::in(['profile', 'status']),
+            ],
+        ]);
+
+        $action = $request->input('action');
+        $item_id = $request->input('item_id');
+        $item_type = $request->input('item_type');
+
+        $status = Status::findOrFail($item_id);
+        $author = User::whereProfileId($status->profile_id)->first();
+        abort_if($author && $author->is_admin, 422, 'Cannot moderate administrator accounts');
+
+        switch ($action) {
+            case 'addcw':
+                $status->is_nsfw = true;
+                $status->save();
+                ModLogService::boot()
+                    ->user(Auth::user())
+                    ->objectUid($status->profile->user_id)
+                    ->objectId($status->id)
+                    ->objectType('App\Status::class')
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'cw',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                if ($status->uri == null) {
+                    $media = $status->media;
+                    $ai = new AccountInterstitial;
+                    $ai->user_id = $status->profile->user_id;
+                    $ai->type = 'post.cw';
+                    $ai->view = 'account.moderation.post.cw';
+                    $ai->item_type = 'App\Status';
+                    $ai->item_id = $status->id;
+                    $ai->has_media = (bool) $media->count();
+                    $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
+                    $ai->meta = json_encode([
+                        'caption' => $status->caption,
+                        'created_at' => $status->created_at,
+                        'type' => $status->type,
+                        'url' => $status->url(),
+                        'is_nsfw' => $status->is_nsfw,
+                        'scope' => $status->scope,
+                        'reblog' => $status->reblog_of_id,
+                        'likes_count' => $status->likes_count,
+                        'reblogs_count' => $status->reblogs_count,
+                    ]);
+                    $ai->save();
+
+                    $u = $status->profile->user;
+                    $u->has_interstitial = true;
+                    $u->save();
+                }
+                break;
+
+            case 'remcw':
+                $status->is_nsfw = false;
+                $status->save();
+                ModLogService::boot()
+                    ->user(Auth::user())
+                    ->objectUid($status->profile->user_id)
+                    ->objectId($status->id)
+                    ->objectType('App\Status::class')
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'remove_cw',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+                if ($status->uri == null) {
+                    $ai = AccountInterstitial::whereUserId($status->profile->user_id)
+                        ->whereType('post.cw')
+                        ->whereItemId($status->id)
+                        ->whereItemType('App\Status')
+                        ->first();
+                    $ai->delete();
+                }
+                break;
+
+            case 'unlist':
+                $status->scope = $status->visibility = 'unlisted';
+                $status->save();
+                PublicTimelineService::del($status->id);
+                ModLogService::boot()
+                    ->user(Auth::user())
+                    ->objectUid($status->profile->user_id)
+                    ->objectId($status->id)
+                    ->objectType('App\Status::class')
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'unlist',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+
+                if ($status->uri == null) {
+                    $media = $status->media;
+                    $ai = new AccountInterstitial;
+                    $ai->user_id = $status->profile->user_id;
+                    $ai->type = 'post.unlist';
+                    $ai->view = 'account.moderation.post.unlist';
+                    $ai->item_type = 'App\Status';
+                    $ai->item_id = $status->id;
+                    $ai->has_media = (bool) $media->count();
+                    $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
+                    $ai->meta = json_encode([
+                        'caption' => $status->caption,
+                        'created_at' => $status->created_at,
+                        'type' => $status->type,
+                        'url' => $status->url(),
+                        'is_nsfw' => $status->is_nsfw,
+                        'scope' => $status->scope,
+                        'reblog' => $status->reblog_of_id,
+                        'likes_count' => $status->likes_count,
+                        'reblogs_count' => $status->reblogs_count,
+                    ]);
+                    $ai->save();
+
+                    $u = $status->profile->user;
+                    $u->has_interstitial = true;
+                    $u->save();
+                }
+                break;
+
+            case 'spammer':
+                HandleSpammerPipeline::dispatch($status->profile);
+                ModLogService::boot()
+                    ->user(Auth::user())
+                    ->objectUid($status->profile->user_id)
+                    ->objectId($status->id)
+                    ->objectType('App\User::class')
+                    ->action('admin.status.moderate')
+                    ->metadata([
+                        'action' => 'spammer',
+                        'message' => 'Success!',
+                    ])
+                    ->accessLevel('admin')
+                    ->save();
+                break;
+        }
+
+        StatusService::del($status->id, true);
+
+        return ['msg' => 200];
+    }
+
+    public function composePost(Request $request)
+    {
+        abort(400, 'Endpoint deprecated');
+    }
+
+    public function bookmarks(Request $request)
+    {
+        $pid = $request->user()->profile_id;
+        $res = Bookmark::whereProfileId($pid)
+            ->orderByDesc('created_at')
+            ->simplePaginate(10)
+            ->map(function ($bookmark) use ($pid) {
+                $status = StatusService::get($bookmark->status_id, false);
+                if (! $status) {
+                    return false;
+                }
+                $status['bookmarked_at'] = str_replace('+00:00', 'Z', $bookmark->created_at->format(DATE_RFC3339_EXTENDED));
+
+                if ($status) {
+                    BookmarkService::add($pid, $status['id']);
+                }
+
+                return $status;
+            })
+            ->filter(function ($bookmark) {
+                return $bookmark && isset($bookmark['id']);
+            })
+            ->values();
+
+        return response()->json($res);
+    }
+
+    public function accountStatuses(Request $request, $id)
+    {
+        $this->validate($request, [
+            'only_media' => 'nullable',
+            'pinned' => 'nullable',
+            'exclude_replies' => 'nullable',
+            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
+            'limit' => 'nullable|integer|min:1|max:24',
+        ]);
+
+        $profile = Profile::whereNull('status')->findOrFail($id);
+
+        $limit = $request->limit ?? 9;
+        $max_id = $request->max_id;
+        $min_id = $request->min_id;
+        $scope = $request->only_media == true ?
+            ['photo', 'photo:album', 'video', 'video:album'] :
+            ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
+
+        if ($profile->is_private) {
+            if (! Auth::check()) {
+                return response()->json([]);
+            }
+            $pid = Auth::user()->profile->id;
+            $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
+                $following = Follower::whereProfileId($pid)->pluck('following_id');
+
+                return $following->push($pid)->toArray();
+            });
+            $visibility = in_array($profile->id, $following) == true ? ['public', 'unlisted', 'private'] : [];
+        } else {
+            if (Auth::check()) {
+                $pid = Auth::user()->profile->id;
+                $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
+                    $following = Follower::whereProfileId($pid)->pluck('following_id');
+
+                    return $following->push($pid)->toArray();
+                });
+                $visibility = in_array($profile->id, $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+            } else {
+                $visibility = ['public', 'unlisted'];
+            }
+        }
+
+        $dir = $min_id ? '>' : '<';
+        $id = $min_id ?? $max_id;
+        $timeline = Status::select(
+            'id',
+            'uri',
+            'caption',
+            'profile_id',
+            'type',
+            'in_reply_to_id',
+            'reblog_of_id',
+            'is_nsfw',
+            'likes_count',
+            'reblogs_count',
+            'scope',
+            'local',
+            'created_at',
+            'updated_at'
+        )->whereProfileId($profile->id)
+            ->whereIn('type', $scope)
+            ->where('id', $dir, $id)
+            ->whereIn('visibility', $visibility)
+            ->latest()
+            ->limit($limit)
+            ->get();
+
+        $resource = new Fractal\Resource\Collection($timeline, new StatusTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function remoteProfile(Request $request, $id)
+    {
+        return redirect('/i/web/profile/'.$id);
+    }
+
+    public function remoteStatus(Request $request, $profileId, $statusId)
+    {
+        return redirect('/i/web/post/'.$statusId);
+    }
+
+    public function requestEmailVerification(Request $request)
+    {
+        $pid = $request->user()->profile_id;
+        $exists = Redis::sismember('email:manual', $pid);
+
+        return view('account.email.request_verification', compact('exists'));
+    }
+
+    public function requestEmailVerificationStore(Request $request)
+    {
+        $pid = $request->user()->profile_id;
+        Redis::sadd('email:manual', $pid);
+
+        return redirect('/i/verify-email')->with(['status' => 'Successfully sent manual verification request!']);
+    }
 }

+ 20 - 21
app/Http/Controllers/LandingController.php

@@ -2,44 +2,43 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use App\Profile;
-use App\Services\AccountService;
 use App\Http\Resources\DirectoryProfile;
+use App\Profile;
+use Illuminate\Http\Request;
 
 class LandingController extends Controller
 {
     public function directoryRedirect(Request $request)
     {
-    	if($request->user()) {
-    		return redirect('/');
-    	}
+        if ($request->user()) {
+            return redirect('/');
+        }
 
-    	abort_if(config_cache('instance.landing.show_directory') == false, 404);
+        abort_if((bool) config_cache('instance.landing.show_directory') == false, 404);
 
-    	return view('site.index');
+        return view('site.index');
     }
 
     public function exploreRedirect(Request $request)
     {
-    	if($request->user()) {
-    		return redirect('/');
-    	}
+        if ($request->user()) {
+            return redirect('/');
+        }
 
-    	abort_if(config_cache('instance.landing.show_explore') == false, 404);
+        abort_if((bool) config_cache('instance.landing.show_explore') == false, 404);
 
-    	return view('site.index');
+        return view('site.index');
     }
 
     public function getDirectoryApi(Request $request)
     {
-    	abort_if(config_cache('instance.landing.show_directory') == false, 404);
-
-    	return DirectoryProfile::collection(
-    		Profile::whereNull('domain')
-    		->whereIsSuggestable(true)
-    		->orderByDesc('updated_at')
-    		->cursorPaginate(20)
-    	);
+        abort_if((bool) config_cache('instance.landing.show_directory') == false, 404);
+
+        return DirectoryProfile::collection(
+            Profile::whereNull('domain')
+                ->whereIsSuggestable(true)
+                ->orderByDesc('updated_at')
+                ->cursorPaginate(20)
+        );
     }
 }

+ 19 - 18
app/Http/Controllers/MediaController.php

@@ -2,30 +2,31 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
 use App\Media;
+use Illuminate\Http\Request;
 
 class MediaController extends Controller
 {
-	public function index(Request $request)
-	{
-		//return view('settings.drive.index');
-	}
+    public function index(Request $request)
+    {
+        //return view('settings.drive.index');
+        abort(404);
+    }
 
-	public function composeUpdate(Request $request, $id)
-	{
+    public function composeUpdate(Request $request, $id)
+    {
         abort(400, 'Endpoint deprecated');
-	}	
+    }
 
-	public function fallbackRedirect(Request $request, $pid, $mhash, $uhash, $f)
-	{
-		abort_if(!config_cache('pixelfed.cloud_storage'), 404);
-		$path = 'public/m/_v2/' . $pid . '/' . $mhash . '/' . $uhash . '/' . $f;
-		$media = Media::whereProfileId($pid)
-			->whereMediaPath($path)
-			->whereNotNull('cdn_url')
-			->firstOrFail();
+    public function fallbackRedirect(Request $request, $pid, $mhash, $uhash, $f)
+    {
+        abort_if(! (bool) config_cache('pixelfed.cloud_storage'), 404);
+        $path = 'public/m/_v2/'.$pid.'/'.$mhash.'/'.$uhash.'/'.$f;
+        $media = Media::whereProfileId($pid)
+            ->whereMediaPath($path)
+            ->whereNotNull('cdn_url')
+            ->firstOrFail();
 
-		return redirect()->away($media->cdn_url);
-	}
+        return redirect()->away($media->cdn_url);
+    }
 }

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels