Преглед изворни кода

Merge branch 'staging' into feat/implement-admin-domain-blocks-api

daniel пре 11 месеци
родитељ
комит
1dbcdee289
100 измењених фајлова са 10476 додато и 4725 уклоњено
  1. 9 5
      .circleci/config.yml
  2. 25 1
      .env.docker
  3. 79 0
      .env.example
  4. 1 0
      .hadolint.yaml
  5. 113 2
      CHANGELOG.md
  6. 55 1
      Dockerfile
  7. 5 5
      app/Console/Commands/AvatarStorage.php
  8. 1 1
      app/Console/Commands/AvatarStorageDeepClean.php
  9. 52 0
      app/Console/Commands/CaptchaToggleCommand.php
  10. 5 1
      app/Console/Commands/CloudMediaMigrate.php
  11. 51 0
      app/Console/Commands/DeleteRemoteProfile.php
  12. 7 7
      app/Console/Commands/FetchMissingMediaMimeType.php
  13. 1 1
      app/Console/Commands/FixMediaDriver.php
  14. 79 0
      app/Console/Commands/InstanceUpdateTotalLocalPosts.php
  15. 1 1
      app/Console/Commands/MediaCloudUrlRewrite.php
  16. 1 1
      app/Console/Commands/MediaS3GarbageCollector.php
  17. 47 0
      app/Console/Commands/WeeklyInstanceScan.php
  18. 4 3
      app/Console/Kernel.php
  19. 2 2
      app/Http/Controllers/AccountController.php
  20. 94 96
      app/Http/Controllers/Admin/AdminDirectoryController.php
  21. 49 0
      app/Http/Controllers/Admin/AdminGroupsController.php
  22. 572 0
      app/Http/Controllers/Admin/AdminSettingsController.php
  23. 579 552
      app/Http/Controllers/AdminController.php
  24. 6 1
      app/Http/Controllers/AdminCuratedRegisterController.php
  25. 149 137
      app/Http/Controllers/Api/AdminApiController.php
  26. 185 74
      app/Http/Controllers/Api/ApiV1Controller.php
  27. 889 876
      app/Http/Controllers/Api/ApiV1Dot1Controller.php
  28. 78 79
      app/Http/Controllers/Api/ApiV2Controller.php
  29. 4 6
      app/Http/Controllers/Api/InstanceApiController.php
  30. 1 1
      app/Http/Controllers/Api/V1/DomainBlockController.php
  31. 1 1
      app/Http/Controllers/Auth/ForgotPasswordController.php
  32. 3 3
      app/Http/Controllers/Auth/LoginController.php
  33. 227 222
      app/Http/Controllers/Auth/RegisterController.php
  34. 1 1
      app/Http/Controllers/Auth/ResetPasswordController.php
  35. 133 111
      app/Http/Controllers/CollectionController.php
  36. 30 22
      app/Http/Controllers/ComposeController.php
  37. 385 375
      app/Http/Controllers/DirectMessageController.php
  38. 8 0
      app/Http/Controllers/DiscoverController.php
  39. 119 92
      app/Http/Controllers/FederationController.php
  40. 671 0
      app/Http/Controllers/GroupController.php
  41. 103 0
      app/Http/Controllers/GroupFederationController.php
  42. 10 0
      app/Http/Controllers/GroupPostController.php
  43. 83 0
      app/Http/Controllers/Groups/CreateGroupsController.php
  44. 353 0
      app/Http/Controllers/Groups/GroupsAdminController.php
  45. 84 0
      app/Http/Controllers/Groups/GroupsApiController.php
  46. 361 0
      app/Http/Controllers/Groups/GroupsCommentController.php
  47. 57 0
      app/Http/Controllers/Groups/GroupsDiscoverController.php
  48. 188 0
      app/Http/Controllers/Groups/GroupsFeedController.php
  49. 214 0
      app/Http/Controllers/Groups/GroupsMemberController.php
  50. 31 0
      app/Http/Controllers/Groups/GroupsMetaController.php
  51. 55 0
      app/Http/Controllers/Groups/GroupsNotificationsController.php
  52. 420 0
      app/Http/Controllers/Groups/GroupsPostController.php
  53. 221 0
      app/Http/Controllers/Groups/GroupsSearchController.php
  54. 133 0
      app/Http/Controllers/Groups/GroupsTopicController.php
  55. 25 1
      app/Http/Controllers/Import/Instagram.php
  56. 20 21
      app/Http/Controllers/LandingController.php
  57. 19 18
      app/Http/Controllers/MediaController.php
  58. 50 42
      app/Http/Controllers/PixelfedDirectoryController.php
  59. 7 5
      app/Http/Controllers/ProfileController.php
  60. 389 394
      app/Http/Controllers/PublicApiController.php
  61. 90 80
      app/Http/Controllers/RemoteAuthController.php
  62. 353 354
      app/Http/Controllers/SearchController.php
  63. 21 22
      app/Http/Controllers/Settings/HomeSettings.php
  64. 59 39
      app/Http/Controllers/Settings/PrivacySettings.php
  65. 312 305
      app/Http/Controllers/SettingsController.php
  66. 2 2
      app/Http/Controllers/SiteController.php
  67. 66 32
      app/Http/Controllers/StatusController.php
  68. 131 121
      app/Http/Controllers/Stories/StoryApiV1Controller.php
  69. 89 93
      app/Http/Controllers/StoryComposeController.php
  70. 9 9
      app/Http/Controllers/StoryController.php
  71. 1 1
      app/Http/Controllers/UserEmailForgotController.php
  72. 2 2
      app/Http/Kernel.php
  73. 32 31
      app/Http/Requests/Status/StoreStatusEditRequest.php
  74. 9 8
      app/Http/Resources/AdminUser.php
  75. 76 76
      app/Jobs/AvatarPipeline/AvatarOptimize.php
  76. 95 100
      app/Jobs/AvatarPipeline/RemoteAvatarFetch.php
  77. 76 81
      app/Jobs/AvatarPipeline/RemoteAvatarFetchFromUrl.php
  78. 100 99
      app/Jobs/FollowPipeline/UnfollowPipeline.php
  79. 99 0
      app/Jobs/GroupPipeline/GroupCommentPipeline.php
  80. 57 0
      app/Jobs/GroupPipeline/GroupMediaPipeline.php
  81. 54 0
      app/Jobs/GroupPipeline/GroupMemberInvite.php
  82. 54 0
      app/Jobs/GroupPipeline/JoinApproved.php
  83. 50 0
      app/Jobs/GroupPipeline/JoinRejected.php
  84. 107 0
      app/Jobs/GroupPipeline/LikePipeline.php
  85. 130 0
      app/Jobs/GroupPipeline/NewStatusPipeline.php
  86. 109 0
      app/Jobs/GroupPipeline/UnlikePipeline.php
  87. 58 0
      app/Jobs/GroupsPipeline/DeleteCommentPipeline.php
  88. 89 0
      app/Jobs/GroupsPipeline/ImageResizePipeline.php
  89. 67 0
      app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php
  90. 107 0
      app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php
  91. 47 0
      app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php
  92. 42 0
      app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php
  93. 115 0
      app/Jobs/GroupsPipeline/NewCommentPipeline.php
  94. 108 0
      app/Jobs/GroupsPipeline/NewPostPipeline.php
  95. 1 1
      app/Jobs/ImageOptimizePipeline/ImageOptimize.php
  96. 1 1
      app/Jobs/ImageOptimizePipeline/ImageResize.php
  97. 1 1
      app/Jobs/ImageOptimizePipeline/ImageUpdate.php
  98. 2 0
      app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php
  99. 49 46
      app/Jobs/MediaPipeline/MediaDeletePipeline.php
  100. 61 60
      app/Jobs/MediaPipeline/MediaFixLocalFilesystemCleanupPipeline.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:

+ 25 - 1
.env.docker

@@ -60,6 +60,15 @@ ADMIN_DOMAIN="${APP_DOMAIN}"
 # @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"
@@ -1025,7 +1034,7 @@ DOCKER_APP_HOST_CACHE_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/pixelfed/ca
 
 # Automatically run "One-time setup tasks" commands.
 #
-# If you are migrating to this docker-compose setup or have manually run the "One time seutp"
+# 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.
 #
@@ -1122,6 +1131,13 @@ DOCKER_APP_HOST_CACHE_PATH="${DOCKER_ALL_HOST_DATA_ROOT_PATH:?error}/pixelfed/ca
 # @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
 ################################################################################
@@ -1202,6 +1218,14 @@ DOCKER_DB_HOST_PORT="${DB_PORT:?error}"
 # @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}"

+ 79 - 0
.env.example

@@ -0,0 +1,79 @@
+APP_NAME="Pixelfed"
+APP_ENV="production"
+APP_KEY=
+APP_DEBUG="false"
+
+# Instance Configuration
+OPEN_REGISTRATION="false"
+ENFORCE_EMAIL_VERIFICATION="false"
+PF_MAX_USERS="1000"
+OAUTH_ENABLED="true"
+ENABLE_CONFIG_CACHE=true
+
+# Media Configuration
+PF_OPTIMIZE_IMAGES="true"
+IMAGE_QUALITY="80"
+MAX_PHOTO_SIZE="15000"
+MAX_CAPTION_LENGTH="500"
+MAX_ALBUM_LENGTH="4"
+
+# Instance URL Configuration
+APP_URL="http://localhost"
+APP_DOMAIN="localhost"
+ADMIN_DOMAIN="localhost"
+SESSION_DOMAIN="localhost"
+TRUST_PROXIES="*"
+
+# Database Configuration
+DB_CONNECTION="mysql"
+DB_HOST="127.0.0.1"
+DB_PORT="3306"
+DB_DATABASE="pixelfed"
+DB_USERNAME="pixelfed"
+DB_PASSWORD="pixelfed"
+
+# Redis Configuration
+REDIS_CLIENT="predis"
+REDIS_SCHEME="tcp"
+REDIS_HOST="127.0.0.1"
+REDIS_PASSWORD="null"
+REDIS_PORT="6379"
+
+# Laravel Configuration
+SESSION_DRIVER="database"
+CACHE_DRIVER="redis"
+QUEUE_DRIVER="redis"
+BROADCAST_DRIVER="log"
+LOG_CHANNEL="stack"
+HORIZON_PREFIX="horizon-"
+
+# ActivityPub Configuration
+ACTIVITY_PUB="false"
+AP_REMOTE_FOLLOW="false"
+AP_INBOX="false"
+AP_OUTBOX="false"
+AP_SHAREDINBOX="false"
+
+# Experimental Configuration
+EXP_EMC="true"
+
+## Mail Configuration (Post-Installer)
+MAIL_DRIVER=log
+MAIL_HOST=smtp.mailtrap.io
+MAIL_PORT=2525
+MAIL_USERNAME=null
+MAIL_PASSWORD=null
+MAIL_ENCRYPTION=null
+MAIL_FROM_ADDRESS="pixelfed@example.com"
+MAIL_FROM_NAME="Pixelfed"
+
+## S3 Configuration (Post-Installer)
+PF_ENABLE_CLOUD=false
+FILESYSTEM_CLOUD=s3
+#AWS_ACCESS_KEY_ID=
+#AWS_SECRET_ACCESS_KEY=
+#AWS_DEFAULT_REGION=
+#AWS_BUCKET=<BucketName>
+#AWS_URL=
+#AWS_ENDPOINT=
+#AWS_USE_PATH_STYLE_ENDPOINT=false

+ 1 - 0
.hadolint.yaml

@@ -1,5 +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.

+ 113 - 2
CHANGELOG.md

@@ -1,6 +1,66 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.13...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+
+### 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))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+
+## [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
 
@@ -19,7 +79,58 @@
 - 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))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
+- 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)
 

+ 55 - 1
Dockerfile

@@ -132,6 +132,10 @@ 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
@@ -176,6 +180,55 @@ RUN --mount=type=cache,id=pixelfed-pear-${PHP_VERSION}-${PHP_DEBIAN_RELEASE}-${T
     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 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
 #######################################################
@@ -231,13 +284,14 @@ 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
 
 #! Changing user to runtime user
 USER ${RUNTIME_UID}:${RUNTIME_GID}
 
 # Generate optimized autoloader now that we have all files around
 RUN set -ex \
-    && composer dump-autoload --optimize
+    && ENABLE_CONFIG_CACHE=false composer dump-autoload --optimize
 
 USER root
 

+ 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;
+    }
+}

+ 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;
 		}

+ 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']);
+
+    }
+}

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

@@ -47,7 +47,7 @@ class MediaCloudUrlRewrite extends Command implements PromptsForMissingInput
 
     protected function preflightCheck()
     {
-        if(config_cache('pixelfed.cloud_storage') != true) {
+        if(!(bool) config_cache('pixelfed.cloud_storage')) {
             $this->info('Error: Cloud storage is not enabled!');
             $this->error('Aborting...');
             exit;

+ 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;

+ 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');
+    }
+}

+ 4 - 3
app/Console/Kernel.php

@@ -19,7 +19,6 @@ class Kernel extends ConsoleKernel
     /**
      * Define the application's command schedule.
      *
-     * @param \Illuminate\Console\Scheduling\Schedule $schedule
      *
      * @return void
      */
@@ -32,8 +31,9 @@ class Kernel extends ConsoleKernel
         $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 (in_array(config_cache('pixelfed.cloud_storage'), ['1', true, 'true']) && config('media.delete_local_after_cloud')) {
+        if ((bool) config_cache('pixelfed.cloud_storage') && (bool) config_cache('media.delete_local_after_cloud')) {
             $schedule->command('media:s3gc')->hourlyAt(15);
         }
 
@@ -51,6 +51,7 @@ class Kernel extends ConsoleKernel
         $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();
     }
 
     /**
@@ -60,7 +61,7 @@ class Kernel extends ConsoleKernel
      */
     protected function commands()
     {
-        $this->load(__DIR__ . '/Commands');
+        $this->load(__DIR__.'/Commands');
 
         require base_path('routes/console.php');
     }

+ 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();

+ 94 - 96
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,37 +31,37 @@ 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) : [];
@@ -84,22 +74,22 @@ trait AdminDirectoryController
         $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;
@@ -108,8 +98,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.');
                     }
                 },
@@ -120,7 +110,7 @@ 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();
@@ -146,11 +136,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)]);
             }
         }
@@ -159,25 +149,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;
         }
 
@@ -194,11 +185,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) : [];
@@ -208,26 +199,27 @@ 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);
-            });
+                ->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)));
@@ -240,9 +232,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;
     }
 
@@ -253,7 +246,7 @@ trait AdminDirectoryController
             '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'),
@@ -273,8 +266,8 @@ trait AdminDirectoryController
             '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.');
                     }
                 },
@@ -285,10 +278,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);
         }
 
@@ -297,6 +290,7 @@ trait AdminDirectoryController
 
         $data = (new PixelfedDirectoryController())->buildListing();
         $res = Http::withoutVerifying()->post('https://pixelfed.org/api/v1/directory/submission', $data);
+
         return 200;
     }
 
@@ -304,7 +298,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);
@@ -312,12 +306,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']);
         }
 
@@ -328,12 +322,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')
@@ -343,21 +338,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');
@@ -380,11 +375,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;
     }
 
@@ -392,13 +388,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([]);
@@ -409,7 +405,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());
@@ -417,8 +413,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;
     }
 
@@ -426,7 +423,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');
@@ -434,18 +431,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;
+        });
+    }
+}

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

@@ -7,7 +7,9 @@ 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 Artisan;
@@ -71,6 +73,7 @@ trait AdminSettingsController
             'admin_account_id' => 'nullable',
             'regs' => 'required|in:open,filtered,closed',
             'account_migration' => 'nullable',
+            'rule_delete' => 'sometimes',
         ]);
 
         $orb = false;
@@ -310,4 +313,573 @@ trait AdminSettingsController
 
         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',
+            '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.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;
+    }
 }

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

@@ -2,562 +2,589 @@
 
 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\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 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)
+    {
+        $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();
+
+    }
+
+    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'));
+    }
 }

+ 6 - 1
app/Http/Controllers/AdminCuratedRegisterController.php

@@ -174,7 +174,7 @@ class AdminCuratedRegisterController extends Controller
     public function apiMessageSendStore(Request $request, $id)
     {
         $this->validate($request, [
-            'message' => 'required|string|min:5|max:1000',
+            'message' => 'required|string|min:5|max:3000',
         ]);
         $record = CuratedRegister::findOrFail($id);
         abort_if($record->email_verified_at === null, 400, 'Cannot message an unverified email');
@@ -240,6 +240,11 @@ class AdminCuratedRegisterController extends Controller
         $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,

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

@@ -2,45 +2,40 @@
 
 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() || !$request->user()->token(), 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);
@@ -50,7 +45,7 @@ class AdminApiController extends Controller
 
     public function getStats(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -59,12 +54,13 @@ class AdminApiController extends Controller
         $res['autospam_count'] = AccountInterstitial::whereType('post.autospam')
             ->whereNull('appeal_handled_at')
             ->count();
+
         return $res;
     }
 
     public function autospam(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -73,26 +69,27 @@ class AdminApiController extends Controller
             ->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;
             });
 
@@ -101,14 +98,14 @@ class AdminApiController extends Controller
 
     public function autospamHandle(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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');
@@ -122,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();
@@ -148,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;
@@ -167,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';
@@ -198,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';
@@ -231,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;
         }
 
@@ -247,44 +250,48 @@ class AdminApiController extends Controller
 
     public function modReports(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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()
@@ -295,14 +302,14 @@ class AdminApiController extends Controller
 
     public function modReportHandle(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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');
@@ -311,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);
         }
 
@@ -355,7 +362,7 @@ class AdminApiController extends Controller
 
     public function getConfiguration(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -366,42 +373,43 @@ class AdminApiController extends Controller
             [
                 '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() || !$request->user()->token(), 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);
@@ -410,7 +418,7 @@ class AdminApiController extends Controller
 
         $this->validate($request, [
             'key' => 'required',
-            'value' => 'required'
+            'value' => 'required',
         ]);
 
         $allowedKeys = [
@@ -423,50 +431,51 @@ 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() || !$request->user()->token(), 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);
@@ -477,27 +486,29 @@ class AdminApiController extends Controller
         $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() || !$request->user()->token(), 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);
@@ -510,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;
@@ -520,7 +531,7 @@ class AdminApiController extends Controller
 
     public function userAdminAction(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -528,7 +539,7 @@ class AdminApiController extends Controller
         $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');
@@ -538,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);
             }
 
@@ -567,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;
@@ -586,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)
@@ -600,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();
 
@@ -612,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)
@@ -625,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)
@@ -640,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)
@@ -655,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);
@@ -673,7 +685,7 @@ class AdminApiController extends Controller
                 ->action('admin.user.moderate')
                 ->metadata([
                     'action' => $action,
-                    'message' => 'Success!'
+                    'message' => 'Success!',
                 ])
                 ->accessLevel('admin')
                 ->save();
@@ -687,14 +699,14 @@ 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() || !$request->user()->token(), 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);
@@ -711,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)
@@ -734,7 +746,7 @@ class AdminApiController extends Controller
 
     public function getInstance(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -747,7 +759,7 @@ class AdminApiController extends Controller
 
     public function moderateInstance(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -755,7 +767,7 @@ class AdminApiController extends Controller
         $this->validate($request, [
             'id' => 'required',
             'key' => 'required|in:unlisted,auto_cw,banned',
-            'value' => 'required'
+            'value' => 'required',
         ]);
 
         $id = $request->input('id');
@@ -773,7 +785,7 @@ class AdminApiController extends Controller
 
     public function refreshInstanceStats(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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);
@@ -793,51 +805,51 @@ class AdminApiController extends Controller
 
     public function getAllStats(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 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');

+ 185 - 74
app/Http/Controllers/Api/ApiV1Controller.php

@@ -60,6 +60,7 @@ 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;
@@ -131,7 +132,7 @@ class ApiV1Controller extends Controller
      */
     public function apps(Request $request)
     {
-        abort_if(! config_cache('pixelfed.oauth_enabled'), 404);
+        abort_if(! (bool) config_cache('pixelfed.oauth_enabled'), 404);
 
         $this->validate($request, [
             'client_name' => 'required',
@@ -193,6 +194,10 @@ class ApiV1Controller extends Controller
             'fields' => [],
         ];
 
+        if ($request->has(self::PF_API_ENTITY_KEY)) {
+            $res['settings'] = AccountService::getAccountSettings($user->profile_id);
+        }
+
         return $this->json($res);
     }
 
@@ -207,6 +212,7 @@ class ApiV1Controller extends Controller
         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);
@@ -326,7 +332,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('locked')) {
-            $locked = $request->input('locked') == 'true';
+            $locked = $request->boolean('locked');
             if ($profile->is_private != $locked) {
                 $profile->is_private = $locked;
                 $changes = true;
@@ -334,7 +340,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('reduce_motion')) {
-            $reduced = $request->input('reduce_motion');
+            $reduced = $request->boolean('reduce_motion');
             if ($settings->reduce_motion != $reduced) {
                 $settings->reduce_motion = $reduced;
                 $changes = true;
@@ -342,7 +348,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('high_contrast_mode')) {
-            $contrast = $request->input('high_contrast_mode');
+            $contrast = $request->boolean('high_contrast_mode');
             if ($settings->high_contrast_mode != $contrast) {
                 $settings->high_contrast_mode = $contrast;
                 $changes = true;
@@ -350,7 +356,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('video_autoplay')) {
-            $autoplay = $request->input('video_autoplay');
+            $autoplay = $request->boolean('video_autoplay');
             if ($settings->video_autoplay != $autoplay) {
                 $settings->video_autoplay = $autoplay;
                 $changes = true;
@@ -370,7 +376,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('media_descriptions')) {
-            $md = $request->input('media_descriptions') == true;
+            $md = $request->boolean('media_descriptions');
             if ($composeSettings['media_descriptions'] != $md) {
                 $composeSettings['media_descriptions'] = $md;
                 $changes = true;
@@ -378,7 +384,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('crawlable')) {
-            $crawlable = $request->input('crawlable');
+            $crawlable = $request->boolean('crawlable');
             if ($settings->crawlable != $crawlable) {
                 $settings->crawlable = $crawlable;
                 $changes = true;
@@ -386,7 +392,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('show_profile_follower_count')) {
-            $show_profile_follower_count = $request->input('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;
@@ -395,7 +401,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('show_profile_following_count')) {
-            $show_profile_following_count = $request->input('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;
@@ -404,7 +410,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('public_dm')) {
-            $public_dm = $request->input('public_dm');
+            $public_dm = $request->boolean('public_dm');
             if ($settings->public_dm != $public_dm) {
                 $settings->public_dm = $public_dm;
                 $changes = true;
@@ -422,7 +428,7 @@ class ApiV1Controller extends Controller
         }
 
         if ($request->has('disable_embeds')) {
-            $disabledEmbeds = $request->input('disable_embeds');
+            $disabledEmbeds = $request->boolean('disable_embeds');
             if ($other['disable_embeds'] != $disabledEmbeds) {
                 $other['disable_embeds'] = $disabledEmbeds;
                 $changes = true;
@@ -441,8 +447,16 @@ class ApiV1Controller extends Controller
             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);
+            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) {
@@ -740,7 +754,15 @@ class ApiV1Controller extends Controller
 
         $dir = $min_id ? '>' : '<';
         $id = $min_id ?? $max_id;
-        $res = Status::whereProfileId($profile['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)
@@ -960,10 +982,22 @@ class ApiV1Controller extends Controller
         $napi = $request->has(self::PF_API_ENTITY_KEY);
         $pid = $request->user()->profile_id ?? $request->user()->profile->id;
         $res = collect($ids)
-            ->filter(function ($id) use ($pid) {
-                return intval($id) !== intval($pid);
-            })
             ->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);
@@ -1103,7 +1137,7 @@ class ApiV1Controller extends Controller
         }
 
         $count = UserFilterService::blockCount($pid);
-        $maxLimit = intval(config('instance.user_filters.max_user_blocks'));
+        $maxLimit = (int) config_cache('instance.user_filters.max_user_blocks');
         if ($count == 0) {
             $filterCount = UserFilter::whereUserId($pid)
                 ->whereFilterType('block')
@@ -1200,8 +1234,8 @@ class ApiV1Controller extends Controller
         if ($filter) {
             $filter->delete();
             UserFilterService::unblock($pid, $profile->id);
-            RelationshipService::refresh($pid, $id);
         }
+        RelationshipService::refresh($pid, $id);
 
         $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer());
         $res = $this->fractal->createData($resource)->toArray();
@@ -1301,12 +1335,17 @@ class ApiV1Controller extends Controller
         if ($res->count()) {
             $ids = $res->map(function ($status) {
                 return $status['like_id'];
-            });
-            $max = $ids->max();
-            $min = $ids->min();
+            })->filter();
+
+            $max = $ids->min() - 1;
+            $min = $ids->max();
 
             $baseUrl = config('app.url').'/api/v1/favourites?limit='.$limit.'&';
-            $link = '<'.$baseUrl.'max_id='.$max.'>; rel="next",<'.$baseUrl.'min_id='.$min.'>; rel="prev"';
+            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 {
@@ -1328,7 +1367,8 @@ class ApiV1Controller extends Controller
         $user = $request->user();
         abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action');
 
-        $status = StatusService::getMastodon($id, false);
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
 
         abort_unless($status, 404);
 
@@ -1396,34 +1436,47 @@ class ApiV1Controller extends Controller
         $user = $request->user();
         abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action');
 
-        AccountService::setLastActive($user->id);
+        $napi = $request->has(self::PF_API_ENTITY_KEY);
+        $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
 
-        $status = Status::findOrFail($id);
+        abort_unless($status && isset($status['account']), 404);
 
-        if (intval($status->profile_id) !== intval($user->profile_id)) {
-            if ($status->scope == 'private') {
-                abort_if(! $status->profile->followedBy($user->profile), 403);
+        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->scope, ['public', 'unlisted']), 403);
+                abort_if(! in_array($status['visibility'], ['public', 'unlisted']), 403);
             }
         }
 
         $like = Like::whereProfileId($user->profile_id)
-            ->whereStatusId($status->id)
+            ->whereStatusId($status['id'])
             ->first();
 
         if ($like) {
             $like->forceDelete();
-            $status->likes_count = $status->likes_count > 1 ? $status->likes_count - 1 : 0;
-            $status->save();
+            $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);
+        StatusService::del($status['id']);
 
-        $res = StatusService::getMastodon($status->id, false);
-        $res['favourited'] = false;
+        $status['favourited'] = false;
+        $status['favourites_count'] = isset($ogStatus) ? $ogStatus->likes_count : $status['favourites_count'] - 1;
 
-        return $this->json($res);
+        return $this->json($status);
     }
 
     /**
@@ -1611,7 +1664,7 @@ class ApiV1Controller extends Controller
             $stats = Cache::remember('api:v1:instance-data:stats', 43200, function () {
                 return [
                     'user_count' => User::count(),
-                    'status_count' => Status::whereNull('uri')->count(),
+                    'status_count' => StatusService::totalLocalStatuses(),
                     'domain_count' => Instance::count(),
                 ];
             });
@@ -1632,7 +1685,7 @@ class ApiV1Controller extends Controller
 
             return [
                 'uri' => config('pixelfed.domain.app'),
-                'title' => config('app.name'),
+                'title' => config_cache('app.name'),
                 'short_description' => config_cache('app.short_description'),
                 'description' => config_cache('app.description'),
                 'email' => config('instance.email'),
@@ -1651,7 +1704,7 @@ class ApiV1Controller extends Controller
                     'media_attachments' => [
                         'image_matrix_limit' => 16777216,
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'supported_mime_types' => explode(',', config('pixelfed.media_types')),
+                        '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,
@@ -1665,7 +1718,7 @@ class ApiV1Controller extends Controller
                     'statuses' => [
                         'characters_reserved_per_url' => 23,
                         'max_characters' => (int) config_cache('pixelfed.max_caption_length'),
-                        'max_media_attachments' => (int) config('pixelfed.max_album_length'),
+                        'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
                     ],
                 ],
             ];
@@ -1754,12 +1807,16 @@ class ApiV1Controller extends Controller
 
         $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;
-            });
+        $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 ($size >= $limit) {
+            if ($updatedAccountSize >= $limit) {
                 abort(403, 'Account size limit reached.');
             }
         }
@@ -1767,8 +1824,6 @@ class ApiV1Controller extends Controller
         $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.');
@@ -1831,6 +1886,10 @@ class ApiV1Controller extends Controller
                 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();
@@ -1971,12 +2030,16 @@ class ApiV1Controller extends Controller
 
         $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;
-            });
+        $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 ($size >= $limit) {
+            if ($updatedAccountSize >= $limit) {
                 abort(403, 'Account size limit reached.');
             }
         }
@@ -1984,8 +2047,6 @@ class ApiV1Controller extends Controller
         $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.');
@@ -2053,6 +2114,10 @@ class ApiV1Controller extends Controller
                 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();
@@ -2145,7 +2210,7 @@ class ApiV1Controller extends Controller
         }
 
         $count = UserFilterService::muteCount($pid);
-        $maxLimit = intval(config('instance.user_filters.max_user_mutes'));
+        $maxLimit = (int) config_cache('instance.user_filters.max_user_mutes');
         if ($count == 0) {
             $filterCount = UserFilter::whereUserId($pid)
                 ->whereFilterType('mute')
@@ -2207,9 +2272,10 @@ class ApiV1Controller extends Controller
         if ($filter) {
             $filter->delete();
             UserFilterService::unmute($pid, $profile->id);
-            RelationshipService::refresh($pid, $id);
         }
 
+        RelationshipService::refresh($pid, $id);
+
         $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer());
         $res = $this->fractal->createData($resource)->toArray();
 
@@ -2233,14 +2299,17 @@ class ApiV1Controller extends Controller
             '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');
@@ -2258,6 +2327,10 @@ class ApiV1Controller extends Controller
 
         $types = $request->input('types');
 
+        if ($request->has('types')) {
+            $limit = 150;
+        }
+
         $maxId = null;
         $minId = null;
         AccountService::setLastActive($request->user()->id);
@@ -2280,7 +2353,12 @@ class ApiV1Controller extends Controller
             }
         }
 
-        $baseUrl = config('app.url').'/api/v1/notifications?limit='.$limit.'&';
+        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;
@@ -2322,7 +2400,16 @@ class ApiV1Controller extends Controller
                 }
 
                 return true;
-            })->values();
+            })
+            ->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"';
@@ -3019,9 +3106,9 @@ class ApiV1Controller extends Controller
         abort_unless($request->user()->tokenCan('read'), 403);
 
         $user = $request->user();
-        AccountService::setLastActive($user->id);
         $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);
@@ -3048,7 +3135,9 @@ class ApiV1Controller extends Controller
         $descendants = [];
 
         if ($status['in_reply_to_id']) {
-            $ancestors[] = StatusService::getMastodon($status['in_reply_to_id'], false);
+            $ancestors[] = $pe ?
+            StatusService::get($status['in_reply_to_id'], false) :
+            StatusService::getMastodon($status['in_reply_to_id'], false);
         }
 
         if ($status['replies_count']) {
@@ -3058,8 +3147,10 @@ class ApiV1Controller extends Controller
                 ->where('in_reply_to_id', $id)
                 ->limit(20)
                 ->pluck('id')
-                ->map(function ($sid) {
-                    return StatusService::getMastodon($sid, false);
+                ->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);
@@ -3308,9 +3399,9 @@ class ApiV1Controller extends Controller
         abort_unless($request->user()->tokenCan('write'), 403);
 
         $this->validate($request, [
-            'status' => 'nullable|string|max:' . config_cache('pixelfed.max_caption_length'),
+            'status' => 'nullable|string|max:'.(int) config_cache('pixelfed.max_caption_length'),
             'in_reply_to_id' => 'nullable',
-            'media_ids' => 'sometimes|array|max:'.config_cache('pixelfed.max_album_length'),
+            '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',
@@ -3360,10 +3451,9 @@ class ApiV1Controller extends Controller
         $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)
-                ->whereNull('in_reply_to_id')
-                ->whereNull('reblog_of_id')
-                ->where('created_at', '>', now()->subDays(1))
+                ->where('id', '>', $minId)
                 ->count();
 
             return $dailyLimit >= 1000;
@@ -3436,7 +3526,7 @@ class ApiV1Controller extends Controller
             $mimes = [];
 
             foreach ($ids as $k => $v) {
-                if ($k + 1 > config_cache('pixelfed.max_album_length')) {
+                if ($k + 1 > (int) config_cache('pixelfed.max_album_length')) {
                     continue;
                 }
                 $m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v);
@@ -3712,7 +3802,6 @@ class ApiV1Controller extends Controller
         }
 
         $res = StatusHashtag::whereHashtagId($tag->id)
-            ->whereIn('status_visibility', ['public', 'private', 'unlisted'])
             ->where('status_id', $dir, $id)
             ->orderBy('status_id', 'desc')
             ->limit(100)
@@ -3729,11 +3818,11 @@ class ApiV1Controller extends Controller
                         return false;
                     }
                 }
-                if ($i['visibility'] === 'private') {
-                    if ((int) $i['account']['id'] !== $pid) {
-                        return FollowerService::follows($pid, $i['account']['id'], true);
-                    }
-                }
+                // 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;
@@ -4199,4 +4288,26 @@ class ApiV1Controller extends Controller
 
         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');
+            })
+        );
+    }
 }

Разлика између датотеке није приказан због своје велике величине
+ 889 - 876
app/Http/Controllers/Api/ApiV1Dot1Controller.php


+ 78 - 79
app/Http/Controllers/Api/ApiV2Controller.php

@@ -2,44 +2,32 @@
 
 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;
-use App\Services\UserRoleService;
 
 class ApiV2Controller extends Controller
 {
-    const PF_API_ENTITY_KEY = "_pe";
+    const PF_API_ENTITY_KEY = '_pe';
 
     public function json($res, $code = 200, $headers = [])
     {
@@ -49,10 +37,11 @@ class ApiV2Controller extends Controller
     public function instance(Request $request)
     {
         $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
-            if(config_cache('instance.admin.pid')) {
+            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;
@@ -61,41 +50,42 @@ class ApiV2Controller extends Controller
         $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() : [];
+                    ->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) {
+        $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') .')',
+                '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()
-                    ]
+                        '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'))
-                    ]
+                        '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
+                    ],
                 ],
                 'languages' => [config('app.locale')],
                 'configuration' => [
                     'urls' => [
                         'streaming' => null,
-                        'status' => null
+                        'status' => null,
                     ],
                     'vapid' => [
                         'public_key' => config('webpush.vapid.public_key'),
@@ -106,7 +96,7 @@ class ApiV2Controller extends Controller
                     '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
+                        'characters_reserved_per_url' => 23,
                     ],
                     'media_attachments' => [
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
@@ -114,7 +104,7 @@ class ApiV2Controller extends Controller
                         'image_matrix_limit' => 3686400,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                         'video_frame_rate_limit' => 240,
-                        'video_matrix_limit' => 3686400
+                        'video_matrix_limit' => 3686400,
                     ],
                     'polls' => [
                         'max_options' => 0,
@@ -134,14 +124,15 @@ class ApiV2Controller extends Controller
                 ],
                 'contact' => [
                     'email' => config('instance.email'),
-                    'account' => $contact
+                    'account' => $contact,
                 ],
-                'rules' => $rules
+                '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);
     }
 
@@ -153,7 +144,7 @@ class ApiV2Controller extends Controller
      */
     public function search(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 403);
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
         abort_unless($request->user()->tokenCan('read'), 403);
 
         $this->validate($request, [
@@ -166,18 +157,19 @@ class ApiV2Controller extends Controller
             'resolve' => 'nullable',
             'limit' => 'nullable|integer|max:40',
             'offset' => 'nullable|integer',
-            'following' => 'nullable'
+            'following' => 'nullable',
         ]);
 
-        if($request->user()->has_roles && !UserRoleService::can('can-view-discover', $request->user()->id)) {
+        if ($request->user()->has_roles && ! UserRoleService::can('can-view-discover', $request->user()->id)) {
             return [
                 'accounts' => [],
                 'hashtags' => [],
-                'statuses' => []
+                'statuses' => [],
             ];
         }
 
-        $mastodonMode = !$request->has('_pe');
+        $mastodonMode = ! $request->has('_pe');
+
         return $this->json(SearchApiV2Service::query($request, $mastodonMode));
     }
 
@@ -193,7 +185,7 @@ class ApiV2Controller extends Controller
             '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')
+            'cluster' => config('broadcasting.connections.pusher.options.cluster'),
         ] : [];
     }
 
@@ -205,39 +197,39 @@ class ApiV2Controller extends Controller
      */
     public function mediaUploadV2(Request $request)
     {
-        abort_if(!$request->user() || !$request->user()->token(), 403);
+        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'),
+                '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'),
+                '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'
+            '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) {
+        if ($user->last_active_at == null) {
             return [];
         }
 
-        if(empty($request->file('file'))) {
+        if (empty($request->file('file'))) {
             return response('', 422);
         }
 
-        $limitKey = 'compose:rate-limit:media-upload:' . $user->id;
+        $limitKey = 'compose:rate-limit:media-upload:'.$user->id;
         $limitTtl = now()->addMinutes(15);
-        $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
+        $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) {
             $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
 
             return $dailyLimit >= 1250;
@@ -246,23 +238,25 @@ class ApiV2Controller extends Controller
 
         $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;
-            });
+        $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 ($size >= $limit) {
-               abort(403, 'Account size limit reached.');
+            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;
 
-        $photo = $request->file('file');
-
         $mimes = explode(',', config_cache('pixelfed.media_types'));
-        if(in_array($photo->getMimeType(), $mimes) == false) {
+        if (in_array($photo->getMimeType(), $mimes) == false) {
             abort(403, 'Invalid or unsupported mime type.');
         }
 
@@ -274,24 +268,24 @@ class ApiV2Controller extends Controller
 
         $settings = UserSetting::whereUserId($user->id)->first();
 
-        if($settings && !empty($settings->compose_settings)) {
+        if ($settings && ! empty($settings->compose_settings)) {
             $compose = $settings->compose_settings;
 
-            if(isset($compose['default_license']) && $compose['default_license'] != 1) {
+            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')) {
+        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) {
+            if ($removeMedia) {
                 MediaDeletePipeline::dispatch($removeMedia)
                     ->onQueue('mmo')
                     ->delay(now()->addMinutes(15));
@@ -309,7 +303,7 @@ class ApiV2Controller extends Controller
         $media->caption = $request->input('description');
         $media->filter_class = $filterClass;
         $media->filter_name = $filterName;
-        if($license) {
+        if ($license) {
             $media->license = $license;
         }
         $media->save();
@@ -327,13 +321,18 @@ class ApiV2Controller extends Controller
                 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['preview_url'] = $media->url().'?v='.time();
         $res['url'] = null;
+
         return $this->json($res, 202);
     }
 }

+ 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' => [],

+ 1 - 1
app/Http/Controllers/Api/V1/DomainBlockController.php

@@ -72,7 +72,7 @@ class DomainBlockController extends Controller
         abort_if(config_cache('pixelfed.domain.app') == $domain, 400, 'Cannot ban your own server');
 
         $existingCount = UserDomainBlock::whereProfileId($pid)->count();
-        $maxLimit = config('instance.user_filters.max_domain_blocks');
+        $maxLimit = (int) config_cache('instance.user_filters.max_domain_blocks');
         $errorMsg =  __('profile.block.domain.max', ['max' => $maxLimit]);
 
         abort_if($existingCount >= $maxLimit, 400, $errorMsg);

+ 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'

+ 3 - 3
app/Http/Controllers/Auth/LoginController.php

@@ -74,10 +74,10 @@ class LoginController extends Controller
         $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')
 			)

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

@@ -3,234 +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
-	 */
-	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 (_).');
-				}
-
-				$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
-	 */
-	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.
-	 *
-	 * @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',

+ 133 - 111
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,20 +131,20 @@ 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');
         }
 
@@ -160,10 +155,10 @@ class CollectionController extends Controller
 
         $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::deleteCollection($collection->id);
@@ -177,112 +172,112 @@ class CollectionController extends Controller
 
     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) {
+            ->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();
+            })
+            ->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');
@@ -290,7 +285,7 @@ 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!');
         }
 
@@ -308,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();
             });
@@ -319,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();
+    }
 }

+ 30 - 22
app/Http/Controllers/ComposeController.php

@@ -21,6 +21,7 @@ use App\Services\MediaStorageService;
 use App\Services\MediaTagService;
 use App\Services\SnowflakeService;
 use App\Services\UserRoleService;
+use App\Services\UserStorageService;
 use App\Status;
 use App\Transformer\Api\MediaTransformer;
 use App\UserFilter;
@@ -70,7 +71,7 @@ class ComposeController extends Controller
             'filter_class' => 'nullable|alpha_dash|max:24',
         ]);
 
-        $user = Auth::user();
+        $user = $request->user();
         $profile = $user->profile;
         abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
 
@@ -84,21 +85,22 @@ class ComposeController extends Controller
 
         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;
-            });
+        $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 ($size >= $limit) {
+            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;
-
-        $photo = $request->file('file');
-
         $mimes = explode(',', config_cache('pixelfed.media_types'));
 
         abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format');
@@ -143,6 +145,10 @@ class ComposeController extends Controller
                 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();
@@ -198,6 +204,7 @@ class ComposeController extends Controller
         ];
         ImageOptimize::dispatch($media)->onQueue('mmo');
         Cache::forget($limitKey);
+        UserStorageService::recalculateUpdateStorageUsed($request->user()->id);
 
         return $res;
     }
@@ -218,6 +225,8 @@ class ComposeController extends Controller
 
         MediaStorageService::delete($media, true);
 
+        UserStorageService::recalculateUpdateStorageUsed($request->user()->id);
+
         return response()->json([
             'msg' => 'Successfully deleted',
             'code' => 200,
@@ -494,17 +503,17 @@ class ComposeController extends Controller
 
         $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();
+        // $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;
-        });
+        //     return $dailyLimit >= 1000;
+        // });
 
-        abort_if($limitReached == true, 429);
+        // abort_if($limitReached == true, 429);
 
         $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null;
 
@@ -626,7 +635,6 @@ class ComposeController extends Controller
         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);
 
@@ -741,7 +749,7 @@ class ComposeController extends Controller
             case 'image/jpeg':
             case 'image/png':
             case 'video/mp4':
-                $finished = config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at;
+                $finished = (bool) config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at;
                 break;
 
             default:

Разлика између датотеке није приказан због своје велике величине
+ 385 - 375
app/Http/Controllers/DirectMessageController.php


+ 8 - 0
app/Http/Controllers/DiscoverController.php

@@ -11,6 +11,7 @@ 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\SnowflakeService;
@@ -420,4 +421,11 @@ class DiscoverController extends Controller
 
         return response()->json($ids, 200, [], JSON_UNESCAPED_SLASHES);
     }
+
+    public function discoverNetworkTrending(Request $request)
+    {
+        abort_if(! $request->user(), 404);
+
+        return BeagleService::getDiscoverPosts();
+    }
 }

+ 119 - 92
app/Http/Controllers/FederationController.php

@@ -2,57 +2,42 @@
 
 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;
-use App\Services\AccountService;
 
 class FederationController extends Controller
 {
     public function nodeinfoWellKnown()
     {
-        abort_if(!config('federation.nodeinfo.enabled'), 404);
+        abort_if(! config('federation.nodeinfo.enabled'), 404);
+
         return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES)
-            ->header('Access-Control-Allow-Origin','*');
+            ->header('Access-Control-Allow-Origin', '*');
     }
 
     public function nodeinfo()
     {
-        abort_if(!config('federation.nodeinfo.enabled'), 404);
+        abort_if(! config('federation.nodeinfo.enabled'), 404);
+
         return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES)
-            ->header('Access-Control-Allow-Origin','*');
+            ->header('Access-Control-Allow-Origin', '*');
     }
 
     public function webfinger(Request $request)
     {
-        if (!config('federation.webfinger.enabled') ||
-            !$request->has('resource') ||
-            !$request->filled('resource')
+        if (! config('federation.webfinger.enabled') ||
+            ! $request->has('resource') ||
+            ! $request->filled('resource')
         ) {
             return response('', 400);
         }
@@ -60,55 +45,87 @@ class FederationController extends Controller
         $resource = $request->input('resource');
         $domain = config('pixelfed.domain.app');
 
-        if(config('federation.activitypub.sharedInbox') &&
-            $resource == 'acct:' . $domain . '@' . $domain) {
+        // Instance Actor
+        if (
+            config('federation.activitypub.sharedInbox') &&
+            $resource == 'acct:'.$domain.'@'.$domain
+        ) {
             $res = [
-                'subject' => 'acct:' . $domain . '@' . $domain,
+                'subject' => 'acct:'.$domain.'@'.$domain,
                 'aliases' => [
-                    'https://' . $domain . '/i/actor'
+                    'https://'.$domain.'/i/actor',
                 ],
                 'links' => [
                     [
                         'rel' => 'http://webfinger.net/rel/profile-page',
                         'type' => 'text/html',
-                        'href' => 'https://' . $domain . '/site/kb/instance-actor'
+                        'href' => 'https://'.$domain.'/site/kb/instance-actor',
                     ],
                     [
                         'rel' => 'self',
                         'type' => 'application/activity+json',
-                        'href' => 'https://' . $domain . '/i/actor'
-                    ]
-                ]
+                        'href' => 'https://'.$domain.'/i/actor',
+                    ],
+                ],
             ];
+
             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)) {
+        $key = 'federation:webfinger:sha256:'.$hash;
+        if ($cached = Cache::get($key)) {
             return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
         }
-        if(strpos($resource, $domain) == false) {
+        if (strpos($resource, $domain) == false) {
             return response('', 400);
         }
         $parsed = Nickname::normalizeProfileUrl($resource);
-        if(empty($parsed) || $parsed['domain'] !== $domain) {
+        if (empty($parsed) || $parsed['domain'] !== $domain) {
             return response('', 400);
         }
         $username = $parsed['username'];
-        $profile = Profile::whereNull('domain')->whereUsername($username)->first();
-        if(!$profile || $profile->status !== null) {
+        $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','*');
+            ->header('Access-Control-Allow-Origin', '*');
     }
 
     public function hostMeta(Request $request)
     {
-        abort_if(!config('federation.webfinger.enabled'), 404);
+        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>';
@@ -118,19 +135,19 @@ class FederationController extends Controller
 
     public function userOutbox(Request $request, $username)
     {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
 
-        if(!$request->wantsJson()) {
-            return redirect('/' . $username);
+        if (! $request->wantsJson()) {
+            return redirect('/'.$username);
         }
 
         $id = AccountService::usernameToId($username);
-        abort_if(!$id, 404);
+        abort_if(! $id, 404);
         $account = AccountService::get($id);
-        abort_if(!$account || !isset($account['statuses_count']), 404);
+        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',
+            'id' => 'https://'.config('pixelfed.domain.app').'/users/'.$username.'/outbox',
             'type' => 'OrderedCollection',
             'totalItems' => $account['statuses_count'] ?? 0,
         ];
@@ -140,135 +157,145 @@ class FederationController extends Controller
 
     public function userInbox(Request $request, $username)
     {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-        abort_if(!config('federation.activitypub.inbox'), 404);
+        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)) {
+        if (! $payload || empty($payload)) {
             return;
         }
         $obj = json_decode($payload, true, 8);
-        if(!isset($obj['id'])) {
+        if (! isset($obj['id'])) {
             return;
         }
         $domain = parse_url($obj['id'], PHP_URL_HOST);
-        if(in_array($domain, InstanceService::getBannedDomains())) {
+        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()) {
+        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()) {
+                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') {
+                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'])) {
+        } 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');
         }
-        return;
+
     }
 
     public function sharedInbox(Request $request)
     {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-        abort_if(!config('federation.activitypub.sharedInbox'), 404);
+        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)) {
+        if (! $payload || empty($payload)) {
             return;
         }
 
         $obj = json_decode($payload, true, 8);
-        if(!isset($obj['id'])) {
+        if (! isset($obj['id'])) {
             return;
         }
 
         $domain = parse_url($obj['id'], PHP_URL_HOST);
-        if(in_array($domain, InstanceService::getBannedDomains())) {
+        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()) {
+        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()) {
+                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') {
+                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'])) {
+        } 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');
         }
-        return;
+
     }
 
     public function userFollowing(Request $request, $username)
     {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
 
         $id = AccountService::usernameToId($username);
-        abort_if(!$id, 404);
+        abort_if(! $id, 404);
         $account = AccountService::get($id);
-        abort_if(!$account || !isset($account['following_count']), 404);
+        abort_if(! $account || ! isset($account['following_count']), 404);
         $obj = [
             '@context' => 'https://www.w3.org/ns/activitystreams',
-            'id'       => $request->getUri(),
-            'type'     => 'OrderedCollection',
+            '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(!config_cache('federation.activitypub.enabled'), 404);
+        abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
         $id = AccountService::usernameToId($username);
-        abort_if(!$id, 404);
+        abort_if(! $id, 404);
         $account = AccountService::get($id);
-        abort_if(!$account || !isset($account['followers_count']), 404);
+        abort_if(! $account || ! isset($account['followers_count']), 404);
         $obj = [
             '@context' => 'https://www.w3.org/ns/activitystreams',
-            'id'       => $request->getUri(),
-            'type'     => 'OrderedCollection',
+            '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' => '/'];
+    }
+}

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

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Status;
+use App\Models\InstanceActor;
+use App\Services\MediaService;
+
+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
+
+		$res = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id' => $gp->url(),
+
+			'type' => 'Note',
+
+			'summary'   => null,
+			'content'   => $status->rendered ?? $status->caption,
+			'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 {

+ 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);
+    }
 }

+ 50 - 42
app/Http/Controllers/PixelfedDirectoryController.php

@@ -2,37 +2,41 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
 use App\Models\ConfigCache;
-use Storage;
 use App\Services\AccountService;
+use App\Services\InstanceService;
 use App\Services\StatusService;
+use App\User;
+use Cache;
+use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use Storage;
 
 class PixelfedDirectoryController extends Controller
 {
     public function get(Request $request)
     {
-        if(!$request->filled('sk')) {
+        if (! $request->filled('sk')) {
             abort(404);
         }
 
-        if(!config_cache('pixelfed.directory.submission-key')) {
+        if (! config_cache('pixelfed.directory.submission-key')) {
             abort(404);
         }
 
-        if(!hash_equals(config_cache('pixelfed.directory.submission-key'), $request->input('sk'))) {
+        if (! hash_equals(config_cache('pixelfed.directory.submission-key'), $request->input('sk'))) {
             abort(403);
         }
 
         $res = $this->buildListing();
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     }
 
     public function buildListing()
     {
         $res = config_cache('pixelfed.directory');
-        if($res) {
+        if ($res) {
             $res = is_string($res) ? json_decode($res, true) : $res;
         }
 
@@ -41,40 +45,40 @@ class PixelfedDirectoryController extends Controller
         $res['_ts'] = config_cache('pixelfed.directory.submission-ts');
         $res['version'] = config_cache('pixelfed.version');
 
-        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['admin'])) {
+        if (isset($res['admin'])) {
             $res['admin'] = AccountService::get($res['admin'], true);
         }
 
-        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']);
-            })
-            ->map(function($post) {
-                return [
-                    'avatar' => $post['account']['avatar'],
-                    'display_name' => $post['account']['display_name'],
-                    'username' => $post['account']['username'],
-                    'media' => $post['media_attachments'][0]['url'],
-                    'url' => $post['url']
-                ];
-            })
-            ->values();
+                ->filter(function ($post) {
+                    return $post && isset($post['account']);
+                })
+                ->map(function ($post) {
+                    return [
+                        'avatar' => $post['account']['avatar'],
+                        'display_name' => $post['account']['display_name'],
+                        'username' => $post['account']['username'],
+                        'media' => $post['media_attachments'][0]['url'],
+                        'url' => $post['url'],
+                    ];
+                })
+                ->values();
         }
 
         $guidelines = ConfigCache::whereK('app.rules')->first();
-        if($guidelines) {
+        if ($guidelines) {
             $res['community_guidelines'] = json_decode($guidelines->v, true);
         }
 
@@ -85,27 +89,27 @@ class PixelfedDirectoryController extends Controller
         $res['curated_onboarding'] = $curatedOnboarding;
 
         $oauthEnabled = ConfigCache::whereK('pixelfed.oauth_enabled')->first();
-        if($oauthEnabled) {
+        if ($oauthEnabled) {
             $keys = file_exists(storage_path('oauth-public.key')) && file_exists(storage_path('oauth-private.key'));
             $res['oauth_enabled'] = (bool) $oauthEnabled && $keys;
         }
 
         $activityPubEnabled = ConfigCache::whereK('federation.activitypub.enabled')->first();
-        if($activityPubEnabled) {
+        if ($activityPubEnabled) {
             $res['activitypub_enabled'] = (bool) $activityPubEnabled;
         }
 
         $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'),
         ];
 
         $res['is_eligible'] = $this->validVal($res, 'admin') &&
@@ -115,29 +119,34 @@ class PixelfedDirectoryController extends Controller
             $this->validVal($res, 'privacy_pledge') &&
             $this->validVal($res, 'location');
 
-        if(config_cache('pixelfed.directory.testimonials')) {
+        if (config_cache('pixelfed.directory.testimonials')) {
             $res['testimonials'] = collect(json_decode(config_cache('pixelfed.directory.testimonials'), true))
-                ->map(function($testimonial) {
+                ->map(function ($testimonial) {
                     $profile = AccountService::get($testimonial['profile_id']);
+
                     return [
                         'profile' => [
                             'username' => $profile['username'],
                             'display_name' => $profile['display_name'],
                             'avatar' => $profile['avatar'],
-                            'created_at' => $profile['created_at']
+                            'created_at' => $profile['created_at'],
                         ],
-                        'body' => $testimonial['body']
+                        'body' => $testimonial['body'],
                     ];
                 });
         }
 
         $res['features_enabled'] = [
-            'stories' => (bool) config_cache('instance.stories.enabled')
+            'stories' => (bool) config_cache('instance.stories.enabled'),
         ];
 
+        $statusesCount = InstanceService::totalLocalStatuses();
+        $usersCount = Cache::remember('api:nodeinfo:users', 43200, function () {
+            return User::count();
+        });
         $res['stats'] = [
-            'user_count' => \App\User::count(),
-            'post_count' => \App\Status::whereNull('uri')->count(),
+            'user_count' => (int) $usersCount,
+            'post_count' => (int) $statusesCount,
         ];
 
         $res['primary_locale'] = config('app.locale');
@@ -150,19 +159,18 @@ class PixelfedDirectoryController extends Controller
 
     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;
         }
 
         return $res[$val];
     }
-
 }

+ 7 - 5
app/Http/Controllers/ProfileController.php

@@ -172,6 +172,8 @@ class ProfileController extends Controller
 
         $user = $this->getCachedUser($username);
 
+        abort_if(! $user, 404);
+
         return redirect($user->url());
     }
 
@@ -252,7 +254,7 @@ class ProfileController extends Controller
 
         abort_if(! $profile || $profile['locked'] || ! $profile['local'], 404);
 
-        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile['id'], 86400, function () use ($profile) {
+        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile['id'], 3600, function () use ($profile) {
             $uid = User::whereProfileId($profile['id'])->first();
             if (! $uid) {
                 return true;
@@ -267,7 +269,7 @@ class ProfileController extends Controller
 
         abort_if($aiCheck, 404);
 
-        $enabled = Cache::remember('profile:atom:enabled:'.$profile['id'], 84600, function () use ($profile) {
+        $enabled = Cache::remember('profile:atom:enabled:'.$profile['id'], 86400, function () use ($profile) {
             $uid = User::whereProfileId($profile['id'])->first();
             if (! $uid) {
                 return false;
@@ -346,7 +348,7 @@ class ProfileController extends Controller
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 
-        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile->id, 86400, function () use ($profile) {
+        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile->id, 3600, function () use ($profile) {
             $exists = AccountInterstitial::whereUserId($profile->user_id)->where('is_spam', 1)->count();
             if ($exists) {
                 return true;
@@ -359,7 +361,7 @@ class ProfileController extends Controller
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 
-        if (AccountService::canEmbed($profile->user_id) == false) {
+        if (AccountService::canEmbed($profile->id) == false) {
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 
@@ -371,7 +373,7 @@ class ProfileController extends Controller
 
     public function stories(Request $request, $username)
     {
-        abort_if(! config_cache('instance.stories.enabled') || ! $request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
         $pid = $profile->id;
         $authed = Auth::user()->profile_id;

Разлика између датотеке није приказан због своје велике величине
+ 389 - 394
app/Http/Controllers/PublicApiController.php


+ 90 - 80
app/Http/Controllers/RemoteAuthController.php

@@ -2,22 +2,20 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Support\Str;
-use Illuminate\Http\Request;
-use App\Services\Account\RemoteAuthService;
 use App\Models\RemoteAuth;
-use App\Profile;
-use App\Instance;
-use App\User;
-use Purify;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
-use Illuminate\Auth\Events\Registered;
-use App\Util\Lexer\RestrictedNames;
+use App\Services\Account\RemoteAuthService;
 use App\Services\EmailService;
 use App\Services\MediaStorageService;
+use App\User;
 use App\Util\ActivityPub\Helpers;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
 use InvalidArgumentException;
+use Purify;
 
 class RemoteAuthController extends Controller
 {
@@ -30,9 +28,10 @@ class RemoteAuthController extends Controller
             config('remote-auth.mastodon.ignore_closed_state') &&
             config('remote-auth.mastodon.enabled')
         ), 404);
-        if($request->user()) {
+        if ($request->user()) {
             return redirect('/');
         }
+
         return view('auth.remote.start');
     }
 
@@ -51,25 +50,27 @@ class RemoteAuthController extends Controller
             config('remote-auth.mastodon.enabled')
         ), 404);
 
-        if(config('remote-auth.mastodon.domains.only_custom')) {
+        if (config('remote-auth.mastodon.domains.only_custom')) {
             $res = config('remote-auth.mastodon.domains.custom');
-            if(!$res || !strlen($res)) {
+            if (! $res || ! strlen($res)) {
                 return [];
             }
             $res = explode(',', $res);
+
             return response()->json($res);
         }
 
-        if( config('remote-auth.mastodon.domains.custom') &&
-            !config('remote-auth.mastodon.domains.only_default') &&
+        if (config('remote-auth.mastodon.domains.custom') &&
+            ! config('remote-auth.mastodon.domains.only_default') &&
             strlen(config('remote-auth.mastodon.domains.custom')) > 3 &&
             strpos(config('remote-auth.mastodon.domains.custom'), '.') > -1
         ) {
             $res = config('remote-auth.mastodon.domains.custom');
-            if(!$res || !strlen($res)) {
+            if (! $res || ! strlen($res)) {
                 return [];
             }
             $res = explode(',', $res);
+
             return response()->json($res);
         }
 
@@ -93,57 +94,62 @@ class RemoteAuthController extends Controller
 
         $domain = $request->input('domain');
 
-        if(str_starts_with(strtolower($domain), 'http')) {
+        if (str_starts_with(strtolower($domain), 'http')) {
             $res = [
                 'domain' => $domain,
                 'ready' => false,
-                'action' => 'incompatible_domain'
+                'action' => 'incompatible_domain',
             ];
+
             return response()->json($res);
         }
 
-        $validateInstance = Helpers::validateUrl('https://' . $domain . '/?block-check=' . time());
+        $validateInstance = Helpers::validateUrl('https://'.$domain.'/?block-check='.time());
 
-        if(!$validateInstance) {
-             $res = [
+        if (! $validateInstance) {
+            $res = [
                 'domain' => $domain,
                 'ready' => false,
-                'action' => 'blocked_domain'
+                'action' => 'blocked_domain',
             ];
+
             return response()->json($res);
         }
 
         $compatible = RemoteAuthService::isDomainCompatible($domain);
 
-        if(!$compatible) {
+        if (! $compatible) {
             $res = [
                 'domain' => $domain,
                 'ready' => false,
-                'action' => 'incompatible_domain'
+                'action' => 'incompatible_domain',
             ];
+
             return response()->json($res);
         }
 
-        if(config('remote-auth.mastodon.domains.only_default')) {
+        if (config('remote-auth.mastodon.domains.only_default')) {
             $defaultDomains = explode(',', config('remote-auth.mastodon.domains.default'));
-            if(!in_array($domain, $defaultDomains)) {
+            if (! in_array($domain, $defaultDomains)) {
                 $res = [
                     'domain' => $domain,
                     'ready' => false,
-                    'action' => 'incompatible_domain'
+                    'action' => 'incompatible_domain',
                 ];
+
                 return response()->json($res);
             }
         }
 
-        if(config('remote-auth.mastodon.domains.only_custom') && config('remote-auth.mastodon.domains.custom')) {
+        if (config('remote-auth.mastodon.domains.only_custom') && config('remote-auth.mastodon.domains.custom')) {
             $customDomains = explode(',', config('remote-auth.mastodon.domains.custom'));
-            if(!in_array($domain, $customDomains)) {
+            if (! in_array($domain, $customDomains)) {
                 $res = [
                     'domain' => $domain,
                     'ready' => false,
-                    'action' => 'incompatible_domain'
+                    'action' => 'incompatible_domain',
                 ];
+
                 return response()->json($res);
             }
         }
@@ -163,13 +169,13 @@ class RemoteAuthController extends Controller
             'state' => $state,
         ]);
 
-        $request->session()->put('oauth_redirect_to', 'https://' . $domain . '/oauth/authorize?' . $query);
+        $request->session()->put('oauth_redirect_to', 'https://'.$domain.'/oauth/authorize?'.$query);
 
         $dsh = Str::random(17);
         $res = [
             'domain' => $domain,
             'ready' => true,
-            'dsh' => $dsh
+            'dsh' => $dsh,
         ];
 
         return response()->json($res);
@@ -185,7 +191,7 @@ class RemoteAuthController extends Controller
             config('remote-auth.mastodon.enabled')
         ), 404);
 
-        if(!$request->filled('d') || !$request->filled('dsh') || !$request->session()->exists('oauth_redirect_to')) {
+        if (! $request->filled('d') || ! $request->filled('dsh') || ! $request->session()->exists('oauth_redirect_to')) {
             return redirect('/login');
         }
 
@@ -204,7 +210,7 @@ class RemoteAuthController extends Controller
 
         $domain = $request->session()->get('oauth_domain');
 
-        if($request->filled('code')) {
+        if ($request->filled('code')) {
             $code = $request->input('code');
             $state = $request->session()->pull('state');
 
@@ -216,12 +222,14 @@ class RemoteAuthController extends Controller
 
             $res = RemoteAuthService::getToken($domain, $code);
 
-            if(!$res || !isset($res['access_token'])) {
+            if (! $res || ! isset($res['access_token'])) {
                 $request->session()->regenerate();
+
                 return redirect('/login');
             }
 
             $request->session()->put('oauth_remote_session_token', $res['access_token']);
+
             return redirect('/auth/mastodon/getting-started');
         }
 
@@ -237,9 +245,10 @@ class RemoteAuthController extends Controller
             config('remote-auth.mastodon.ignore_closed_state') &&
             config('remote-auth.mastodon.enabled')
         ), 404);
-        if($request->user()) {
+        if ($request->user()) {
             return redirect('/');
         }
+
         return view('auth.remote.onboarding');
     }
 
@@ -261,36 +270,36 @@ class RemoteAuthController extends Controller
 
         $res = RemoteAuthService::getVerifyCredentials($domain, $token);
 
-        abort_if(!$res || !isset($res['acct']), 403, 'Invalid credentials');
+        abort_if(! $res || ! isset($res['acct']), 403, 'Invalid credentials');
 
-        $webfinger = strtolower('@' . $res['acct'] . '@' . $domain);
+        $webfinger = strtolower('@'.$res['acct'].'@'.$domain);
         $request->session()->put('oauth_masto_webfinger', $webfinger);
 
-        if(config('remote-auth.mastodon.max_uses.enabled')) {
+        if (config('remote-auth.mastodon.max_uses.enabled')) {
             $limit = config('remote-auth.mastodon.max_uses.limit');
             $uses = RemoteAuthService::lookupWebfingerUses($webfinger);
-            if($uses >= $limit) {
+            if ($uses >= $limit) {
                 return response()->json([
                     'code' => 200,
                     'msg' => 'Success!',
-                    'action' => 'max_uses_reached'
+                    'action' => 'max_uses_reached',
                 ]);
             }
         }
 
         $exists = RemoteAuth::whereDomain($domain)->where('webfinger', $webfinger)->whereNotNull('user_id')->first();
-        if($exists && $exists->user_id) {
+        if ($exists && $exists->user_id) {
             return response()->json([
                 'code' => 200,
                 'msg' => 'Success!',
-                'action' => 'redirect_existing_user'
+                'action' => 'redirect_existing_user',
             ]);
         }
 
         return response()->json([
             'code' => 200,
             'msg' => 'Success!',
-            'action' => 'onboard'
+            'action' => 'onboard',
         ]);
     }
 
@@ -311,7 +320,7 @@ class RemoteAuthController extends Controller
         $token = $request->session()->get('oauth_remote_session_token');
 
         $res = RemoteAuthService::getVerifyCredentials($domain, $token);
-        $res['_webfinger'] = strtolower('@' . $res['acct'] . '@' . $domain);
+        $res['_webfinger'] = strtolower('@'.$res['acct'].'@'.$domain);
         $res['_domain'] = strtolower($domain);
         $request->session()->put('oauth_remasto_id', $res['id']);
 
@@ -324,7 +333,7 @@ class RemoteAuthController extends Controller
             'bearer_token' => $token,
             'verify_credentials' => $res,
             'last_verify_credentials_at' => now(),
-            'last_successful_login_at' => now()
+            'last_successful_login_at' => now(),
         ]);
 
         $request->session()->put('oauth_masto_raid', $ra->id);
@@ -355,24 +364,24 @@ class RemoteAuthController extends Controller
                     $underscore = substr_count($value, '_');
                     $period = substr_count($value, '.');
 
-                    if(ends_with($value, ['.php', '.js', '.css'])) {
+                    if (ends_with($value, ['.php', '.js', '.css'])) {
                         return $fail('Username is invalid.');
                     }
 
-                    if(($dash + $underscore + $period) > 1) {
+                    if (($dash + $underscore + $period) > 1) {
                         return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
                     }
 
-                    if (!ctype_alnum($value[0])) {
+                    if (! ctype_alnum($value[0])) {
                         return $fail('Username is invalid. Must start with a letter or number.');
                     }
 
-                    if (!ctype_alnum($value[strlen($value) - 1])) {
+                    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)) {
+                    if (! ctype_alnum($val)) {
                         return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
                     }
 
@@ -380,8 +389,8 @@ class RemoteAuthController extends Controller
                     if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
                         return $fail('Username cannot be used.');
                     }
-                }
-            ]
+                },
+            ],
         ]);
         $username = strtolower($request->input('username'));
 
@@ -390,7 +399,7 @@ class RemoteAuthController extends Controller
         return response()->json([
             'code' => 200,
             'username' => $username,
-            'exists' => $exists
+            'exists' => $exists,
         ]);
     }
 
@@ -411,7 +420,7 @@ class RemoteAuthController extends Controller
             'email' => [
                 'required',
                 'email:strict,filter_unicode,dns,spoof',
-            ]
+            ],
         ]);
 
         $email = $request->input('email');
@@ -422,7 +431,7 @@ class RemoteAuthController extends Controller
             'code' => 200,
             'email' => $email,
             'exists' => $exists,
-            'banned' => $banned
+            'banned' => $banned,
         ]);
     }
 
@@ -445,18 +454,18 @@ class RemoteAuthController extends Controller
 
         $res = RemoteAuthService::getFollowing($domain, $token, $id);
 
-        if(!$res) {
+        if (! $res) {
             return response()->json([
                 'code' => 200,
-                'following' => []
+                'following' => [],
             ]);
         }
 
-        $res = collect($res)->filter(fn($acct) => Helpers::validateUrl($acct['url']))->values()->toArray();
+        $res = collect($res)->filter(fn ($acct) => Helpers::validateUrl($acct['url']))->values()->toArray();
 
         return response()->json([
             'code' => 200,
-            'following' => $res
+            'following' => $res,
         ]);
     }
 
@@ -487,24 +496,24 @@ class RemoteAuthController extends Controller
                     $underscore = substr_count($value, '_');
                     $period = substr_count($value, '.');
 
-                    if(ends_with($value, ['.php', '.js', '.css'])) {
+                    if (ends_with($value, ['.php', '.js', '.css'])) {
                         return $fail('Username is invalid.');
                     }
 
-                    if(($dash + $underscore + $period) > 1) {
+                    if (($dash + $underscore + $period) > 1) {
                         return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
                     }
 
-                    if (!ctype_alnum($value[0])) {
+                    if (! ctype_alnum($value[0])) {
                         return $fail('Username is invalid. Must start with a letter or number.');
                     }
 
-                    if (!ctype_alnum($value[strlen($value) - 1])) {
+                    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)) {
+                    if (! ctype_alnum($val)) {
                         return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
                     }
 
@@ -512,10 +521,10 @@ class RemoteAuthController extends Controller
                     if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
                         return $fail('Username cannot be used.');
                     }
-                }
+                },
             ],
             'password' => 'required|string|min:8|confirmed',
-            'name' => 'nullable|max:30'
+            'name' => 'nullable|max:30',
         ]);
 
         $email = $request->input('email');
@@ -527,7 +536,7 @@ class RemoteAuthController extends Controller
             'name' => $name,
             'username' => $username,
             'password' => $password,
-            'email' => $email
+            'email' => $email,
         ]);
 
         $raid = $request->session()->pull('oauth_masto_raid');
@@ -541,7 +550,7 @@ class RemoteAuthController extends Controller
         return [
             'code' => 200,
             'msg' => 'Success',
-            'token' => $token
+            'token' => $token,
         ];
     }
 
@@ -585,7 +594,7 @@ class RemoteAuthController extends Controller
         abort_unless($request->session()->exists('oauth_remasto_id'), 403);
 
         $this->validate($request, [
-            'account' => 'required|url'
+            'account' => 'required|url',
         ]);
 
         $account = $request->input('account');
@@ -594,10 +603,10 @@ class RemoteAuthController extends Controller
         $host = strtolower(config('pixelfed.domain.app'));
         $domain = strtolower(parse_url($account, PHP_URL_HOST));
 
-        if($domain == $host) {
+        if ($domain == $host) {
             $username = Str::of($account)->explode('/')->last();
             $user = User::where('username', $username)->first();
-            if($user) {
+            if ($user) {
                 return ['id' => (string) $user->profile_id];
             } else {
                 return [];
@@ -605,7 +614,7 @@ class RemoteAuthController extends Controller
         } else {
             try {
                 $profile = Helpers::profileFetch($account);
-                if($profile) {
+                if ($profile) {
                     return ['id' => (string) $profile->id];
                 } else {
                     return [];
@@ -635,13 +644,13 @@ class RemoteAuthController extends Controller
         $user = $request->user();
         $profile = $user->profile;
 
-        abort_if(!$profile->avatar, 404, 'Missing avatar');
+        abort_if(! $profile->avatar, 404, 'Missing avatar');
 
         $avatar = $profile->avatar;
         $avatar->remote_url = $request->input('avatar_url');
         $avatar->save();
 
-        MediaStorageService::avatar($avatar, config_cache('pixelfed.cloud_storage') == false);
+        MediaStorageService::avatar($avatar, (bool) config_cache('pixelfed.cloud_storage') == false);
 
         return [200];
     }
@@ -657,7 +666,7 @@ class RemoteAuthController extends Controller
         ), 404);
         abort_unless($request->user(), 404);
 
-        $currentWebfinger = '@' . $request->user()->username . '@' . config('pixelfed.domain.app');
+        $currentWebfinger = '@'.$request->user()->username.'@'.config('pixelfed.domain.app');
         $ra = RemoteAuth::where('user_id', $request->user()->id)->firstOrFail();
         RemoteAuthService::submitToBeagle(
             $ra->webfinger,
@@ -691,19 +700,20 @@ class RemoteAuthController extends Controller
         $user = User::findOrFail($ra->user_id);
         abort_if($user->is_admin || $user->status != null, 422, 'Invalid auth action');
         Auth::loginUsingId($ra->user_id);
+
         return [200];
     }
 
     protected function createUser($data)
     {
         event(new Registered($user = User::create([
-            'name'     => Purify::clean($data['name']),
+            'name' => Purify::clean($data['name']),
             'username' => $data['username'],
-            'email'    => $data['email'],
+            'email' => $data['email'],
             'password' => Hash::make($data['password']),
             'email_verified_at' => config('remote-auth.mastodon.contraints.skip_email_verification') ? now() : null,
             'app_register_ip' => request()->ip(),
-            'register_source' => 'mastodon'
+            'register_source' => 'mastodon',
         ])));
 
         $this->guarder()->login($user);

+ 353 - 354
app/Http/Controllers/SearchController.php

@@ -2,368 +2,367 @@
 
 namespace App\Http\Controllers;
 
-use Auth;
 use App\Hashtag;
 use App\Place;
 use App\Profile;
+use App\Services\WebfingerService;
 use App\Status;
-use Illuminate\Http\Request;
 use App\Util\ActivityPub\Helpers;
+use Auth;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Str;
-use App\Transformer\Api\{
-	AccountTransformer,
-	HashtagTransformer,
-	StatusTransformer,
-};
-use App\Services\WebfingerService;
 
 class SearchController extends Controller
 {
-	public $tokens = [];
-	public $term = '';
-	public $hash = '';
-	public $cacheKey = 'api:search:tag:';
-
-	public function __construct()
-	{
-		$this->middleware('auth');
-	}
-
-	public function searchAPI(Request $request)
-	{
-		$this->validate($request, [
-			'q' => 'required|string|min:3|max:120',
-			'src' => 'required|string|in:metro',
-			'v' => 'required|integer|in:2',
-			'scope' => 'required|in:all,hashtag,profile,remote,webfinger'
-		]);
-
-		$scope = $request->input('scope') ?? 'all';
-		$this->term = e(urldecode($request->input('q')));
-		$this->hash = hash('sha256', $this->term);
-
-		switch ($scope) {
-			case 'all':
-				$this->getHashtags();
-				$this->getPosts();
-				$this->getProfiles();
-				// $this->getPlaces();
-				break;
-
-			case 'hashtag':
-				$this->getHashtags();
-				break;
-
-			case 'profile':
-				$this->getProfiles();
-				break;
-
-			case 'webfinger':
-				$this->webfingerSearch();
-				break;
-
-			case 'remote':
-				$this->remoteLookupSearch();
-				break;
-
-			case 'place':
-				$this->getPlaces();
-				break;
-
-			default:
-				break;
-		}
-
-		return response()->json($this->tokens, 200, [], JSON_PRETTY_PRINT);
-	}
-
-	protected function getPosts()
-	{
-		$tag = $this->term;
-		$hash = hash('sha256', $tag);
-		if( Helpers::validateUrl($tag) != false &&
-			Helpers::validateLocalUrl($tag) != true &&
-			config_cache('federation.activitypub.enabled') == true &&
-			config('federation.activitypub.remoteFollow') == true
-		) {
-			$remote = Helpers::fetchFromUrl($tag);
-			if( isset($remote['type']) &&
-				$remote['type'] == 'Note') {
-				$item = Helpers::statusFetch($tag);
-				$this->tokens['posts'] = [[
-					'count'  => 0,
-					'url'    => $item->url(),
-					'type'   => 'status',
-					'value'  => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
-					'tokens' => [$item->caption],
-					'name'   => $item->caption,
-					'thumb'  => $item->thumb(),
-				]];
-			}
-		} else {
-			$posts = Status::select('id', 'profile_id', 'caption', 'created_at')
-						->whereHas('media')
-						->whereNull('in_reply_to_id')
-						->whereNull('reblog_of_id')
-						->whereProfileId(Auth::user()->profile_id)
-						->where('caption', 'like', '%'.$tag.'%')
-						->latest()
-						->limit(10)
-						->get();
-
-			if($posts->count() > 0) {
-				$posts = $posts->map(function($item, $key) {
-					return [
-						'count'  => 0,
-						'url'    => $item->url(),
-						'type'   => 'status',
-						'value'  => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
-						'tokens' => [$item->caption],
-						'name'   => $item->caption,
-						'thumb'  => $item->thumb(),
-						'filter' => $item->firstMedia()->filter_class
-					];
-				});
-				$this->tokens['posts'] = $posts;
-			}
-		}
-	}
-
-	protected function getHashtags()
-	{
-		$tag = $this->term;
-		$key = $this->cacheKey . 'hashtags:' . $this->hash;
-		$ttl = now()->addMinutes(1);
-		$tokens = Cache::remember($key, $ttl, function() use($tag) {
-			$htag = Str::startsWith($tag, '#') == true ? mb_substr($tag, 1) : $tag;
-			$hashtags = Hashtag::select('id', 'name', 'slug')
-				->where('slug', 'like', '%'.$htag.'%')
-				->whereHas('posts')
-				->limit(20)
-				->get();
-			if($hashtags->count() > 0) {
-				$tags = $hashtags->map(function ($item, $key) {
-					return [
-						'count'  => $item->posts()->count(),
-						'url'    => $item->url(),
-						'type'   => 'hashtag',
-						'value'  => $item->name,
-						'tokens' => '',
-						'name'   => null,
-					];
-				});
-				return $tags;
-			}
-		});
-		$this->tokens['hashtags'] = $tokens;
-	}
-
-	protected function getPlaces()
-	{
-		$tag = $this->term;
-		// $key = $this->cacheKey . 'places:' . $this->hash;
-		// $ttl = now()->addHours(12);
-		// $tokens = Cache::remember($key, $ttl, function() use($tag) {
-			$htag = Str::contains($tag, ',') == true ? explode(',', $tag) : [$tag];
-			$hashtags = Place::select('id', 'name', 'slug', 'country')
-				->where('name', 'like', '%'.$htag[0].'%')
-				->paginate(20);
-			$tags = [];
-			if($hashtags->count() > 0) {
-				$tags = $hashtags->map(function ($item, $key) {
-					return [
-						'count'     => null,
-						'url'       => $item->url(),
-						'type'      => 'place',
-						'value'     => $item->name . ', ' . $item->country,
-						'tokens'    => '',
-						'name'      => null,
-						'city'      => $item->name,
-						'country'   => $item->country
-					];
-				});
-				// return $tags;
-			}
-		// });
-		$this->tokens['places'] = $tags;
-		$this->tokens['placesPagination'] = [
-			'total' => $hashtags->total(),
-			'current_page' => $hashtags->currentPage(),
-			'last_page' => $hashtags->lastPage()
-		];
-	}
-
-	protected function getProfiles()
-	{
-		$tag = $this->term;
-		$remoteKey = $this->cacheKey . 'profiles:remote:' . $this->hash;
-		$key = $this->cacheKey . 'profiles:' . $this->hash;
-		$remoteTtl = now()->addMinutes(15);
-		$ttl = now()->addHours(2);
-		if( Helpers::validateUrl($tag) != false &&
-			Helpers::validateLocalUrl($tag) != true &&
-			config_cache('federation.activitypub.enabled') == true &&
-			config('federation.activitypub.remoteFollow') == true
-		) {
-			$remote = Helpers::fetchFromUrl($tag);
-			if( isset($remote['type']) &&
-				$remote['type'] == 'Person'
-			) {
-				$this->tokens['profiles'] = Cache::remember($remoteKey, $remoteTtl, function() use($tag) {
-					$item = Helpers::profileFirstOrNew($tag);
-					$tokens = [[
-						'count'  => 1,
-						'url'    => $item->url(),
-						'type'   => 'profile',
-						'value'  => $item->username,
-						'tokens' => [$item->username],
-						'name'   => $item->name,
-						'entity' => [
-							'id' => (string) $item->id,
-							'following' => $item->followedBy(Auth::user()->profile),
-							'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
-							'thumb' => $item->avatarUrl(),
-							'local' => (bool) !$item->domain,
-							'post_count' => $item->statuses()->count()
-						]
-					]];
-					return $tokens;
-				});
-			}
-		}
-
-		else {
-			$this->tokens['profiles'] = Cache::remember($key, $ttl, function() use($tag) {
-				if(Str::startsWith($tag, '@')) {
-					$tag = substr($tag, 1);
-				}
-				$users = Profile::select('status', 'domain', 'username', 'name', 'id')
-					->whereNull('status')
-					->where('username', 'like', '%'.$tag.'%')
-					->limit(20)
-					->orderBy('domain')
-					->get();
-
-				if($users->count() > 0) {
-					return $users->map(function ($item, $key) {
-						return [
-							'count'  => 0,
-							'url'    => $item->url(),
-							'type'   => 'profile',
-							'value'  => $item->username,
-							'tokens' => [$item->username],
-							'name'   => $item->name,
-							'avatar' => $item->avatarUrl(),
-							'id'     =>  (string) $item->id,
-							'entity' => [
-								'id' => (string) $item->id,
-								'following' => $item->followedBy(Auth::user()->profile),
-								'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
-								'thumb' => $item->avatarUrl(),
-								'local' => (bool) !$item->domain,
-								'post_count' => $item->statuses()->count()
-							]
-						];
-					});
-				}
-			});
-		}
-	}
-
-	public function results(Request $request)
-	{
-		$this->validate($request, [
-			'q' => 'required|string|min:1',
-		]);
-
-		return view('search.results');
-	}
-
-	protected function webfingerSearch()
-	{
-		$wfs = WebfingerService::lookup($this->term);
-
-		if(empty($wfs)) {
-			return;
-		}
-
-		$this->tokens['profiles'] = [
-			[
-				'count'  => 1,
-				'url'    => $wfs['url'],
-				'type'   => 'profile',
-				'value'  => $wfs['username'],
-				'tokens' => [$wfs['username']],
-				'name'   => $wfs['display_name'],
-				'entity' => [
-					'id' => (string) $wfs['id'],
-					'following' => null,
-					'follow_request' => null,
-					'thumb' => $wfs['avatar'],
-					'local' => (bool) $wfs['local']
-				]
-			]
-		];
-		return;
-	}
-
-	protected function remotePostLookup()
-	{
-		$tag = $this->term;
-		$hash = hash('sha256', $tag);
-		$local = Helpers::validateLocalUrl($tag);
-		$valid = Helpers::validateUrl($tag);
-
-		if($valid == false || $local == true) {
-			return;
-		}
-
-		if(Status::whereUri($tag)->whereLocal(false)->exists()) {
-			$item = Status::whereUri($tag)->first();
-			$media = $item->firstMedia();
-			$url = null;
-			if($media) {
-				$url = $media->remote_url;
-			}
-			$this->tokens['posts'] = [[
-				'count'  => 0,
-				'url'    => "/i/web/post/_/$item->profile_id/$item->id",
-				'type'   => 'status',
-				'username' => $item->profile->username,
-				'caption'   => $item->rendered ?? $item->caption,
-				'thumb'  => $url,
-				'timestamp' => $item->created_at->diffForHumans()
-			]];
-		}
-
-		$remote = Helpers::fetchFromUrl($tag);
-
-		if(isset($remote['type']) && $remote['type'] == 'Note') {
-			$item = Helpers::statusFetch($tag);
-			$media = $item->firstMedia();
-			$url = null;
-			if($media) {
-				$url = $media->remote_url;
-			}
-			$this->tokens['posts'] = [[
-				'count'  => 0,
-				'url'    => "/i/web/post/_/$item->profile_id/$item->id",
-				'type'   => 'status',
-				'username' => $item->profile->username,
-				'caption'   => $item->rendered ?? $item->caption,
-				'thumb'  => $url,
-				'timestamp' => $item->created_at->diffForHumans()
-			]];
-		}
-	}
-
-	protected function remoteLookupSearch()
-	{
-		if(!Helpers::validateUrl($this->term)) {
-			return;
-		}
-		$this->getProfiles();
-		$this->remotePostLookup();
-	}
+    public $tokens = [];
+
+    public $term = '';
+
+    public $hash = '';
+
+    public $cacheKey = 'api:search:tag:';
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function searchAPI(Request $request)
+    {
+        $this->validate($request, [
+            'q' => 'required|string|min:3|max:120',
+            'src' => 'required|string|in:metro',
+            'v' => 'required|integer|in:2',
+            'scope' => 'required|in:all,hashtag,profile,remote,webfinger',
+        ]);
+
+        $scope = $request->input('scope') ?? 'all';
+        $this->term = e(urldecode($request->input('q')));
+        $this->hash = hash('sha256', $this->term);
+
+        switch ($scope) {
+            case 'all':
+                $this->getHashtags();
+                $this->getPosts();
+                $this->getProfiles();
+                // $this->getPlaces();
+                break;
+
+            case 'hashtag':
+                $this->getHashtags();
+                break;
+
+            case 'profile':
+                $this->getProfiles();
+                break;
+
+            case 'webfinger':
+                $this->webfingerSearch();
+                break;
+
+            case 'remote':
+                $this->remoteLookupSearch();
+                break;
+
+            case 'place':
+                $this->getPlaces();
+                break;
+
+            default:
+                break;
+        }
+
+        return response()->json($this->tokens, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    protected function getPosts()
+    {
+        $tag = $this->term;
+        $hash = hash('sha256', $tag);
+        if (Helpers::validateUrl($tag) != false &&
+            Helpers::validateLocalUrl($tag) != true &&
+            (bool) config_cache('federation.activitypub.enabled') == true &&
+            config('federation.activitypub.remoteFollow') == true
+        ) {
+            $remote = Helpers::fetchFromUrl($tag);
+            if (isset($remote['type']) &&
+                in_array($remote['type'], ['Note', 'Question'])
+            ) {
+                $item = Helpers::statusFetch($tag);
+                $this->tokens['posts'] = [[
+                    'count' => 0,
+                    'url' => $item->url(),
+                    'type' => 'status',
+                    'value' => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
+                    'tokens' => [$item->caption],
+                    'name' => $item->caption,
+                    'thumb' => $item->thumb(),
+                ]];
+            }
+        } else {
+            $posts = Status::select('id', 'profile_id', 'caption', 'created_at')
+                ->whereHas('media')
+                ->whereNull('in_reply_to_id')
+                ->whereNull('reblog_of_id')
+                ->whereProfileId(Auth::user()->profile_id)
+                ->where('caption', 'like', '%'.$tag.'%')
+                ->latest()
+                ->limit(10)
+                ->get();
+
+            if ($posts->count() > 0) {
+                $posts = $posts->map(function ($item, $key) {
+                    return [
+                        'count' => 0,
+                        'url' => $item->url(),
+                        'type' => 'status',
+                        'value' => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
+                        'tokens' => [$item->caption],
+                        'name' => $item->caption,
+                        'thumb' => $item->thumb(),
+                        'filter' => $item->firstMedia()->filter_class,
+                    ];
+                });
+                $this->tokens['posts'] = $posts;
+            }
+        }
+    }
+
+    protected function getHashtags()
+    {
+        $tag = $this->term;
+        $key = $this->cacheKey.'hashtags:'.$this->hash;
+        $ttl = now()->addMinutes(1);
+        $tokens = Cache::remember($key, $ttl, function () use ($tag) {
+            $htag = Str::startsWith($tag, '#') == true ? mb_substr($tag, 1) : $tag;
+            $hashtags = Hashtag::select('id', 'name', 'slug')
+                ->where('slug', 'like', '%'.$htag.'%')
+                ->whereHas('posts')
+                ->limit(20)
+                ->get();
+            if ($hashtags->count() > 0) {
+                $tags = $hashtags->map(function ($item, $key) {
+                    return [
+                        'count' => $item->posts()->count(),
+                        'url' => $item->url(),
+                        'type' => 'hashtag',
+                        'value' => $item->name,
+                        'tokens' => '',
+                        'name' => null,
+                    ];
+                });
+
+                return $tags;
+            }
+        });
+        $this->tokens['hashtags'] = $tokens;
+    }
+
+    protected function getPlaces()
+    {
+        $tag = $this->term;
+        // $key = $this->cacheKey . 'places:' . $this->hash;
+        // $ttl = now()->addHours(12);
+        // $tokens = Cache::remember($key, $ttl, function() use($tag) {
+        $htag = Str::contains($tag, ',') == true ? explode(',', $tag) : [$tag];
+        $hashtags = Place::select('id', 'name', 'slug', 'country')
+            ->where('name', 'like', '%'.$htag[0].'%')
+            ->paginate(20);
+        $tags = [];
+        if ($hashtags->count() > 0) {
+            $tags = $hashtags->map(function ($item, $key) {
+                return [
+                    'count' => null,
+                    'url' => $item->url(),
+                    'type' => 'place',
+                    'value' => $item->name.', '.$item->country,
+                    'tokens' => '',
+                    'name' => null,
+                    'city' => $item->name,
+                    'country' => $item->country,
+                ];
+            });
+            // return $tags;
+        }
+        // });
+        $this->tokens['places'] = $tags;
+        $this->tokens['placesPagination'] = [
+            'total' => $hashtags->total(),
+            'current_page' => $hashtags->currentPage(),
+            'last_page' => $hashtags->lastPage(),
+        ];
+    }
+
+    protected function getProfiles()
+    {
+        $tag = $this->term;
+        $remoteKey = $this->cacheKey.'profiles:remote:'.$this->hash;
+        $key = $this->cacheKey.'profiles:'.$this->hash;
+        $remoteTtl = now()->addMinutes(15);
+        $ttl = now()->addHours(2);
+        if (Helpers::validateUrl($tag) != false &&
+            Helpers::validateLocalUrl($tag) != true &&
+            (bool) config_cache('federation.activitypub.enabled') == true &&
+            config('federation.activitypub.remoteFollow') == true
+        ) {
+            $remote = Helpers::fetchFromUrl($tag);
+            if (isset($remote['type']) &&
+                $remote['type'] == 'Person'
+            ) {
+                $this->tokens['profiles'] = Cache::remember($remoteKey, $remoteTtl, function () use ($tag) {
+                    $item = Helpers::profileFirstOrNew($tag);
+                    $tokens = [[
+                        'count' => 1,
+                        'url' => $item->url(),
+                        'type' => 'profile',
+                        'value' => $item->username,
+                        'tokens' => [$item->username],
+                        'name' => $item->name,
+                        'entity' => [
+                            'id' => (string) $item->id,
+                            'following' => $item->followedBy(Auth::user()->profile),
+                            'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
+                            'thumb' => $item->avatarUrl(),
+                            'local' => (bool) ! $item->domain,
+                            'post_count' => $item->statuses()->count(),
+                        ],
+                    ]];
+
+                    return $tokens;
+                });
+            }
+        } else {
+            $this->tokens['profiles'] = Cache::remember($key, $ttl, function () use ($tag) {
+                if (Str::startsWith($tag, '@')) {
+                    $tag = substr($tag, 1);
+                }
+                $users = Profile::select('status', 'domain', 'username', 'name', 'id')
+                    ->whereNull('status')
+                    ->where('username', 'like', '%'.$tag.'%')
+                    ->limit(20)
+                    ->orderBy('domain')
+                    ->get();
+
+                if ($users->count() > 0) {
+                    return $users->map(function ($item, $key) {
+                        return [
+                            'count' => 0,
+                            'url' => $item->url(),
+                            'type' => 'profile',
+                            'value' => $item->username,
+                            'tokens' => [$item->username],
+                            'name' => $item->name,
+                            'avatar' => $item->avatarUrl(),
+                            'id' => (string) $item->id,
+                            'entity' => [
+                                'id' => (string) $item->id,
+                                'following' => $item->followedBy(Auth::user()->profile),
+                                'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
+                                'thumb' => $item->avatarUrl(),
+                                'local' => (bool) ! $item->domain,
+                                'post_count' => $item->statuses()->count(),
+                            ],
+                        ];
+                    });
+                }
+            });
+        }
+    }
+
+    public function results(Request $request)
+    {
+        $this->validate($request, [
+            'q' => 'required|string|min:1',
+        ]);
+
+        return view('search.results');
+    }
+
+    protected function webfingerSearch()
+    {
+        $wfs = WebfingerService::lookup($this->term);
+
+        if (empty($wfs)) {
+            return;
+        }
+
+        $this->tokens['profiles'] = [
+            [
+                'count' => 1,
+                'url' => $wfs['url'],
+                'type' => 'profile',
+                'value' => $wfs['username'],
+                'tokens' => [$wfs['username']],
+                'name' => $wfs['display_name'],
+                'entity' => [
+                    'id' => (string) $wfs['id'],
+                    'following' => null,
+                    'follow_request' => null,
+                    'thumb' => $wfs['avatar'],
+                    'local' => (bool) $wfs['local'],
+                ],
+            ],
+        ];
+
+    }
+
+    protected function remotePostLookup()
+    {
+        $tag = $this->term;
+        $hash = hash('sha256', $tag);
+        $local = Helpers::validateLocalUrl($tag);
+        $valid = Helpers::validateUrl($tag);
+
+        if ($valid == false || $local == true) {
+            return;
+        }
+
+        if (Status::whereUri($tag)->whereLocal(false)->exists()) {
+            $item = Status::whereUri($tag)->first();
+            $media = $item->firstMedia();
+            $url = null;
+            if ($media) {
+                $url = $media->remote_url;
+            }
+            $this->tokens['posts'] = [[
+                'count' => 0,
+                'url' => "/i/web/post/_/$item->profile_id/$item->id",
+                'type' => 'status',
+                'username' => $item->profile->username,
+                'caption' => $item->rendered ?? $item->caption,
+                'thumb' => $url,
+                'timestamp' => $item->created_at->diffForHumans(),
+            ]];
+        }
+
+        $remote = Helpers::fetchFromUrl($tag);
+
+        if (isset($remote['type']) && $remote['type'] == 'Note') {
+            $item = Helpers::statusFetch($tag);
+            $media = $item->firstMedia();
+            $url = null;
+            if ($media) {
+                $url = $media->remote_url;
+            }
+            $this->tokens['posts'] = [[
+                'count' => 0,
+                'url' => "/i/web/post/_/$item->profile_id/$item->id",
+                'type' => 'status',
+                'username' => $item->profile->username,
+                'caption' => $item->rendered ?? $item->caption,
+                'thumb' => $url,
+                'timestamp' => $item->created_at->diffForHumans(),
+            ]];
+        }
+    }
+
+    protected function remoteLookupSearch()
+    {
+        if (! Helpers::validateUrl($this->term)) {
+            return;
+        }
+        $this->getProfiles();
+        $this->remotePostLookup();
+    }
 }

+ 21 - 22
app/Http/Controllers/Settings/HomeSettings.php

@@ -4,21 +4,17 @@ namespace App\Http\Controllers\Settings;
 
 use App\AccountLog;
 use App\EmailVerification;
+use App\Mail\PasswordChange;
 use App\Media;
-use App\Profile;
-use App\User;
-use App\UserFilter;
+use App\Services\AccountService;
+use App\Services\PronounService;
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\PrettyNumber;
 use Auth;
 use Cache;
-use DB;
+use Illuminate\Http\Request;
 use Mail;
 use Purify;
-use App\Mail\PasswordChange;
-use Illuminate\Http\Request;
-use App\Services\AccountService;
-use App\Services\PronounService;
 
 trait HomeSettings
 {
@@ -40,11 +36,11 @@ trait HomeSettings
     public function homeUpdate(Request $request)
     {
         $this->validate($request, [
-            'name'    => 'nullable|string|max:'.config('pixelfed.max_name_length'),
-            'bio'     => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
+            'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
+            'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
             'website' => 'nullable|url',
             'language' => 'nullable|string|min:2|max:5',
-            'pronouns' => 'nullable|array|max:4'
+            'pronouns' => 'nullable|array|max:4',
         ]);
 
         $changes = false;
@@ -57,14 +53,14 @@ trait HomeSettings
         $pronouns = $request->input('pronouns');
         $existingPronouns = PronounService::get($profile->id);
         $layout = $request->input('profile_layout');
-        if($layout) {
-            $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout;
+        if ($layout) {
+            $layout = ! in_array($layout, ['metro', 'moment']) ? 'metro' : $layout;
         }
 
         $enforceEmailVerification = config_cache('pixelfed.enforce_email_verification');
 
         // Only allow email to be updated if not yet verified
-        if (!$enforceEmailVerification || !$changes && $user->email_verified_at) {
+        if (! $enforceEmailVerification || ! $changes && $user->email_verified_at) {
             if ($profile->name != $name) {
                 $changes = true;
                 $user->name = $name;
@@ -81,7 +77,7 @@ trait HomeSettings
                 $profile->bio = Autolink::create()->autolink($bio);
             }
 
-            if($user->language != $language &&
+            if ($user->language != $language &&
                 in_array($language, \App\Util\Localization\Localization::languages())
             ) {
                 $changes = true;
@@ -89,8 +85,8 @@ trait HomeSettings
                 session()->put('locale', $language);
             }
 
-            if($existingPronouns != $pronouns) {
-                if($pronouns && in_array('Select Pronoun(s)', $pronouns)) {
+            if ($existingPronouns != $pronouns) {
+                if ($pronouns && in_array('Select Pronoun(s)', $pronouns)) {
                     PronounService::clear($profile->id);
                 } else {
                     PronounService::put($profile->id, $pronouns);
@@ -102,7 +98,9 @@ trait HomeSettings
             $user->save();
             $profile->save();
             Cache::forget('user:account:id:'.$user->id);
+            AccountService::forgetAccountSettings($profile->id);
             AccountService::del($profile->id);
+
             return redirect('/settings/home')->with('status', 'Profile successfully updated!');
         }
 
@@ -117,10 +115,10 @@ trait HomeSettings
     public function passwordUpdate(Request $request)
     {
         $this->validate($request, [
-        'current'                => 'required|string',
-        'password'               => 'required|string',
-        'password_confirmation'  => 'required|string',
-      ]);
+            'current' => 'required|string',
+            'password' => 'required|string',
+            'password_confirmation' => 'required|string',
+        ]);
 
         $current = $request->input('current');
         $new = $request->input('password');
@@ -144,6 +142,7 @@ trait HomeSettings
             $log->save();
 
             Mail::to($request->user())->send(new PasswordChange($user));
+
             return redirect('/settings/home')->with('status', 'Password successfully updated!');
         } else {
             return redirect()->back()->with('error', 'There was an error with your request! Please try again.');
@@ -159,7 +158,7 @@ trait HomeSettings
     public function emailUpdate(Request $request)
     {
         $this->validate($request, [
-            'email'   => 'required|email|unique:users,email',
+            'email' => 'required|email|unique:users,email',
         ]);
         $changes = false;
         $email = $request->input('email');

+ 59 - 39
app/Http/Controllers/Settings/PrivacySettings.php

@@ -2,30 +2,31 @@
 
 namespace App\Http\Controllers\Settings;
 
-use App\AccountLog;
-use App\EmailVerification;
-use App\Instance;
 use App\Follower;
-use App\Media;
 use App\Profile;
-use App\User;
+use App\Services\AccountService;
+use App\Services\RelationshipService;
 use App\UserFilter;
-use App\Util\Lexer\PrettyNumber;
-use App\Util\ActivityPub\Helpers;
-use Auth, Cache, DB;
+use Auth;
+use Cache;
+use DB;
 use Illuminate\Http\Request;
-use App\Models\UserDomainBlock;
 
 trait PrivacySettings
 {
-
     public function privacy()
     {
         $user = Auth::user();
         $settings = $user->settings;
         $profile = $user->profile;
         $is_private = $profile->is_private;
+        $cachedSettings = AccountService::getAccountSettings($profile->id);
         $settings['is_private'] = (bool) $is_private;
+        if ($cachedSettings && isset($cachedSettings['disable_embeds'])) {
+            $settings['disable_embeds'] = (bool) $cachedSettings['disable_embeds'];
+        } else {
+            $settings['disable_embeds'] = false;
+        }
 
         return view('settings.privacy', compact('settings', 'profile'));
     }
@@ -34,20 +35,31 @@ trait PrivacySettings
     {
         $settings = $request->user()->settings;
         $profile = $request->user()->profile;
+        $other = $settings->other;
         $fields = [
-          'is_private',
-          'crawlable',
-          'public_dm',
-          'show_profile_follower_count',
-          'show_profile_following_count',
-          'indexable',
-          'show_atom',
+            'is_private',
+            'crawlable',
+            'public_dm',
+            'show_profile_follower_count',
+            'show_profile_following_count',
+            'indexable',
+            'show_atom',
         ];
 
         $profile->indexable = $request->input('indexable') == 'on';
         $profile->is_suggestable = $request->input('is_suggestable') == 'on';
         $profile->save();
 
+        if ($request->has('disable_embeds')) {
+            $other['disable_embeds'] = true;
+            $settings->other = $other;
+            $settings->save();
+        } else {
+            $other['disable_embeds'] = false;
+            $settings->other = $other;
+            $settings->save();
+        }
+
         foreach ($fields as $field) {
             $form = $request->input($field);
             if ($field == 'is_private') {
@@ -67,7 +79,7 @@ trait PrivacySettings
                 } else {
                     $settings->{$field} = true;
                 }
-             } elseif ($field == 'public_dm') {
+            } elseif ($field == 'public_dm') {
                 if ($form == 'on') {
                     $settings->{$field} = true;
                 } else {
@@ -85,33 +97,36 @@ trait PrivacySettings
             $settings->save();
         }
         $pid = $profile->id;
-        Cache::forget('profile:settings:' . $pid);
-        Cache::forget('user:account:id:' . $profile->user_id);
-        Cache::forget('profile:follower_count:' . $pid);
-        Cache::forget('profile:following_count:' . $pid);
-        Cache::forget('profile:atom:enabled:' . $pid);
-        Cache::forget('profile:embed:' . $pid);
-        Cache::forget('pf:acct:settings:hidden-followers:' . $pid);
-        Cache::forget('pf:acct:settings:hidden-following:' . $pid);
-        Cache::forget('pf:acct-trans:hideFollowing:' . $pid);
-        Cache::forget('pf:acct-trans:hideFollowers:' . $pid);
-        Cache::forget('pfc:cached-user:wt:' . strtolower($profile->username));
-        Cache::forget('pfc:cached-user:wot:' . strtolower($profile->username));
+        Cache::forget('profile:settings:'.$pid);
+        Cache::forget('user:account:id:'.$profile->user_id);
+        Cache::forget('profile:follower_count:'.$pid);
+        Cache::forget('profile:following_count:'.$pid);
+        Cache::forget('profile:atom:enabled:'.$pid);
+        Cache::forget('profile:embed:'.$pid);
+        Cache::forget('pf:acct:settings:hidden-followers:'.$pid);
+        Cache::forget('pf:acct:settings:hidden-following:'.$pid);
+        Cache::forget('pf:acct-trans:hideFollowing:'.$pid);
+        Cache::forget('pf:acct-trans:hideFollowers:'.$pid);
+        Cache::forget('pfc:cached-user:wt:'.strtolower($profile->username));
+        Cache::forget('pfc:cached-user:wot:'.strtolower($profile->username));
+        AccountService::forgetAccountSettings($profile->id);
+
         return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
     }
 
     public function mutedUsers()
-    {   
+    {
         $pid = Auth::user()->profile->id;
         $ids = (new UserFilter())->mutedUserIds($pid);
         $users = Profile::whereIn('id', $ids)->simplePaginate(15);
+
         return view('settings.privacy.muted', compact('users'));
     }
 
     public function mutedUsersUpdate(Request $request)
-    {   
+    {
         $this->validate($request, [
-            'profile_id' => 'required|integer|min:1'
+            'profile_id' => 'required|integer|min:1',
         ]);
         $fid = $request->input('profile_id');
         $pid = Auth::user()->profile->id;
@@ -123,6 +138,8 @@ trait PrivacySettings
                 ->firstOrFail();
             $filter->delete();
         });
+        RelationshipService::refresh($pid, $fid);
+
         return redirect()->back();
     }
 
@@ -131,14 +148,14 @@ trait PrivacySettings
         $pid = Auth::user()->profile->id;
         $ids = (new UserFilter())->blockedUserIds($pid);
         $users = Profile::whereIn('id', $ids)->simplePaginate(15);
+
         return view('settings.privacy.blocked', compact('users'));
     }
 
-
     public function blockedUsersUpdate(Request $request)
-    {   
+    {
         $this->validate($request, [
-            'profile_id' => 'required|integer|min:1'
+            'profile_id' => 'required|integer|min:1',
         ]);
         $fid = $request->input('profile_id');
         $pid = Auth::user()->profile->id;
@@ -150,6 +167,8 @@ trait PrivacySettings
                 ->firstOrFail();
             $filter->delete();
         });
+        RelationshipService::refresh($pid, $fid);
+
         return redirect()->back();
     }
 
@@ -194,7 +213,7 @@ trait PrivacySettings
         $profile = Auth::user()->profile;
         $settings = Auth::user()->settings;
 
-        if($mode !== 'keep-all') {
+        if ($mode !== 'keep-all') {
             switch ($mode) {
                 case 'mutual-only':
                     $following = $profile->following()->pluck('profiles.id');
@@ -209,9 +228,9 @@ trait PrivacySettings
                 case 'remove-all':
                     Follower::whereFollowingId($profile->id)->delete();
                     break;
-                
+
                 default:
-                    # code...
+                    // code...
                     break;
             }
         }
@@ -221,6 +240,7 @@ trait PrivacySettings
         $settings->save();
         $profile->save();
         Cache::forget('profiles:private');
+
         return [200];
     }
 }

+ 312 - 305
app/Http/Controllers/SettingsController.php

@@ -2,270 +2,275 @@
 
 namespace App\Http\Controllers;
 
-use App\AccountLog;
-use App\Following;
+use App\Http\Controllers\Settings\ExportSettings;
+use App\Http\Controllers\Settings\HomeSettings;
+use App\Http\Controllers\Settings\LabsSettings;
+use App\Http\Controllers\Settings\PrivacySettings;
+use App\Http\Controllers\Settings\RelationshipSettings;
+use App\Http\Controllers\Settings\SecuritySettings;
+use App\Jobs\DeletePipeline\DeleteAccountPipeline;
+use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
 use App\ProfileSponsor;
-use App\Report;
-use App\UserFilter;
+use App\Services\AccountService;
 use App\UserSetting;
-use Auth, Cookie, DB, Cache, Purify;
-use Illuminate\Support\Facades\Redis;
+use Auth;
+use Cache;
 use Carbon\Carbon;
+use Cookie;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Str;
-use App\Http\Controllers\Settings\{
-	ExportSettings,
-	LabsSettings,
-	HomeSettings,
-	PrivacySettings,
-	RelationshipSettings,
-	SecuritySettings
-};
-use App\Jobs\DeletePipeline\DeleteAccountPipeline;
-use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
-use App\Services\AccountService;
 
 class SettingsController extends Controller
 {
-	use ExportSettings,
-	LabsSettings,
-	HomeSettings,
-	PrivacySettings,
-	RelationshipSettings,
-	SecuritySettings;
-
-	public function __construct()
-	{
-		$this->middleware('auth');
-	}
-
-	public function accessibility()
-	{
-		$settings = Auth::user()->settings;
-
-		return view('settings.accessibility', compact('settings'));
-	}
-
-	public function accessibilityStore(Request $request)
-	{
-		$settings = Auth::user()->settings;
-		$fields = [
-		  'compose_media_descriptions',
-		  'reduce_motion',
-		  'optimize_screen_reader',
-		  'high_contrast_mode',
-		  'video_autoplay',
-		];
-		foreach ($fields as $field) {
-			$form = $request->input($field);
-			if ($form == 'on') {
-				$settings->{$field} = true;
-			} else {
-				$settings->{$field} = false;
-			}
-			$settings->save();
-		}
-
-		return redirect(route('settings.accessibility'))->with('status', 'Settings successfully updated!');
-	}
-
-	public function notifications()
-	{
-		return view('settings.notifications');
-	}
-
-	public function applications()
-	{
-		return view('settings.applications');
-	}
-
-	public function dataImport()
-	{
-		return view('settings.import.home');
-	}
-
-	public function dataImportInstagram()
-	{
-		abort(404);
-	}
-
-	public function developers()
-	{
-		return view('settings.developers');
-	}
-
-	public function removeAccountTemporary(Request $request)
-	{
-		$user = Auth::user();
-		abort_if(!config('pixelfed.account_deletion'), 403);
-		abort_if($user->is_admin, 403);
-
-		return view('settings.remove.temporary');
-	}
-
-	public function removeAccountTemporarySubmit(Request $request)
-	{
-		$user = Auth::user();
-		abort_if(!config('pixelfed.account_deletion'), 403);
-		abort_if($user->is_admin, 403);
-		$profile = $user->profile;
-		$user->status = 'disabled';
-		$profile->status = 'disabled';
-		$user->save();
-		$profile->save();
-		Auth::logout();
-		Cache::forget('profiles:private');
-		return redirect('/');
-	}
-
-	public function removeAccountPermanent(Request $request)
-	{
-		$user = Auth::user();
-		abort_if($user->is_admin, 403);
-		return view('settings.remove.permanent');
-	}
-
-	public function removeAccountPermanentSubmit(Request $request)
-	{
-		if(config('pixelfed.account_deletion') == false) {
-			abort(404);
-		}
-		$user = Auth::user();
-		abort_if(!config('pixelfed.account_deletion'), 403);
-		abort_if($user->is_admin, 403);
-		$profile = $user->profile;
-		$ts = Carbon::now()->addMonth();
-		$user->email = $user->id;
-		$user->password = '';
-		$user->status = 'delete';
-		$profile->status = 'delete';
-		$user->delete_after = $ts;
-		$profile->delete_after = $ts;
-		$user->save();
-		$profile->save();
-		Cache::forget('profiles:private');
-		AccountService::del($profile->id);
-		Auth::logout();
-		DeleteAccountPipeline::dispatch($user)->onQueue('low');
-		return redirect('/');
-	}
-
-	public function requestFullExport(Request $request)
-	{
-		$user = Auth::user();
-		return view('settings.export.show');
-	}
-
-	public function metroDarkMode(Request $request)
-	{
-		$this->validate($request, [
-			'mode' => 'required|string|in:light,dark'
-		]);
-
-		$mode = $request->input('mode');
-
-		if($mode == 'dark') {
-			$cookie = Cookie::make('dark-mode', 'true', 43800);
-		} else {
-			$cookie = Cookie::forget('dark-mode');
-		}
-
-		return response()->json([200])->cookie($cookie);
-	}
-
-	public function sponsor()
-	{
-		$default = [
-			'patreon' => null,
-			'liberapay' => null,
-			'opencollective' => null
-		];
-		$sponsors = ProfileSponsor::whereProfileId(Auth::user()->profile->id)->first();
-		$sponsors = $sponsors ? json_decode($sponsors->sponsors, true) : $default;
-		return view('settings.sponsor', compact('sponsors'));
-	}
-
-	public function sponsorStore(Request $request)
-	{
-		$this->validate($request, [
-			'patreon' => 'nullable|string',
-			'liberapay' => 'nullable|string',
-			'opencollective' => 'nullable|string'
-		]);
-
-		$patreon = Str::startsWith($request->input('patreon'), 'https://') ?
-			substr($request->input('patreon'), 8) :
-			$request->input('patreon');
-
-		$liberapay = Str::startsWith($request->input('liberapay'), 'https://') ?
-			substr($request->input('liberapay'), 8) :
-			$request->input('liberapay');
-
-		$opencollective = Str::startsWith($request->input('opencollective'), 'https://') ?
-			substr($request->input('opencollective'), 8) :
-			$request->input('opencollective');
-
-		$patreon = Str::startsWith($patreon, 'patreon.com/') ? e($patreon) : null;
-		$liberapay = Str::startsWith($liberapay, 'liberapay.com/') ? e($liberapay) : null;
-		$opencollective = Str::startsWith($opencollective, 'opencollective.com/') ? e($opencollective) : null;
-
-		if(empty($patreon) && empty($liberapay) && empty($opencollective)) {
-			return redirect(route('settings'))->with('error', 'An error occured. Please try again later.');
-		}
-
-		$res = [
-			'patreon' => $patreon,
-			'liberapay' => $liberapay,
-			'opencollective' => $opencollective
-		];
-
-		$sponsors = ProfileSponsor::firstOrCreate([
-			'profile_id' => Auth::user()->profile_id ?? Auth::user()->profile->id
-		]);
-		$sponsors->sponsors = json_encode($res);
-		$sponsors->save();
-		$sponsors = $res;
-		return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');
-	}
-
-	public function timelineSettings(Request $request)
-	{
+    use ExportSettings,
+        HomeSettings,
+        LabsSettings,
+        PrivacySettings,
+        RelationshipSettings,
+        SecuritySettings;
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function accessibility()
+    {
+        $settings = Auth::user()->settings;
+
+        return view('settings.accessibility', compact('settings'));
+    }
+
+    public function accessibilityStore(Request $request)
+    {
+        $user = $request->user();
+        $settings = $user->settings;
+        $fields = [
+            'compose_media_descriptions',
+            'reduce_motion',
+            'optimize_screen_reader',
+            'high_contrast_mode',
+            'video_autoplay',
+        ];
+        foreach ($fields as $field) {
+            $form = $request->input($field);
+            if ($form == 'on') {
+                $settings->{$field} = true;
+            } else {
+                $settings->{$field} = false;
+            }
+            $settings->save();
+        }
+        AccountService::forgetAccountSettings($user->profile_id);
+
+        return redirect(route('settings.accessibility'))->with('status', 'Settings successfully updated!');
+    }
+
+    public function notifications()
+    {
+        return view('settings.notifications');
+    }
+
+    public function applications()
+    {
+        return view('settings.applications');
+    }
+
+    public function dataImport()
+    {
+        return view('settings.import.home');
+    }
+
+    public function dataImportInstagram()
+    {
+        abort(404);
+    }
+
+    public function developers()
+    {
+        return view('settings.developers');
+    }
+
+    public function removeAccountTemporary(Request $request)
+    {
+        $user = Auth::user();
+        abort_if(! config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
+
+        return view('settings.remove.temporary');
+    }
+
+    public function removeAccountTemporarySubmit(Request $request)
+    {
+        $user = Auth::user();
+        abort_if(! config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
+        $profile = $user->profile;
+        $user->status = 'disabled';
+        $profile->status = 'disabled';
+        $user->save();
+        $profile->save();
+        Auth::logout();
+        Cache::forget('profiles:private');
+
+        return redirect('/');
+    }
+
+    public function removeAccountPermanent(Request $request)
+    {
+        $user = Auth::user();
+        abort_if($user->is_admin, 403);
+
+        return view('settings.remove.permanent');
+    }
+
+    public function removeAccountPermanentSubmit(Request $request)
+    {
+        if (config('pixelfed.account_deletion') == false) {
+            abort(404);
+        }
+        $user = Auth::user();
+        abort_if(! config('pixelfed.account_deletion'), 403);
+        abort_if($user->is_admin, 403);
+        $profile = $user->profile;
+        $ts = Carbon::now()->addMonth();
+        $user->email = $user->id;
+        $user->password = '';
+        $user->status = 'delete';
+        $profile->status = 'delete';
+        $user->delete_after = $ts;
+        $profile->delete_after = $ts;
+        $user->save();
+        $profile->save();
+        Cache::forget('profiles:private');
+        AccountService::del($profile->id);
+        Auth::logout();
+        DeleteAccountPipeline::dispatch($user)->onQueue('low');
+
+        return redirect('/');
+    }
+
+    public function requestFullExport(Request $request)
+    {
+        $user = Auth::user();
+
+        return view('settings.export.show');
+    }
+
+    public function metroDarkMode(Request $request)
+    {
+        $this->validate($request, [
+            'mode' => 'required|string|in:light,dark',
+        ]);
+
+        $mode = $request->input('mode');
+
+        if ($mode == 'dark') {
+            $cookie = Cookie::make('dark-mode', 'true', 43800);
+        } else {
+            $cookie = Cookie::forget('dark-mode');
+        }
+
+        return response()->json([200])->cookie($cookie);
+    }
+
+    public function sponsor()
+    {
+        $default = [
+            'patreon' => null,
+            'liberapay' => null,
+            'opencollective' => null,
+        ];
+        $sponsors = ProfileSponsor::whereProfileId(Auth::user()->profile->id)->first();
+        $sponsors = $sponsors ? json_decode($sponsors->sponsors, true) : $default;
+
+        return view('settings.sponsor', compact('sponsors'));
+    }
+
+    public function sponsorStore(Request $request)
+    {
+        $this->validate($request, [
+            'patreon' => 'nullable|string',
+            'liberapay' => 'nullable|string',
+            'opencollective' => 'nullable|string',
+        ]);
+
+        $patreon = Str::startsWith($request->input('patreon'), 'https://') ?
+            substr($request->input('patreon'), 8) :
+            $request->input('patreon');
+
+        $liberapay = Str::startsWith($request->input('liberapay'), 'https://') ?
+            substr($request->input('liberapay'), 8) :
+            $request->input('liberapay');
+
+        $opencollective = Str::startsWith($request->input('opencollective'), 'https://') ?
+            substr($request->input('opencollective'), 8) :
+            $request->input('opencollective');
+
+        $patreon = Str::startsWith($patreon, 'patreon.com/') ? e($patreon) : null;
+        $liberapay = Str::startsWith($liberapay, 'liberapay.com/') ? e($liberapay) : null;
+        $opencollective = Str::startsWith($opencollective, 'opencollective.com/') ? e($opencollective) : null;
+
+        if (empty($patreon) && empty($liberapay) && empty($opencollective)) {
+            return redirect(route('settings'))->with('error', 'An error occured. Please try again later.');
+        }
+
+        $res = [
+            'patreon' => $patreon,
+            'liberapay' => $liberapay,
+            'opencollective' => $opencollective,
+        ];
+
+        $sponsors = ProfileSponsor::firstOrCreate([
+            'profile_id' => Auth::user()->profile_id ?? Auth::user()->profile->id,
+        ]);
+        $sponsors->sponsors = json_encode($res);
+        $sponsors->save();
+        $sponsors = $res;
+
+        return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');
+    }
+
+    public function timelineSettings(Request $request)
+    {
         $uid = $request->user()->id;
-		$pid = $request->user()->profile_id;
-		$top = Redis::zscore('pf:tl:top', $pid) != false;
-		$replies = Redis::zscore('pf:tl:replies', $pid) != false;
+        $pid = $request->user()->profile_id;
+        $top = Redis::zscore('pf:tl:top', $pid) != false;
+        $replies = Redis::zscore('pf:tl:replies', $pid) != false;
         $userSettings = UserSetting::firstOrCreate([
-            'user_id' => $uid
+            'user_id' => $uid,
         ]);
-        if(!$userSettings || !$userSettings->other) {
+        if (! $userSettings || ! $userSettings->other) {
             $userSettings = [
                 'enable_reblogs' => false,
-                'photo_reblogs_only' => false
+                'photo_reblogs_only' => false,
             ];
         } else {
             $userSettings = array_merge([
                 'enable_reblogs' => false,
-                'photo_reblogs_only' => false
+                'photo_reblogs_only' => false,
             ],
-            $userSettings->other);
+                $userSettings->other);
         }
-		return view('settings.timeline', compact('top', 'replies', 'userSettings'));
-	}
 
-	public function updateTimelineSettings(Request $request)
-	{
+        return view('settings.timeline', compact('top', 'replies', 'userSettings'));
+    }
+
+    public function updateTimelineSettings(Request $request)
+    {
         $pid = $request->user()->profile_id;
-		$uid = $request->user()->id;
+        $uid = $request->user()->id;
         $this->validate($request, [
             'enable_reblogs' => 'sometimes',
-            'photo_reblogs_only' => 'sometimes'
+            'photo_reblogs_only' => 'sometimes',
         ]);
-		Redis::zrem('pf:tl:top', $pid);
-		Redis::zrem('pf:tl:replies', $pid);
+        Redis::zrem('pf:tl:top', $pid);
+        Redis::zrem('pf:tl:replies', $pid);
         $userSettings = UserSetting::firstOrCreate([
-            'user_id' => $uid
+            'user_id' => $uid,
         ]);
-		if($userSettings->other) {
+        if ($userSettings->other) {
             $other = $userSettings->other;
             $other['enable_reblogs'] = $request->has('enable_reblogs');
             $other['photo_reblogs_only'] = $request->has('photo_reblogs_only');
@@ -275,72 +280,74 @@ class SettingsController extends Controller
         }
         $userSettings->other = $other;
         $userSettings->save();
-		return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!');
-	}
-
-	public function mediaSettings(Request $request)
-	{
-		$setting = UserSetting::whereUserId($request->user()->id)->firstOrFail();
-		$compose = $setting->compose_settings ? (
-			is_string($setting->compose_settings) ? json_decode($setting->compose_settings, true) : $setting->compose_settings
-			) : [
-			'default_license' => null,
-			'media_descriptions' => false
-		];
-		return view('settings.media', compact('compose'));
-	}
-
-	public function updateMediaSettings(Request $request)
-	{
-		$this->validate($request, [
-			'default' => 'required|int|min:1|max:16',
-			'sync' => 'nullable',
-			'media_descriptions' => 'nullable'
-		]);
-
-		$license = $request->input('default');
-		$sync = $request->input('sync') == 'on';
-		$media_descriptions = $request->input('media_descriptions') == 'on';
-		$uid = $request->user()->id;
-
-		$setting = UserSetting::whereUserId($uid)->firstOrFail();
-		$compose = is_string($setting->compose_settings) ? json_decode($setting->compose_settings, true) : $setting->compose_settings;
-		$changed = false;
-
-		if($sync) {
-			$key = 'pf:settings:mls_recently:'.$uid;
-			if(Cache::get($key) == 2) {
-				$msg = 'You can only sync licenses twice per 24 hours. Try again later.';
-				return redirect(route('settings'))
-					->with('error', $msg);
-			}
-		}
-
-		if(!isset($compose['default_license']) || $compose['default_license'] !== $license) {
-			$compose['default_license'] = (int) $license;
-			$changed = true;
-		}
-
-		if(!isset($compose['media_descriptions']) || $compose['media_descriptions'] !== $media_descriptions) {
-			$compose['media_descriptions'] = $media_descriptions;
-			$changed = true;
-		}
-
-		if($changed) {
-			$setting->compose_settings = $compose;
-			$setting->save();
-			Cache::forget('profile:compose:settings:' . $request->user()->id);
-		}
-
-		if($sync) {
-			$val = Cache::has($key) ? 2 : 1;
-			Cache::put($key, $val, 86400);
-			MediaSyncLicensePipeline::dispatch($uid, $license);
-			return redirect(route('settings'))->with('status', 'Media licenses successfully synced! It may take a few minutes to take effect for every post.');
-		}
-
-		return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
-	}
 
-}
+        return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!');
+    }
+
+    public function mediaSettings(Request $request)
+    {
+        $setting = UserSetting::whereUserId($request->user()->id)->firstOrFail();
+        $compose = $setting->compose_settings ? (
+            is_string($setting->compose_settings) ? json_decode($setting->compose_settings, true) : $setting->compose_settings
+        ) : [
+            'default_license' => null,
+            'media_descriptions' => false,
+        ];
+
+        return view('settings.media', compact('compose'));
+    }
+
+    public function updateMediaSettings(Request $request)
+    {
+        $this->validate($request, [
+            'default' => 'required|int|min:1|max:16',
+            'sync' => 'nullable',
+            'media_descriptions' => 'nullable',
+        ]);
+
+        $license = $request->input('default');
+        $sync = $request->input('sync') == 'on';
+        $media_descriptions = $request->input('media_descriptions') == 'on';
+        $uid = $request->user()->id;
 
+        $setting = UserSetting::whereUserId($uid)->firstOrFail();
+        $compose = is_string($setting->compose_settings) ? json_decode($setting->compose_settings, true) : $setting->compose_settings;
+        $changed = false;
+
+        if ($sync) {
+            $key = 'pf:settings:mls_recently:'.$uid;
+            if (Cache::get($key) == 2) {
+                $msg = 'You can only sync licenses twice per 24 hours. Try again later.';
+
+                return redirect(route('settings'))
+                    ->with('error', $msg);
+            }
+        }
+
+        if (! isset($compose['default_license']) || $compose['default_license'] !== $license) {
+            $compose['default_license'] = (int) $license;
+            $changed = true;
+        }
+
+        if (! isset($compose['media_descriptions']) || $compose['media_descriptions'] !== $media_descriptions) {
+            $compose['media_descriptions'] = $media_descriptions;
+            $changed = true;
+        }
+
+        if ($changed) {
+            $setting->compose_settings = $compose;
+            $setting->save();
+            Cache::forget('profile:compose:settings:'.$request->user()->id);
+        }
+
+        if ($sync) {
+            $val = Cache::has($key) ? 2 : 1;
+            Cache::put($key, $val, 86400);
+            MediaSyncLicensePipeline::dispatch($uid, $license);
+
+            return redirect(route('settings'))->with('status', 'Media licenses successfully synced! It may take a few minutes to take effect for every post.');
+        }
+
+        return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
+    }
+}

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

@@ -5,7 +5,7 @@ namespace App\Http\Controllers;
 use App\Page;
 use App\Profile;
 use App\Services\FollowerService;
-use App\Status;
+use App\Services\StatusService;
 use App\User;
 use App\Util\ActivityPub\Helpers;
 use App\Util\Localization\Localization;
@@ -60,7 +60,7 @@ class SiteController extends Controller
     {
         return Cache::remember('site.about_v2', now()->addMinutes(15), function () {
             $user_count = number_format(User::count());
-            $post_count = number_format(Status::count());
+            $post_count = number_format(StatusService::totalLocalStatuses());
             $rules = config_cache('app.rules') ? json_decode(config_cache('app.rules'), true) : null;
 
             return view('site.about', compact('rules', 'user_count', 'post_count'))->render();

+ 66 - 32
app/Http/Controllers/StatusController.php

@@ -8,6 +8,7 @@ use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\RemoteStatusDelete;
 use App\Jobs\StatusPipeline\StatusDelete;
 use App\Profile;
+use App\Services\AccountService;
 use App\Services\HashidService;
 use App\Services\ReblogService;
 use App\Services\StatusService;
@@ -34,8 +35,21 @@ class StatusController extends Controller
             }
         }
 
-        $user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
+        $status = StatusService::get($id, false);
+
+        abort_if(
+            ! $status ||
+            ! isset($status['account'], $status['account']['username']) ||
+            $status['account']['username'] != $username ||
+            isset($status['reblog']), 404);
+
+        abort_if(! in_array($status['visibility'], ['public', 'unlisted']) && ! $request->user(), 403, 'Invalid permission');
 
+        if ($request->wantsJson() && (bool) config_cache('federation.activitypub.enabled')) {
+            return $this->showActivityPub($request, $status);
+        }
+
+        $user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
         if ($user->status != null) {
             return ProfileController::accountCheck($user);
         }
@@ -70,18 +84,6 @@ class StatusController extends Controller
             }
         }
 
-        if ($request->user() && $request->user()->profile_id != $status->profile_id) {
-            StatusView::firstOrCreate([
-                'status_id' => $status->id,
-                'status_profile_id' => $status->profile_id,
-                'profile_id' => $request->user()->profile_id,
-            ]);
-        }
-
-        if ($request->wantsJson() && config_cache('federation.activitypub.enabled')) {
-            return $this->showActivityPub($request, $status);
-        }
-
         $template = $status->in_reply_to_id ? 'status.reply' : 'status.show';
 
         return view($template, compact('user', 'status'));
@@ -113,19 +115,41 @@ class StatusController extends Controller
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 
-        $profile = Profile::whereNull(['domain', 'status'])
-            ->whereIsPrivate(false)
-            ->whereUsername($username)
-            ->first();
+        $status = StatusService::get($id);
 
-        if (! $profile) {
+        if (
+            ! $status ||
+            ! isset($status['account'], $status['account']['id'], $status['local']) ||
+            ! $status['local'] ||
+            strtolower($status['account']['username']) !== strtolower($username)
+        ) {
+            $content = view('status.embed-removed');
+
+            return response($content, 404)->header('X-Frame-Options', 'ALLOWALL');
+        }
+
+        $profile = AccountService::get($status['account']['id'], true);
+
+        if (! $profile || $profile['locked'] || ! $profile['local']) {
+            $content = view('status.embed-removed');
+
+            return response($content)->header('X-Frame-Options', 'ALLOWALL');
+        }
+
+        $embedCheck = AccountService::canEmbed($profile['id']);
+
+        if (! $embedCheck) {
             $content = view('status.embed-removed');
 
             return response($content)->header('X-Frame-Options', 'ALLOWALL');
         }
 
-        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile->id, 86400, function () use ($profile) {
-            $exists = AccountInterstitial::whereUserId($profile->user_id)->where('is_spam', 1)->count();
+        $aiCheck = Cache::remember('profile:ai-check:spam-login:'.$profile['id'], 3600, function () use ($profile) {
+            $user = Profile::find($profile['id']);
+            if (! $user) {
+                return true;
+            }
+            $exists = AccountInterstitial::whereUserId($user->user_id)->where('is_spam', 1)->count();
             if ($exists) {
                 return true;
             }
@@ -138,17 +162,22 @@ class StatusController extends Controller
 
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
-        $status = Status::whereProfileId($profile->id)
-            ->whereNull('uri')
-            ->whereScope('public')
-            ->whereIsNsfw(false)
-            ->whereIn('type', ['photo', 'video', 'photo:album'])
-            ->find($id);
-        if (! $status) {
+
+        $status = StatusService::get($id);
+
+        if (
+            ! $status ||
+            ! isset($status['account'], $status['account']['id']) ||
+            intval($status['account']['id']) !== intval($profile['id']) ||
+            $status['sensitive'] ||
+            $status['visibility'] !== 'public' ||
+            ! in_array($status['pf_type'], ['photo', 'photo:album'])
+        ) {
             $content = view('status.embed-removed');
 
             return response($content)->header('X-Frame-Options', 'ALLOWALL');
         }
+
         $showLikes = $request->filled('likes') && $request->likes == true;
         $showCaption = $request->filled('caption') && $request->caption !== false;
         $layout = $request->filled('layout') && $request->layout == 'compact' ? 'compact' : 'full';
@@ -319,12 +348,17 @@ class StatusController extends Controller
 
     public function showActivityPub(Request $request, $status)
     {
-        $object = $status->type == 'poll' ? new Question() : new Note();
-        $fractal = new Fractal\Manager();
-        $resource = new Fractal\Resource\Item($status, $object);
-        $res = $fractal->createData($resource)->toArray();
+        $key = 'pf:status:ap:v1:sid:'.$status['id'];
 
-        return response()->json($res['data'], 200, ['Content-Type' => 'application/activity+json'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+        return Cache::remember($key, 3600, function () use ($status) {
+            $status = Status::findOrFail($status['id']);
+            $object = $status->type == 'poll' ? new Question() : new Note();
+            $fractal = new Fractal\Manager();
+            $resource = new Fractal\Resource\Item($status, $object);
+            $res = $fractal->createData($resource)->toArray();
+
+            return response()->json($res['data'], 200, ['Content-Type' => 'application/activity+json'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+        });
     }
 
     public function edit(Request $request, $username, $id)

+ 131 - 121
app/Http/Controllers/Stories/StoryApiV1Controller.php

@@ -2,54 +2,56 @@
 
 namespace App\Http\Controllers\Stories;
 
-use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Storage;
-use App\Models\Conversation;
 use App\DirectMessage;
-use App\Notification;
-use App\Story;
-use App\Status;
-use App\StoryView;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\StoryView as StoryViewResource;
 use App\Jobs\StoryPipeline\StoryDelete;
 use App\Jobs\StoryPipeline\StoryFanout;
 use App\Jobs\StoryPipeline\StoryReplyDeliver;
 use App\Jobs\StoryPipeline\StoryViewDeliver;
+use App\Models\Conversation;
+use App\Notification;
 use App\Services\AccountService;
 use App\Services\MediaPathService;
 use App\Services\StoryService;
-use App\Http\Resources\StoryView as StoryViewResource;
+use App\Status;
+use App\Story;
+use App\StoryView;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 
 class StoryApiV1Controller extends Controller
 {
     const RECENT_KEY = 'pf:stories:recent-by-id:';
+
     const RECENT_TTL = 300;
 
     public function carousel(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $pid = $request->user()->profile_id;
 
-        if(config('database.default') == 'pgsql') {
-            $s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
+        if (config('database.default') == 'pgsql') {
+            $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
                 return Story::select('stories.*', 'followers.following_id')
                     ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
                     ->where('followers.profile_id', $pid)
                     ->where('stories.active', true)
-                    ->map(function($s) {
-                        $r  = new \StdClass;
+                    ->map(function ($s) {
+                        $r = new \StdClass;
                         $r->id = $s->id;
                         $r->profile_id = $s->profile_id;
                         $r->type = $s->type;
                         $r->path = $s->path;
+
                         return $r;
                     })
                     ->unique('profile_id');
             });
         } else {
-            $s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
+            $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
                 return Story::select('stories.*', 'followers.following_id')
                     ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
                     ->where('followers.profile_id', $pid)
@@ -59,9 +61,9 @@ class StoryApiV1Controller extends Controller
             });
         }
 
-        $nodes = $s->map(function($s) use($pid) {
+        $nodes = $s->map(function ($s) use ($pid) {
             $profile = AccountService::get($s->profile_id, true);
-            if(!$profile || !isset($profile['id'])) {
+            if (! $profile || ! isset($profile['id'])) {
                 return false;
             }
 
@@ -72,50 +74,51 @@ class StoryApiV1Controller extends Controller
                 'src' => url(Storage::url($s->path)),
                 'duration' => $s->duration ?? 3,
                 'seen' => StoryService::hasSeen($pid, $s->id),
-                'created_at' => $s->created_at->format('c')
+                'created_at' => $s->created_at->format('c'),
             ];
         })
-        ->filter()
-        ->groupBy('pid')
-        ->map(function($item) use($pid) {
-            $profile = AccountService::get($item[0]['pid'], true);
-            $url = $profile['local'] ? url("/stories/{$profile['username']}") :
-                url("/i/rs/{$profile['id']}");
-            return [
-                'id' => 'pfs:' . $profile['id'],
-                'user' => [
-                    'id' => (string) $profile['id'],
-                    'username' => $profile['username'],
-                    'username_acct' => $profile['acct'],
-                    'avatar' => $profile['avatar'],
-                    'local' => $profile['local'],
-                    'is_author' => $profile['id'] == $pid
-                ],
-                'nodes' => $item,
-                'url' => $url,
-                'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
-            ];
-        })
-        ->sortBy('seen')
-        ->values();
+            ->filter()
+            ->groupBy('pid')
+            ->map(function ($item) use ($pid) {
+                $profile = AccountService::get($item[0]['pid'], true);
+                $url = $profile['local'] ? url("/stories/{$profile['username']}") :
+                    url("/i/rs/{$profile['id']}");
+
+                return [
+                    'id' => 'pfs:'.$profile['id'],
+                    'user' => [
+                        'id' => (string) $profile['id'],
+                        'username' => $profile['username'],
+                        'username_acct' => $profile['acct'],
+                        'avatar' => $profile['avatar'],
+                        'local' => $profile['local'],
+                        'is_author' => $profile['id'] == $pid,
+                    ],
+                    'nodes' => $item,
+                    'url' => $url,
+                    'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
+                ];
+            })
+            ->sortBy('seen')
+            ->values();
 
         $res = [
             'self' => [],
             'nodes' => $nodes,
         ];
 
-        if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
+        if (Story::whereProfileId($pid)->whereActive(true)->exists()) {
             $selfStories = Story::whereProfileId($pid)
                 ->whereActive(true)
                 ->get()
-                ->map(function($s) use($pid) {
+                ->map(function ($s) {
                     return [
                         'id' => (string) $s->id,
                         'type' => $s->type,
                         'src' => url(Storage::url($s->path)),
                         'duration' => $s->duration,
                         'seen' => true,
-                        'created_at' => $s->created_at->format('c')
+                        'created_at' => $s->created_at->format('c'),
                     ];
                 })
                 ->sortBy('id')
@@ -127,38 +130,40 @@ class StoryApiV1Controller extends Controller
                     'username' => $selfProfile['acct'],
                     'avatar' => $selfProfile['avatar'],
                     'local' => $selfProfile['local'],
-                    'is_author' => true
+                    'is_author' => true,
                 ],
 
                 'nodes' => $selfStories,
             ];
         }
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     }
 
     public function selfCarousel(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $pid = $request->user()->profile_id;
 
-        if(config('database.default') == 'pgsql') {
-            $s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
+        if (config('database.default') == 'pgsql') {
+            $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
                 return Story::select('stories.*', 'followers.following_id')
                     ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
                     ->where('followers.profile_id', $pid)
                     ->where('stories.active', true)
-                    ->map(function($s) {
-                        $r  = new \StdClass;
+                    ->map(function ($s) {
+                        $r = new \StdClass;
                         $r->id = $s->id;
                         $r->profile_id = $s->profile_id;
                         $r->type = $s->type;
                         $r->path = $s->path;
+
                         return $r;
                     })
                     ->unique('profile_id');
             });
         } else {
-            $s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
+            $s = Cache::remember(self::RECENT_KEY.$pid, self::RECENT_TTL, function () use ($pid) {
                 return Story::select('stories.*', 'followers.following_id')
                     ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
                     ->where('followers.profile_id', $pid)
@@ -168,9 +173,9 @@ class StoryApiV1Controller extends Controller
             });
         }
 
-        $nodes = $s->map(function($s) use($pid) {
+        $nodes = $s->map(function ($s) use ($pid) {
             $profile = AccountService::get($s->profile_id, true);
-            if(!$profile || !isset($profile['id'])) {
+            if (! $profile || ! isset($profile['id'])) {
                 return false;
             }
 
@@ -181,32 +186,33 @@ class StoryApiV1Controller extends Controller
                 'src' => url(Storage::url($s->path)),
                 'duration' => $s->duration ?? 3,
                 'seen' => StoryService::hasSeen($pid, $s->id),
-                'created_at' => $s->created_at->format('c')
+                'created_at' => $s->created_at->format('c'),
             ];
         })
-        ->filter()
-        ->groupBy('pid')
-        ->map(function($item) use($pid) {
-            $profile = AccountService::get($item[0]['pid'], true);
-            $url = $profile['local'] ? url("/stories/{$profile['username']}") :
-                url("/i/rs/{$profile['id']}");
-            return [
-                'id' => 'pfs:' . $profile['id'],
-                'user' => [
-                    'id' => (string) $profile['id'],
-                    'username' => $profile['username'],
-                    'username_acct' => $profile['acct'],
-                    'avatar' => $profile['avatar'],
-                    'local' => $profile['local'],
-                    'is_author' => $profile['id'] == $pid
-                ],
-                'nodes' => $item,
-                'url' => $url,
-                'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
-            ];
-        })
-        ->sortBy('seen')
-        ->values();
+            ->filter()
+            ->groupBy('pid')
+            ->map(function ($item) use ($pid) {
+                $profile = AccountService::get($item[0]['pid'], true);
+                $url = $profile['local'] ? url("/stories/{$profile['username']}") :
+                    url("/i/rs/{$profile['id']}");
+
+                return [
+                    'id' => 'pfs:'.$profile['id'],
+                    'user' => [
+                        'id' => (string) $profile['id'],
+                        'username' => $profile['username'],
+                        'username_acct' => $profile['acct'],
+                        'avatar' => $profile['avatar'],
+                        'local' => $profile['local'],
+                        'is_author' => $profile['id'] == $pid,
+                    ],
+                    'nodes' => $item,
+                    'url' => $url,
+                    'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
+                ];
+            })
+            ->sortBy('seen')
+            ->values();
 
         $selfProfile = AccountService::get($pid, true);
         $res = [
@@ -216,7 +222,7 @@ class StoryApiV1Controller extends Controller
                     'username' => $selfProfile['acct'],
                     'avatar' => $selfProfile['avatar'],
                     'local' => $selfProfile['local'],
-                    'is_author' => true
+                    'is_author' => true,
                 ],
 
                 'nodes' => [],
@@ -224,40 +230,41 @@ class StoryApiV1Controller extends Controller
             'nodes' => $nodes,
         ];
 
-        if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
+        if (Story::whereProfileId($pid)->whereActive(true)->exists()) {
             $selfStories = Story::whereProfileId($pid)
                 ->whereActive(true)
                 ->get()
-                ->map(function($s) use($pid) {
+                ->map(function ($s) {
                     return [
                         'id' => (string) $s->id,
                         'type' => $s->type,
                         'src' => url(Storage::url($s->path)),
                         'duration' => $s->duration,
                         'seen' => true,
-                        'created_at' => $s->created_at->format('c')
+                        'created_at' => $s->created_at->format('c'),
                     ];
                 })
                 ->sortBy('id')
                 ->values();
             $res['self']['nodes'] = $selfStories;
         }
-        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     }
 
     public function add(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
-            'file' => function() {
+            'file' => function () {
                 return [
                     'required',
                     'mimetypes:image/jpeg,image/png,video/mp4',
-                    'max:' . config_cache('pixelfed.max_photo_size'),
+                    'max:'.config_cache('pixelfed.max_photo_size'),
                 ];
             },
-            'duration' => 'sometimes|integer|min:0|max:30'
+            'duration' => 'sometimes|integer|min:0|max:30',
         ]);
 
         $user = $request->user();
@@ -267,7 +274,7 @@ class StoryApiV1Controller extends Controller
             ->where('expires_at', '>', now())
             ->count();
 
-        if($count >= Story::MAX_PER_DAY) {
+        if ($count >= Story::MAX_PER_DAY) {
             abort(418, 'You have reached your limit for new Stories today.');
         }
 
@@ -277,7 +284,7 @@ class StoryApiV1Controller extends Controller
         $story = new Story();
         $story->duration = $request->input('duration', 3);
         $story->profile_id = $user->profile_id;
-        $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
+        $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' : 'photo';
         $story->mime = $photo->getMimeType();
         $story->path = $path;
         $story->local = true;
@@ -290,10 +297,10 @@ class StoryApiV1Controller extends Controller
 
         $res = [
             'code' => 200,
-            'msg'  => 'Successfully added',
+            'msg' => 'Successfully added',
             'media_id' => (string) $story->id,
-            'media_url' => url(Storage::url($url)) . '?v=' . time(),
-            'media_type' => $story->type
+            'media_url' => url(Storage::url($url)).'?v='.time(),
+            'media_type' => $story->type,
         ];
 
         return $res;
@@ -301,13 +308,13 @@ class StoryApiV1Controller extends Controller
 
     public function publish(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
             'media_id' => 'required',
             'duration' => 'required|integer|min:0|max:30',
             'can_reply' => 'required|boolean',
-            'can_react' => 'required|boolean'
+            'can_react' => 'required|boolean',
         ]);
 
         $id = $request->input('media_id');
@@ -327,13 +334,13 @@ class StoryApiV1Controller extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully published',
+            'msg' => 'Successfully published',
         ];
     }
 
     public function delete(Request $request, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $user = $request->user();
 
@@ -346,16 +353,16 @@ class StoryApiV1Controller extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully deleted'
+            'msg' => 'Successfully deleted',
         ];
     }
 
     public function viewed(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
-            'id'    => 'required|min:1',
+            'id' => 'required|min:1',
         ]);
         $id = $request->input('id');
 
@@ -367,44 +374,45 @@ class StoryApiV1Controller extends Controller
 
         $profile = $story->profile;
 
-        if($story->profile_id == $authed->id) {
+        if ($story->profile_id == $authed->id) {
             return [];
         }
 
         $publicOnly = (bool) $profile->followedBy($authed);
-        abort_if(!$publicOnly, 403);
+        abort_if(! $publicOnly, 403);
 
         $v = StoryView::firstOrCreate([
             'story_id' => $id,
-            'profile_id' => $authed->id
+            'profile_id' => $authed->id,
         ]);
 
-        if($v->wasRecentlyCreated) {
+        if ($v->wasRecentlyCreated) {
             Story::findOrFail($story->id)->increment('view_count');
 
-            if($story->local == false) {
+            if ($story->local == false) {
                 StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
             }
         }
 
-        Cache::forget('stories:recent:by_id:' . $authed->id);
+        Cache::forget('stories:recent:by_id:'.$authed->id);
         StoryService::addSeen($authed->id, $story->id);
+
         return ['code' => 200];
     }
 
     public function comment(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $this->validate($request, [
             'sid' => 'required',
-            'caption' => 'required|string'
+            'caption' => 'required|string',
         ]);
         $pid = $request->user()->profile_id;
         $text = $request->input('caption');
 
         $story = Story::findOrFail($request->input('sid'));
 
-        abort_if(!$story->can_reply, 422);
+        abort_if(! $story->can_reply, 422);
 
         $status = new Status;
         $status->type = 'story:reply';
@@ -415,7 +423,7 @@ class StoryApiV1Controller extends Controller
         $status->visibility = 'direct';
         $status->in_reply_to_profile_id = $story->profile_id;
         $status->entities = json_encode([
-            'story_id' => $story->id
+            'story_id' => $story->id,
         ]);
         $status->save();
 
@@ -429,24 +437,24 @@ class StoryApiV1Controller extends Controller
             'story_actor_username' => $request->user()->username,
             'story_id' => $story->id,
             'story_media_url' => url(Storage::url($story->path)),
-            'caption' => $text
+            'caption' => $text,
         ]);
         $dm->save();
 
         Conversation::updateOrInsert(
             [
                 'to_id' => $story->profile_id,
-                'from_id' => $pid
+                'from_id' => $pid,
             ],
             [
                 'type' => 'story:comment',
                 'status_id' => $status->id,
                 'dm_id' => $dm->id,
-                'is_hidden' => false
+                'is_hidden' => false,
             ]
         );
 
-        if($story->local) {
+        if ($story->local) {
             $n = new Notification;
             $n->profile_id = $dm->to_id;
             $n->actor_id = $dm->from_id;
@@ -460,33 +468,35 @@ class StoryApiV1Controller extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Sent!'
+            'msg' => 'Sent!',
         ];
     }
 
     protected function storeMedia($photo, $user)
     {
         $mimes = explode(',', config_cache('pixelfed.media_types'));
-        if(in_array($photo->getMimeType(), [
+        if (in_array($photo->getMimeType(), [
             'image/jpeg',
             'image/png',
-            'video/mp4'
+            'video/mp4',
         ]) == false) {
             abort(400, 'Invalid media type');
+
             return;
         }
 
         $storagePath = MediaPathService::story($user->profile);
-        $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
+        $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)).'_'.Str::random(random_int(32, 35)).'_'.Str::random(random_int(1, 14)).'.'.$photo->extension());
+
         return $path;
     }
 
     public function viewers(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
-            'sid' => 'required|string|min:1|max:50'
+            'sid' => 'required|string|min:1|max:50',
         ]);
 
         $pid = $request->user()->profile_id;

+ 89 - 93
app/Http/Controllers/StoryComposeController.php

@@ -2,59 +2,52 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use App\Media;
-use App\Profile;
-use App\Report;
 use App\DirectMessage;
-use App\Notification;
-use App\Status;
-use App\Story;
-use App\StoryView;
+use App\Jobs\StoryPipeline\StoryDelete;
+use App\Jobs\StoryPipeline\StoryFanout;
+use App\Jobs\StoryPipeline\StoryReactionDeliver;
+use App\Jobs\StoryPipeline\StoryReplyDeliver;
+use App\Models\Conversation;
 use App\Models\Poll;
 use App\Models\PollVote;
-use App\Services\ProfileService;
-use App\Services\StoryService;
-use Cache, Storage;
-use Image as Intervention;
+use App\Notification;
+use App\Report;
 use App\Services\FollowerService;
 use App\Services\MediaPathService;
-use FFMpeg;
-use FFMpeg\Coordinate\Dimension;
-use FFMpeg\Format\Video\X264;
-use App\Jobs\StoryPipeline\StoryReactionDeliver;
-use App\Jobs\StoryPipeline\StoryReplyDeliver;
-use App\Jobs\StoryPipeline\StoryFanout;
-use App\Jobs\StoryPipeline\StoryDelete;
-use ImageOptimizer;
-use App\Models\Conversation;
+use App\Services\StoryService;
 use App\Services\UserRoleService;
+use App\Status;
+use App\Story;
+use FFMpeg;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Image as Intervention;
+use Storage;
 
 class StoryComposeController extends Controller
 {
     public function apiV1Add(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
-            'file' => function() {
+            'file' => function () {
                 return [
                     'required',
                     'mimetypes:image/jpeg,image/png,video/mp4',
-                    'max:' . config_cache('pixelfed.max_photo_size'),
+                    'max:'.config_cache('pixelfed.max_photo_size'),
                 ];
             },
         ]);
 
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
         $count = Story::whereProfileId($user->profile_id)
             ->whereActive(true)
             ->where('expires_at', '>', now())
             ->count();
 
-        if($count >= Story::MAX_PER_DAY) {
+        if ($count >= Story::MAX_PER_DAY) {
             abort(418, 'You have reached your limit for new Stories today.');
         }
 
@@ -64,7 +57,7 @@ class StoryComposeController extends Controller
         $story = new Story();
         $story->duration = 3;
         $story->profile_id = $user->profile_id;
-        $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
+        $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' : 'photo';
         $story->mime = $photo->getMimeType();
         $story->path = $path;
         $story->local = true;
@@ -77,21 +70,22 @@ class StoryComposeController extends Controller
 
         $res = [
             'code' => 200,
-            'msg'  => 'Successfully added',
+            'msg' => 'Successfully added',
             'media_id' => (string) $story->id,
-            'media_url' => url(Storage::url($url)) . '?v=' . time(),
-            'media_type' => $story->type
+            'media_url' => url(Storage::url($url)).'?v='.time(),
+            'media_type' => $story->type,
         ];
 
-        if($story->type === 'video') {
+        if ($story->type === 'video') {
             $video = FFMpeg::open($path);
             $duration = $video->getDurationInSeconds();
             $res['media_duration'] = $duration;
-            if($duration > 500) {
+            if ($duration > 500) {
                 Storage::delete($story->path);
                 $story->delete();
+
                 return response()->json([
-                    'message' => 'Video duration cannot exceed 60 seconds'
+                    'message' => 'Video duration cannot exceed 60 seconds',
                 ], 422);
             }
         }
@@ -102,37 +96,39 @@ class StoryComposeController extends Controller
     protected function storePhoto($photo, $user)
     {
         $mimes = explode(',', config_cache('pixelfed.media_types'));
-        if(in_array($photo->getMimeType(), [
+        if (in_array($photo->getMimeType(), [
             'image/jpeg',
             'image/png',
-            'video/mp4'
+            'video/mp4',
         ]) == false) {
             abort(400, 'Invalid media type');
+
             return;
         }
 
         $storagePath = MediaPathService::story($user->profile);
-        $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
-        if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
-            $fpath = storage_path('app/' . $path);
+        $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)).'_'.Str::random(random_int(32, 35)).'_'.Str::random(random_int(1, 14)).'.'.$photo->extension());
+        if (in_array($photo->getMimeType(), ['image/jpeg', 'image/png'])) {
+            $fpath = storage_path('app/'.$path);
             $img = Intervention::make($fpath);
             $img->orientate();
             $img->save($fpath, config_cache('pixelfed.image_quality'));
             $img->destroy();
         }
+
         return $path;
     }
 
     public function cropPhoto(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
             'media_id' => 'required|integer|min:1',
             'width' => 'required',
             'height' => 'required',
             'x' => 'required',
-            'y' => 'required'
+            'y' => 'required',
         ]);
 
         $user = $request->user();
@@ -144,13 +140,13 @@ class StoryComposeController extends Controller
 
         $story = Story::whereProfileId($user->profile_id)->findOrFail($id);
 
-        $path = storage_path('app/' . $story->path);
+        $path = storage_path('app/'.$story->path);
 
-        if(!is_file($path)) {
+        if (! is_file($path)) {
             abort(400, 'Invalid or missing media.');
         }
 
-        if($story->type === 'photo') {
+        if ($story->type === 'photo') {
             $img = Intervention::make($path);
             $img->crop($width, $height, $x, $y);
             $img->resize(1080, 1920, function ($constraint) {
@@ -161,24 +157,24 @@ class StoryComposeController extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully cropped',
+            'msg' => 'Successfully cropped',
         ];
     }
 
     public function publishStory(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
             'media_id' => 'required',
             'duration' => 'required|integer|min:3|max:120',
             'can_reply' => 'required|boolean',
-            'can_react' => 'required|boolean'
+            'can_react' => 'required|boolean',
         ]);
 
         $id = $request->input('media_id');
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
         $story = Story::whereProfileId($user->profile_id)
             ->findOrFail($id);
 
@@ -194,13 +190,13 @@ class StoryComposeController extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully published',
+            'msg' => 'Successfully published',
         ];
     }
 
     public function apiV1Delete(Request $request, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $user = $request->user();
 
@@ -213,40 +209,40 @@ class StoryComposeController extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully deleted'
+            'msg' => 'Successfully deleted',
         ];
     }
 
     public function compose(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
 
         return view('stories.compose');
     }
 
     public function createPoll(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-        abort_if(!config_cache('instance.polls.enabled'), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
+        abort_if(! config_cache('instance.polls.enabled'), 404);
 
         return $request->all();
     }
 
     public function publishStoryPoll(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
             'question' => 'required|string|min:6|max:140',
             'options' => 'required|array|min:2|max:4',
             'can_reply' => 'required|boolean',
-            'can_react' => 'required|boolean'
+            'can_react' => 'required|boolean',
         ]);
 
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
         $pid = $request->user()->profile_id;
 
         $count = Story::whereProfileId($pid)
@@ -254,7 +250,7 @@ class StoryComposeController extends Controller
             ->where('expires_at', '>', now())
             ->count();
 
-        if($count >= Story::MAX_PER_DAY) {
+        if ($count >= Story::MAX_PER_DAY) {
             abort(418, 'You have reached your limit for new Stories today.');
         }
 
@@ -262,7 +258,7 @@ class StoryComposeController extends Controller
         $story->type = 'poll';
         $story->story = json_encode([
             'question' => $request->input('question'),
-            'options' => $request->input('options')
+            'options' => $request->input('options'),
         ]);
         $story->public = false;
         $story->local = true;
@@ -278,7 +274,7 @@ class StoryComposeController extends Controller
         $poll->profile_id = $pid;
         $poll->poll_options = $request->input('options');
         $poll->expires_at = $story->expires_at;
-        $poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+        $poll->cached_tallies = collect($poll->poll_options)->map(function ($o) {
             return 0;
         })->toArray();
         $poll->save();
@@ -290,23 +286,23 @@ class StoryComposeController extends Controller
 
         return [
             'code' => 200,
-            'msg'  => 'Successfully published',
+            'msg' => 'Successfully published',
         ];
     }
 
     public function storyPollVote(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
             'sid' => 'required',
-            'ci' => 'required|integer|min:0|max:3'
+            'ci' => 'required|integer|min:0|max:3',
         ]);
 
         $pid = $request->user()->profile_id;
         $ci = $request->input('ci');
         $story = Story::findOrFail($request->input('sid'));
-        abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
+        abort_if(! FollowerService::follows($pid, $story->profile_id), 403);
         $poll = Poll::whereStoryId($story->id)->firstOrFail();
 
         $vote = new PollVote;
@@ -318,7 +314,7 @@ class StoryComposeController extends Controller
         $vote->save();
 
         $poll->votes_count = $poll->votes_count + 1;
-        $poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
+        $poll->cached_tallies = collect($poll->getTallies())->map(function ($tally, $key) use ($ci) {
             return $ci == $key ? $tally + 1 : $tally;
         })->toArray();
         $poll->save();
@@ -328,15 +324,15 @@ class StoryComposeController extends Controller
 
     public function storeReport(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
 
         $this->validate($request, [
-            'type'  => 'required|alpha_dash',
-            'id'    => 'required|integer|min:1',
+            'type' => 'required|alpha_dash',
+            'id' => 'required|integer|min:1',
         ]);
 
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
 
         $pid = $request->user()->profile_id;
         $sid = $request->input('id');
@@ -353,24 +349,24 @@ class StoryComposeController extends Controller
             'copyright',
             'impersonation',
             'scam',
-            'terrorism'
+            'terrorism',
         ];
 
-        abort_if(!in_array($type, $types), 422, 'Invalid story report type');
+        abort_if(! in_array($type, $types), 422, 'Invalid story report type');
 
         $story = Story::findOrFail($sid);
 
         abort_if($story->profile_id == $pid, 422, 'Cannot report your own story');
-        abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
+        abort_if(! FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
 
-        if( Report::whereProfileId($pid)
+        if (Report::whereProfileId($pid)
             ->whereObjectType('App\Story')
             ->whereObjectId($story->id)
             ->exists()
         ) {
             return response()->json(['error' => [
                 'code' => 409,
-                'message' => 'Cannot report the same story again'
+                'message' => 'Cannot report the same story again',
             ]], 409);
         }
 
@@ -389,18 +385,18 @@ class StoryComposeController extends Controller
 
     public function react(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $this->validate($request, [
             'sid' => 'required',
-            'reaction' => 'required|string'
+            'reaction' => 'required|string',
         ]);
         $pid = $request->user()->profile_id;
         $text = $request->input('reaction');
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
         $story = Story::findOrFail($request->input('sid'));
 
-        abort_if(!$story->can_react, 422);
+        abort_if(! $story->can_react, 422);
         abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
 
         $status = new Status;
@@ -413,7 +409,7 @@ class StoryComposeController extends Controller
         $status->in_reply_to_profile_id = $story->profile_id;
         $status->entities = json_encode([
             'story_id' => $story->id,
-            'reaction' => $text
+            'reaction' => $text,
         ]);
         $status->save();
 
@@ -427,24 +423,24 @@ class StoryComposeController extends Controller
             'story_actor_username' => $request->user()->username,
             'story_id' => $story->id,
             'story_media_url' => url(Storage::url($story->path)),
-            'reaction' => $text
+            'reaction' => $text,
         ]);
         $dm->save();
 
         Conversation::updateOrInsert(
             [
                 'to_id' => $story->profile_id,
-                'from_id' => $pid
+                'from_id' => $pid,
             ],
             [
                 'type' => 'story:react',
                 'status_id' => $status->id,
                 'dm_id' => $dm->id,
-                'is_hidden' => false
+                'is_hidden' => false,
             ]
         );
 
-        if($story->local) {
+        if ($story->local) {
             // generate notification
             $n = new Notification;
             $n->profile_id = $dm->to_id;
@@ -464,18 +460,18 @@ class StoryComposeController extends Controller
 
     public function comment(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(! (bool) config_cache('instance.stories.enabled') || ! $request->user(), 404);
         $this->validate($request, [
             'sid' => 'required',
-            'caption' => 'required|string'
+            'caption' => 'required|string',
         ]);
         $pid = $request->user()->profile_id;
         $text = $request->input('caption');
         $user = $request->user();
-        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        abort_if($user->has_roles && ! UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
         $story = Story::findOrFail($request->input('sid'));
 
-        abort_if(!$story->can_reply, 422);
+        abort_if(! $story->can_reply, 422);
 
         $status = new Status;
         $status->type = 'story:reply';
@@ -486,7 +482,7 @@ class StoryComposeController extends Controller
         $status->visibility = 'direct';
         $status->in_reply_to_profile_id = $story->profile_id;
         $status->entities = json_encode([
-            'story_id' => $story->id
+            'story_id' => $story->id,
         ]);
         $status->save();
 
@@ -500,24 +496,24 @@ class StoryComposeController extends Controller
             'story_actor_username' => $request->user()->username,
             'story_id' => $story->id,
             'story_media_url' => url(Storage::url($story->path)),
-            'caption' => $text
+            'caption' => $text,
         ]);
         $dm->save();
 
         Conversation::updateOrInsert(
             [
                 'to_id' => $story->profile_id,
-                'from_id' => $pid
+                'from_id' => $pid,
             ],
             [
                 'type' => 'story:comment',
                 'status_id' => $status->id,
                 'dm_id' => $dm->id,
-                'is_hidden' => false
+                'is_hidden' => false,
             ]
         );
 
-        if($story->local) {
+        if ($story->local) {
             // generate notification
             $n = new Notification;
             $n->profile_id = $dm->to_id;

+ 9 - 9
app/Http/Controllers/StoryController.php

@@ -34,7 +34,7 @@ class StoryController extends StoryComposeController
 {
     public function recent(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
         $user = $request->user();
         if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
             return [];
@@ -117,7 +117,7 @@ class StoryController extends StoryComposeController
 
     public function profile(Request $request, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $user = $request->user();
         if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
@@ -176,7 +176,7 @@ class StoryController extends StoryComposeController
 
     public function viewed(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $this->validate($request, [
             'id'    => 'required|min:1',
@@ -221,7 +221,7 @@ class StoryController extends StoryComposeController
 
     public function exists(Request $request, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
         $user = $request->user();
         if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
             return response()->json(false);
@@ -233,7 +233,7 @@ class StoryController extends StoryComposeController
 
     public function iRedirect(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $user = $request->user();
         abort_if(!$user, 404);
@@ -243,7 +243,7 @@ class StoryController extends StoryComposeController
 
     public function viewers(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $this->validate($request, [
             'sid' => 'required|string'
@@ -274,7 +274,7 @@ class StoryController extends StoryComposeController
 
     public function remoteStory(Request $request, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $profile = Profile::findOrFail($id);
         if($profile->user_id != null || $profile->domain == null) {
@@ -286,7 +286,7 @@ class StoryController extends StoryComposeController
 
     public function pollResults(Request $request)
     {
-        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled') || !$request->user(), 404);
 
         $this->validate($request, [
             'sid' => 'required|string'
@@ -304,7 +304,7 @@ class StoryController extends StoryComposeController
 
     public function getActivityObject(Request $request, $username, $id)
     {
-        abort_if(!config_cache('instance.stories.enabled'), 404);
+        abort_if(!(bool) config_cache('instance.stories.enabled'), 404);
 
         if(!$request->wantsJson()) {
             return redirect('/stories/' . $username);

+ 1 - 1
app/Http/Controllers/UserEmailForgotController.php

@@ -34,7 +34,7 @@ class UserEmailForgotController extends Controller
             'username.exists' => 'This username is no longer active or does not exist!'
         ];
 
-        if(config('captcha.enabled') || config('captcha.active.login') || config('captcha.active.register')) {
+        if((bool) config_cache('captcha.enabled')) {
             $rules['h-captcha-response'] = 'required|captcha';
             $messages['h-captcha-response.required'] = 'You need to complete the captcha!';
         }

+ 2 - 2
app/Http/Kernel.php

@@ -14,12 +14,12 @@ class Kernel extends HttpKernel
      * @var array
      */
     protected $middleware = [
+        \Illuminate\Http\Middleware\HandleCors::class,
         \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
         \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
+        \App\Http\Middleware\TrustProxies::class,
         \App\Http\Middleware\TrimStrings::class,
         \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
-        \App\Http\Middleware\TrustProxies::class,
-        \Illuminate\Http\Middleware\HandleCors::class,
     ];
 
     /**

+ 32 - 31
app/Http/Requests/Status/StoreStatusEditRequest.php

@@ -2,10 +2,10 @@
 
 namespace App\Http\Requests\Status;
 
-use Illuminate\Foundation\Http\FormRequest;
 use App\Media;
 use App\Status;
 use Closure;
+use Illuminate\Foundation\Http\FormRequest;
 
 class StoreStatusEditRequest extends FormRequest
 {
@@ -14,24 +14,25 @@ class StoreStatusEditRequest extends FormRequest
      */
     public function authorize(): bool
     {
-    	$profile = $this->user()->profile;
-    	if($profile->status != null) {
-    		return false;
-    	}
-    	if($profile->unlisted == true && $profile->cw == true) {
-    		return false;
-    	}
-    	$types = [
-			"photo",
-			"photo:album",
-			"photo:video:album",
-			"reply",
-			"text",
-			"video",
-			"video:album"
-    	];
-    	$scopes = ['public', 'unlisted', 'private'];
-    	$status = Status::whereNull('reblog_of_id')->whereIn('type', $types)->whereIn('scope', $scopes)->find($this->route('id'));
+        $profile = $this->user()->profile;
+        if ($profile->status != null) {
+            return false;
+        }
+        if ($profile->unlisted == true && $profile->cw == true) {
+            return false;
+        }
+        $types = [
+            'photo',
+            'photo:album',
+            'photo:video:album',
+            'reply',
+            'text',
+            'video',
+            'video:album',
+        ];
+        $scopes = ['public', 'unlisted', 'private'];
+        $status = Status::whereNull('reblog_of_id')->whereIn('type', $types)->whereIn('scope', $scopes)->find($this->route('id'));
+
         return $status && $this->user()->profile_id === $status->profile_id;
     }
 
@@ -47,18 +48,18 @@ class StoreStatusEditRequest extends FormRequest
             'spoiler_text' => 'nullable|string|max:140',
             'sensitive' => 'sometimes|boolean',
             'media_ids' => [
-            	'nullable',
-            	'required_without:status',
-            	'array',
-            	'max:' . config('pixelfed.max_album_length'),
-				function (string $attribute, mixed $value, Closure $fail) {
-					Media::whereProfileId($this->user()->profile_id)
-						->where(function($query) {
-							return $query->whereNull('status_id')
-							->orWhere('status_id', '=', $this->route('id'));
-						})
-						->findOrFail($value);
-				},
+                'nullable',
+                'required_without:status',
+                'array',
+                'max:'.(int) config_cache('pixelfed.max_album_length'),
+                function (string $attribute, mixed $value, Closure $fail) {
+                    Media::whereProfileId($this->user()->profile_id)
+                        ->where(function ($query) {
+                            return $query->whereNull('status_id')
+                                ->orWhere('status_id', '=', $this->route('id'));
+                        })
+                        ->findOrFail($value);
+                },
             ],
             'location' => 'sometimes|nullable',
             'location.id' => 'sometimes|integer|min:1|max:128769',

+ 9 - 8
app/Http/Resources/AdminUser.php

@@ -2,8 +2,8 @@
 
 namespace App\Http\Resources;
 
-use Illuminate\Http\Resources\Json\JsonResource;
 use App\Services\AccountService;
+use Illuminate\Http\Resources\Json\JsonResource;
 
 class AdminUser extends JsonResource
 {
@@ -18,8 +18,8 @@ class AdminUser extends JsonResource
         $account = AccountService::get($this->profile_id, true);
 
         $res = [
-            'id' => $this->id,
-            'profile_id' => $this->profile_id,
+            'id' => (string) $this->id,
+            'profile_id' => (string) $this->profile_id,
             'name' => $this->name,
             'username' => $this->username,
             'is_admin' => (bool) $this->is_admin,
@@ -28,17 +28,18 @@ class AdminUser extends JsonResource
             'two_factor_enabled' => (bool) $this->{'2fa_enabled'},
             'register_source' => $this->register_source,
             'app_register_ip' => $this->app_register_ip,
+            'has_interstitial' => (bool) $this->has_interstitial,
             'last_active_at' => $this->last_active_at,
             'created_at' => $this->created_at,
         ];
 
-        if($account) {
+        if ($account) {
             $res['avatar'] = $account['avatar'];
             $res['bio'] = $account['note_text'];
-            $res['statuses_count'] = $account['statuses_count'];
-            $res['following_count'] = $account['following_count'];
-            $res['followers_count'] = $account['followers_count'];
-            $res['is_private'] = $account['locked'];
+            $res['statuses_count'] = (int) $account['statuses_count'];
+            $res['following_count'] = (int) $account['following_count'];
+            $res['followers_count'] = (int) $account['followers_count'];
+            $res['is_private'] = (bool) $account['locked'];
         }
 
         return $res;

+ 76 - 76
app/Jobs/AvatarPipeline/AvatarOptimize.php

@@ -2,9 +2,9 @@
 
 namespace App\Jobs\AvatarPipeline;
 
-use Cache;
 use App\Avatar;
 use App\Profile;
+use Cache;
 use Carbon\Carbon;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -17,88 +17,88 @@ use Storage;
 
 class AvatarOptimize implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $profile;
 
-	protected $profile;
-	protected $current;
+    protected $current;
 
-	/**
-	 * Delete the job if its models no longer exist.
-	 *
-	 * @var bool
-	 */
-	public $deleteWhenMissingModels = true;
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
 
-	/**
-	 * Create a new job instance.
-	 *
-	 * @return void
-	 */
-	public function __construct(Profile $profile, $current)
-	{
-		$this->profile = $profile;
-		$this->current = $current;
-	}
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Profile $profile, $current)
+    {
+        $this->profile = $profile;
+        $this->current = $current;
+    }
 
-	/**
-	 * Execute the job.
-	 *
-	 * @return void
-	 */
-	public function handle()
-	{
-		$avatar = $this->profile->avatar;
-		$file = storage_path("app/$avatar->media_path");
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $avatar = $this->profile->avatar;
+        $file = storage_path("app/$avatar->media_path");
 
-		try {
-			$img = Intervention::make($file)->orientate();
-			$img->fit(200, 200, function ($constraint) {
-				$constraint->upsize();
-			});
-			$quality = config_cache('pixelfed.image_quality');
-			$img->save($file, $quality);
+        try {
+            $img = Intervention::make($file)->orientate();
+            $img->fit(200, 200, function ($constraint) {
+                $constraint->upsize();
+            });
+            $quality = config_cache('pixelfed.image_quality');
+            $img->save($file, $quality);
 
-			$avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail();
-			$avatar->change_count = ++$avatar->change_count;
-			$avatar->last_processed_at = Carbon::now();
-			$avatar->save();
-			Cache::forget('avatar:' . $avatar->profile_id);
-			$this->deleteOldAvatar($avatar->media_path, $this->current);
+            $avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail();
+            $avatar->change_count = ++$avatar->change_count;
+            $avatar->last_processed_at = Carbon::now();
+            $avatar->save();
+            Cache::forget('avatar:'.$avatar->profile_id);
+            $this->deleteOldAvatar($avatar->media_path, $this->current);
 
-			if(config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
-				$this->uploadToCloud($avatar);
-			} else {
-				$avatar->cdn_url = null;
-				$avatar->save();
-			}
-		} catch (Exception $e) {
-		}
-	}
+            if ((bool) config_cache('pixelfed.cloud_storage') && (bool) config_cache('instance.avatar.local_to_cloud')) {
+                $this->uploadToCloud($avatar);
+            } else {
+                $avatar->cdn_url = null;
+                $avatar->save();
+            }
+        } catch (Exception $e) {
+        }
+    }
 
-	protected function deleteOldAvatar($new, $current)
-	{
-		if ( storage_path('app/'.$new) == $current ||
-			 Str::endsWith($current, 'avatars/default.png') ||
-			 Str::endsWith($current, 'avatars/default.jpg'))
-		{
-			return;
-		}
-		if (is_file($current)) {
-			@unlink($current);
-		}
-	}
+    protected function deleteOldAvatar($new, $current)
+    {
+        if (storage_path('app/'.$new) == $current ||
+             Str::endsWith($current, 'avatars/default.png') ||
+             Str::endsWith($current, 'avatars/default.jpg')) {
+            return;
+        }
+        if (is_file($current)) {
+            @unlink($current);
+        }
+    }
 
-	protected function uploadToCloud($avatar)
-	{
-		$base = 'cache/avatars/' . $avatar->profile_id;
-		$disk = Storage::disk(config('filesystems.cloud'));
-		$disk->deleteDirectory($base);
-		$path = $base . '/' . 'avatar_' . strtolower(Str::random(random_int(3,6))) . $avatar->change_count . '.' . pathinfo($avatar->media_path, PATHINFO_EXTENSION);
-		$url = $disk->put($path, Storage::get($avatar->media_path));
-		$avatar->media_path = $path;
-		$avatar->cdn_url = $disk->url($path);
-		$avatar->save();
-		Storage::delete($avatar->media_path);
-		Cache::forget('avatar:' . $avatar->profile_id);
-	}
+    protected function uploadToCloud($avatar)
+    {
+        $base = 'cache/avatars/'.$avatar->profile_id;
+        $disk = Storage::disk(config('filesystems.cloud'));
+        $disk->deleteDirectory($base);
+        $path = $base.'/'.'avatar_'.strtolower(Str::random(random_int(3, 6))).$avatar->change_count.'.'.pathinfo($avatar->media_path, PATHINFO_EXTENSION);
+        $url = $disk->put($path, Storage::get($avatar->media_path));
+        $avatar->media_path = $path;
+        $avatar->cdn_url = $disk->url($path);
+        $avatar->save();
+        Storage::delete($avatar->media_path);
+        Cache::forget('avatar:'.$avatar->profile_id);
+    }
 }

+ 95 - 100
app/Jobs/AvatarPipeline/RemoteAvatarFetch.php

@@ -4,112 +4,107 @@ namespace App\Jobs\AvatarPipeline;
 
 use App\Avatar;
 use App\Profile;
+use App\Services\MediaStorageService;
+use App\Util\ActivityPub\Helpers;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
-use App\Util\ActivityPub\Helpers;
-use Illuminate\Support\Str;
-use Zttp\Zttp;
-use App\Http\Controllers\AvatarController;
-use Storage;
-use Log;
-use Illuminate\Http\File;
-use App\Services\MediaStorageService;
-use App\Services\ActivityPubFetchService;
 
 class RemoteAvatarFetch implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-	protected $profile;
-
-	/**
-	* Delete the job if its models no longer exist.
-	*
-	* @var bool
-	*/
-	public $deleteWhenMissingModels = true;
-
-	/**
-	 * The number of times the job may be attempted.
-	 *
-	 * @var int
-	 */
-	public $tries = 1;
-	public $timeout = 300;
-	public $maxExceptions = 1;
-
-	/**
-	* Create a new job instance.
-	*
-	* @return void
-	*/
-	public function __construct(Profile $profile)
-	{
-		$this->profile = $profile;
-	}
-
-	/**
-	* Execute the job.
-	*
-	* @return void
-	*/
-	public function handle()
-	{
-		$profile = $this->profile;
-
-		if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
-			return 1;
-		}
-
-		if($profile->domain == null || $profile->private_key) {
-			return 1;
-		}
-
-		$avatar = Avatar::whereProfileId($profile->id)->first();
-
-		if(!$avatar) {
-			$avatar = new Avatar;
-			$avatar->profile_id = $profile->id;
-			$avatar->save();
-		}
-
-		if($avatar->media_path == null && $avatar->remote_url == null) {
-			$avatar->media_path = 'public/avatars/default.jpg';
-			$avatar->is_remote = true;
-			$avatar->save();
-		}
-
-		$person = Helpers::fetchFromUrl($profile->remote_url);
-
-		if(!$person || !isset($person['@context'])) {
-			return 1;
-		}
-
-		if( !isset($person['icon']) ||
-			!isset($person['icon']['type']) ||
-			!isset($person['icon']['url'])
-		) {
-			return 1;
-		}
-
-		if($person['icon']['type'] !== 'Image') {
-			return 1;
-		}
-
-		if(!Helpers::validateUrl($person['icon']['url'])) {
-			return 1;
-		}
-
-		$icon = $person['icon'];
-
-		$avatar->remote_url = $icon['url'];
-		$avatar->save();
-
-		MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
-
-		return 1;
-	}
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $profile;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * The number of times the job may be attempted.
+     *
+     * @var int
+     */
+    public $tries = 1;
+
+    public $timeout = 300;
+
+    public $maxExceptions = 1;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Profile $profile)
+    {
+        $this->profile = $profile;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $profile = $this->profile;
+
+        if ((bool) config_cache('pixelfed.cloud_storage') == false && (bool) config_cache('federation.avatars.store_local') == false) {
+            return 1;
+        }
+
+        if ($profile->domain == null || $profile->private_key) {
+            return 1;
+        }
+
+        $avatar = Avatar::whereProfileId($profile->id)->first();
+
+        if (! $avatar) {
+            $avatar = new Avatar;
+            $avatar->profile_id = $profile->id;
+            $avatar->save();
+        }
+
+        if ($avatar->media_path == null && $avatar->remote_url == null) {
+            $avatar->media_path = 'public/avatars/default.jpg';
+            $avatar->is_remote = true;
+            $avatar->save();
+        }
+
+        $person = Helpers::fetchFromUrl($profile->remote_url);
+
+        if (! $person || ! isset($person['@context'])) {
+            return 1;
+        }
+
+        if (! isset($person['icon']) ||
+            ! isset($person['icon']['type']) ||
+            ! isset($person['icon']['url'])
+        ) {
+            return 1;
+        }
+
+        if ($person['icon']['type'] !== 'Image') {
+            return 1;
+        }
+
+        if (! Helpers::validateUrl($person['icon']['url'])) {
+            return 1;
+        }
+
+        $icon = $person['icon'];
+
+        $avatar->remote_url = $icon['url'];
+        $avatar->save();
+
+        MediaStorageService::avatar($avatar, (bool) config_cache('pixelfed.cloud_storage') == false, true);
+
+        return 1;
+    }
 }

+ 76 - 81
app/Jobs/AvatarPipeline/RemoteAvatarFetchFromUrl.php

@@ -4,93 +4,88 @@ namespace App\Jobs\AvatarPipeline;
 
 use App\Avatar;
 use App\Profile;
+use App\Services\AccountService;
+use App\Services\MediaStorageService;
+use Cache;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
-use App\Util\ActivityPub\Helpers;
-use Illuminate\Support\Str;
-use Zttp\Zttp;
-use App\Http\Controllers\AvatarController;
-use Cache;
-use Storage;
-use Log;
-use Illuminate\Http\File;
-use App\Services\AccountService;
-use App\Services\MediaStorageService;
-use App\Services\ActivityPubFetchService;
 
 class RemoteAvatarFetchFromUrl implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-	protected $profile;
-	protected $url;
-
-	/**
-	* Delete the job if its models no longer exist.
-	*
-	* @var bool
-	*/
-	public $deleteWhenMissingModels = true;
-
-	/**
-	 * The number of times the job may be attempted.
-	 *
-	 * @var int
-	 */
-	public $tries = 1;
-	public $timeout = 300;
-	public $maxExceptions = 1;
-
-	/**
-	* Create a new job instance.
-	*
-	* @return void
-	*/
-	public function __construct(Profile $profile, $url)
-	{
-		$this->profile = $profile;
-		$this->url = $url;
-	}
-
-	/**
-	* Execute the job.
-	*
-	* @return void
-	*/
-	public function handle()
-	{
-		$profile = $this->profile;
-
-		Cache::forget('avatar:' . $profile->id);
-		AccountService::del($profile->id);
-
-		if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
-			return 1;
-		}
-
-		if($profile->domain == null || $profile->private_key) {
-			return 1;
-		}
-
-		$avatar = Avatar::whereProfileId($profile->id)->first();
-
-		if(!$avatar) {
-			$avatar = new Avatar;
-			$avatar->profile_id = $profile->id;
-			$avatar->is_remote = true;
-			$avatar->remote_url = $this->url;
-			$avatar->save();
-		} else {
-			$avatar->remote_url = $this->url;
-			$avatar->is_remote = true;
-			$avatar->save();
-		}
-
-		MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
-
-		return 1;
-	}
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $profile;
+
+    protected $url;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * The number of times the job may be attempted.
+     *
+     * @var int
+     */
+    public $tries = 1;
+
+    public $timeout = 300;
+
+    public $maxExceptions = 1;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Profile $profile, $url)
+    {
+        $this->profile = $profile;
+        $this->url = $url;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $profile = $this->profile;
+
+        Cache::forget('avatar:'.$profile->id);
+        AccountService::del($profile->id);
+
+        if ((bool) config_cache('pixelfed.cloud_storage') == false && (bool) config_cache('federation.avatars.store_local') == false) {
+            return 1;
+        }
+
+        if ($profile->domain == null || $profile->private_key) {
+            return 1;
+        }
+
+        $avatar = Avatar::whereProfileId($profile->id)->first();
+
+        if (! $avatar) {
+            $avatar = new Avatar;
+            $avatar->profile_id = $profile->id;
+            $avatar->is_remote = true;
+            $avatar->remote_url = $this->url;
+            $avatar->save();
+        } else {
+            $avatar->remote_url = $this->url;
+            $avatar->is_remote = true;
+            $avatar->save();
+        }
+
+        MediaStorageService::avatar($avatar, (bool) config_cache('pixelfed.cloud_storage') == false, true);
+
+        return 1;
+    }
 }

+ 100 - 99
app/Jobs/FollowPipeline/UnfollowPipeline.php

@@ -4,114 +4,115 @@ namespace App\Jobs\FollowPipeline;
 
 use App\Follower;
 use App\FollowRequest;
+use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
 use App\Notification;
 use App\Profile;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\NotificationService;
 use Cache;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
-use Log;
-use Illuminate\Support\Facades\Redis;
-use App\Services\AccountService;
-use App\Services\FollowerService;
-use App\Services\NotificationService;
-use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
 
 class UnfollowPipeline implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-	protected $actor;
-	protected $target;
-
-	/**
-	 * Create a new job instance.
-	 *
-	 * @return void
-	 */
-	public function __construct($actor, $target)
-	{
-		$this->actor = $actor;
-		$this->target = $target;
-	}
-
-	/**
-	 * Execute the job.
-	 *
-	 * @return void
-	 */
-	public function handle()
-	{
-		$actor = $this->actor;
-		$target = $this->target;
-
-		$actorProfile = Profile::find($actor);
-		if(!$actorProfile) {
-			return;
-		}
-		$targetProfile = Profile::find($target);
-		if(!$targetProfile) {
-			return;
-		}
-
-		FeedUnfollowPipeline::dispatch($actor, $target)->onQueue('follow');
-
-		FollowerService::remove($actor, $target);
-
-		$actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY . $actor);
-		if(!$actorProfileSync) {
-			FollowServiceWarmCache::dispatch($actor)->onQueue('low');
-		} else {
-			if($actorProfile->following_count) {
-				$actorProfile->decrement('following_count');
-			} else {
-				$count = Follower::whereProfileId($actor)->count();
-				$actorProfile->following_count = $count;
-				$actorProfile->save();
-			}
-			Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $actor, 1, 604800);
-			AccountService::del($actor);
-		}
-
-		$targetProfileSync = Cache::get(FollowerService::FOLLOWERS_SYNC_KEY . $target);
-		if(!$targetProfileSync) {
-			FollowServiceWarmCache::dispatch($target)->onQueue('low');
-		} else {
-			if($targetProfile->followers_count) {
-				$targetProfile->decrement('followers_count');
-			} else {
-				$count = Follower::whereFollowingId($target)->count();
-				$targetProfile->followers_count = $count;
-				$targetProfile->save();
-			}
-			Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $target, 1, 604800);
-			AccountService::del($target);
-		}
-
-		if($targetProfile->domain == null) {
-			Notification::withTrashed()
-				->whereProfileId($target)
-				->whereAction('follow')
-				->whereActorId($actor)
-				->whereItemId($target)
-				->whereItemType('App\Profile')
-				->get()
-				->each(function($n) {
-					NotificationService::del($n->profile_id, $n->id);
-					$n->forceDelete();
-				});
-		}
-
-		if($actorProfile->domain == null && config('instance.timeline.home.cached')) {
-			Cache::forget('pf:timelines:home:' . $actor);
-		}
-
-		FollowRequest::whereFollowingId($target)
-			->whereFollowerId($actor)
-			->delete();
-
-		return;
-	}
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $actor;
+
+    protected $target;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($actor, $target)
+    {
+        $this->actor = $actor;
+        $this->target = $target;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $actor = $this->actor;
+        $target = $this->target;
+
+        $actorProfile = Profile::find($actor);
+        if (! $actorProfile) {
+            return;
+        }
+        $targetProfile = Profile::find($target);
+        if (! $targetProfile) {
+            return;
+        }
+
+        FeedUnfollowPipeline::dispatch($actor, $target)->onQueue('follow');
+
+        FollowerService::remove($actor, $target);
+
+        $actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY.$actor);
+        if (! $actorProfileSync) {
+            FollowServiceWarmCache::dispatch($actor)->onQueue('low');
+        } else {
+            if ($actorProfile->following_count) {
+                $actorProfile->decrement('following_count');
+            } else {
+                $count = Follower::whereProfileId($actor)->count();
+                $actorProfile->following_count = $count;
+                $actorProfile->save();
+            }
+            Cache::put(FollowerService::FOLLOWING_SYNC_KEY.$actor, 1, 604800);
+            AccountService::del($actor);
+        }
+
+        $targetProfileSync = Cache::get(FollowerService::FOLLOWERS_SYNC_KEY.$target);
+        if (! $targetProfileSync) {
+            FollowServiceWarmCache::dispatch($target)->onQueue('low');
+        } else {
+            if ($targetProfile->followers_count) {
+                $targetProfile->decrement('followers_count');
+            } else {
+                $count = Follower::whereFollowingId($target)->count();
+                $targetProfile->followers_count = $count;
+                $targetProfile->save();
+            }
+            Cache::put(FollowerService::FOLLOWERS_SYNC_KEY.$target, 1, 604800);
+            AccountService::del($target);
+        }
+
+        if ($targetProfile->domain == null) {
+            Notification::withTrashed()
+                ->whereProfileId($target)
+                ->whereAction('follow')
+                ->whereActorId($actor)
+                ->whereItemId($target)
+                ->whereItemType('App\Profile')
+                ->get()
+                ->each(function ($n) {
+                    NotificationService::del($n->profile_id, $n->id);
+                    $n->forceDelete();
+                });
+        }
+
+        if ($actorProfile->domain == null && config('instance.timeline.home.cached')) {
+            Cache::forget('pf:timelines:home:'.$actor);
+        }
+
+        FollowRequest::whereFollowingId($target)
+            ->whereFollowerId($actor)
+            ->delete();
+
+        AccountService::del($target);
+        AccountService::del($actor);
+
+    }
 }

+ 99 - 0
app/Jobs/GroupPipeline/GroupCommentPipeline.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Notification;
+use App\Status;
+use App\Models\GroupPost;
+use Cache;
+use DB;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+use App\Services\NotificationService;
+use App\Services\StatusService;
+
+class GroupCommentPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+	protected $comment;
+	protected $groupPost;
+
+	public function __construct(Status $status, Status $comment, $groupPost = null)
+	{
+		$this->status = $status;
+		$this->comment = $comment;
+		$this->groupPost = $groupPost;
+	}
+
+	public function handle()
+	{
+		if($this->status->group_id == null || $this->comment->group_id == null) {
+			return;
+		}
+
+		$this->updateParentReplyCount();
+		$this->generateNotification();
+
+		if($this->groupPost) {
+			$this->updateChildReplyCount();
+		}
+	}
+
+	protected function updateParentReplyCount()
+	{
+		$parent = $this->status;
+		$parent->reply_count = Status::whereInReplyToId($parent->id)->count();
+		$parent->save();
+		StatusService::del($parent->id);
+	}
+
+	protected function updateChildReplyCount()
+	{
+		$gp = $this->groupPost;
+		if($gp->reply_child_id) {
+			$parent = GroupPost::whereStatusId($gp->reply_child_id)->first();
+			if($parent) {
+				$parent->reply_count++;
+				$parent->save();
+			}
+		}
+	}
+
+	protected function generateNotification()
+	{
+		$status = $this->status;
+		$comment = $this->comment;
+
+		$target = $status->profile;
+        $actor = $comment->profile;
+
+        if ($actor->id == $target->id || $status->comments_disabled == true) {
+            return;
+        }
+
+		$notification = DB::transaction(function() use($target, $actor, $comment) {
+			$actorName = $actor->username;
+			$actorUrl = $actor->url();
+			$text = "{$actorName}  commented on your group post.";
+			$html = "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> commented on your group post.";
+            $notification = new Notification();
+            $notification->profile_id = $target->id;
+            $notification->actor_id = $actor->id;
+            $notification->action = 'group:comment';
+            $notification->item_id = $comment->id;
+            $notification->item_type = "App\Status";
+            $notification->save();
+            return $notification;
+        });
+
+        NotificationService::setNotification($notification);
+        NotificationService::set($notification->profile_id, $notification->id);
+	}
+}

+ 57 - 0
app/Jobs/GroupPipeline/GroupMediaPipeline.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Media;
+use Cache;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+
+class GroupMediaPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $media;
+
+	public function __construct(Media $media)
+	{
+		$this->media = $media;
+	}
+
+	public function handle()
+	{
+		MediaStorageService::store($this->media);
+	}
+
+	protected function localToCloud($media)
+	{
+		$path = storage_path('app/'.$media->media_path);
+		$thumb = storage_path('app/'.$media->thumbnail_path);
+
+		$p = explode('/', $media->media_path);
+		$name = array_pop($p);
+		$pt = explode('/', $media->thumbnail_path);
+		$thumbname = array_pop($pt);
+		$storagePath = implode('/', $p);
+
+		$disk = Storage::disk(config('filesystems.cloud'));
+		$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
+		$url = $disk->url($file);
+		$thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public');
+		$thumbUrl = $disk->url($thumbFile);
+		$media->thumbnail_url = $thumbUrl;
+		$media->cdn_url = $url;
+		$media->optimized_url = $url;
+		$media->replicated_at = now();
+		$media->save();
+		if($media->status_id) {
+			Cache::forget('status:transformer:media:attachments:' . $media->status_id);
+		}
+	}
+
+}

+ 54 - 0
app/Jobs/GroupPipeline/GroupMemberInvite.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupInvitation;
+use App\Notification;
+use App\Profile;
+
+class GroupMemberInvite implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $invite;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupInvitation $invite)
+    {
+        $this->invite = $invite;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $invite = $this->invite;
+        $actor = Profile::find($invite->from_profile_id);
+        $target = Profile::find($invite->to_profile_id);
+
+        if(!$actor || !$target) {
+        	return;
+        }
+
+      	$notification = new Notification;
+      	$notification->profile_id = $target->id;
+      	$notification->actor_id = $actor->id;
+      	$notification->action = 'group:invite';
+      	$notification->item_id = $invite->group_id;
+      	$notification->item_type = 'App\Models\Group';
+      	$notification->save();
+    }
+}

+ 54 - 0
app/Jobs/GroupPipeline/JoinApproved.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class JoinApproved implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->member = $member;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $member = $this->member;
+        $member->approved_at = now();
+        $member->join_request = false;
+        $member->role = 'member';
+        $member->save();
+
+        $n = new Notification;
+        $n->profile_id = $member->profile_id;
+        $n->actor_id = $member->profile_id;
+        $n->item_id = $member->group_id;
+        $n->item_type = 'App\Models\Group';
+        $n->save();
+
+        GroupService::del($member->group_id);
+        GroupService::delSelf($member->group_id, $member->profile_id);
+    }
+}

+ 50 - 0
app/Jobs/GroupPipeline/JoinRejected.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class JoinRejected implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->member = $member;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $member = $this->member;
+        $member->rejected_at = now();
+        $member->save();
+
+        $n = new Notification;
+        $n->profile_id = $member->profile_id;
+        $n->actor_id = $member->profile_id;
+        $n->item_id = $member->group_id;
+        $n->item_type = 'App\Models\Group';
+        $n->action = 'group.join.rejected';
+        $n->save();
+    }
+}

+ 107 - 0
app/Jobs/GroupPipeline/LikePipeline.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Cache, Log;
+use Illuminate\Support\Facades\Redis;
+use App\{Like, Notification};
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\Like as LikeTransformer;
+use App\Services\StatusService;
+
+class LikePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $like;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	public $timeout = 5;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Like $like)
+	{
+		$this->like = $like;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$like = $this->like;
+
+		$status = $this->like->status;
+		$actor = $this->like->actor;
+
+		if (!$status) {
+			// Ignore notifications to deleted statuses
+			return;
+		}
+
+		StatusService::refresh($status->id);
+
+		if($status->url && $actor->domain == null) {
+			return $this->remoteLikeDeliver();
+		}
+
+		$exists = Notification::whereProfileId($status->profile_id)
+				  ->whereActorId($actor->id)
+				  ->whereAction('group:like')
+				  ->whereItemId($status->id)
+				  ->whereItemType('App\Status')
+				  ->count();
+
+		if ($actor->id === $status->profile_id || $exists !== 0) {
+			return true;
+		}
+
+		try {
+			$notification = new Notification();
+			$notification->profile_id = $status->profile_id;
+			$notification->actor_id = $actor->id;
+			$notification->action = 'group:like';
+			$notification->item_id = $status->id;
+			$notification->item_type = "App\Status";
+			$notification->save();
+
+		} catch (Exception $e) {
+		}
+	}
+
+	public function remoteLikeDeliver()
+	{
+		$like = $this->like;
+		$status = $this->like->status;
+		$actor = $this->like->actor;
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($like, new LikeTransformer());
+		$activity = $fractal->createData($resource)->toArray();
+
+		$url = $status->profile->sharedInbox ?? $status->profile->inbox_url;
+
+		Helpers::sendSignedObject($actor, $url, $activity);
+	}
+}

+ 130 - 0
app/Jobs/GroupPipeline/NewStatusPipeline.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Notification;
+use App\Hashtag;
+use App\Mention;
+use App\Profile;
+use App\Status;
+use App\StatusHashtag;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupPost;
+use Cache;
+use DB;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+use App\Services\NotificationService;
+use App\Services\StatusService;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+
+class NewStatusPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+	protected $gp;
+	protected $tags;
+	protected $mentions;
+
+	public function __construct(Status $status, GroupPost $gp)
+	{
+		$this->status = $status;
+		$this->gp = $gp;
+	}
+
+	public function handle()
+	{
+		$status = $this->status;
+
+		$autolink = Autolink::create()
+			->setAutolinkActiveUsersOnly(true)
+			->setBaseHashPath("/groups/{$status->group_id}/topics/")
+			->setBaseUserPath("/groups/{$status->group_id}/username/")
+			->autolink($status->caption);
+
+        $entities = Extractor::create()->extract($status->caption);
+
+		$autolink = str_replace('/discover/tags/', '/groups/' . $status->group_id . '/topics/', $autolink);
+
+		$status->rendered = nl2br($autolink);
+		$status->entities = null;
+		$status->save();
+
+		$this->tags = array_unique($entities['hashtags']);
+		$this->mentions = array_unique($entities['mentions']);
+
+		if(count($this->tags)) {
+			$this->storeHashtags();
+		}
+
+		if(count($this->mentions)) {
+			$this->storeMentions($this->mentions);
+		}
+	}
+
+	protected function storeHashtags()
+	{
+		$tags = $this->tags;
+		$status = $this->status;
+		$gp = $this->gp;
+
+		foreach ($tags as $tag) {
+			if(mb_strlen($tag) > 124) {
+				continue;
+			}
+
+			DB::transaction(function () use ($status, $tag, $gp) {
+				$slug = str_slug($tag, '-', false);
+				$hashtag = Hashtag::firstOrCreate(
+					['name' => $tag, 'slug' => $slug]
+				);
+				GroupPostHashtag::firstOrCreate(
+					[
+						'group_id' => $status->group_id,
+						'group_post_id' => $gp->id,
+						'status_id' => $status->id,
+						'hashtag_id' => $hashtag->id,
+						'profile_id' => $status->profile_id,
+					]
+				);
+
+			});
+		}
+
+		if(count($this->mentions)) {
+			$this->storeMentions();
+		}
+		StatusService::del($status->id);
+	}
+
+	protected function storeMentions()
+	{
+		$mentions = $this->mentions;
+		$status = $this->status;
+
+		foreach ($mentions as $mention) {
+			$mentioned = Profile::whereUsername($mention)->first();
+
+			if (empty($mentioned) || !isset($mentioned->id)) {
+				continue;
+			}
+
+			DB::transaction(function () use ($status, $mentioned) {
+				$m = new Mention();
+				$m->status_id = $status->id;
+				$m->profile_id = $mentioned->id;
+				$m->save();
+
+				MentionPipeline::dispatch($status, $m);
+			});
+		}
+		StatusService::del($status->id);
+	}
+}

+ 109 - 0
app/Jobs/GroupPipeline/UnlikePipeline.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Cache, Log;
+use Illuminate\Support\Facades\Redis;
+use App\{Like, Notification};
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\UndoLike as LikeTransformer;
+use App\Services\StatusService;
+
+class UnlikePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $like;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	public $timeout = 5;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Like $like)
+	{
+		$this->like = $like;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$like = $this->like;
+
+		$status = $this->like->status;
+		$actor = $this->like->actor;
+
+		if (!$status) {
+			// Ignore notifications to deleted statuses
+			return;
+		}
+
+		$count = $status->likes_count > 1 ? $status->likes_count : $status->likes()->count();
+		$status->likes_count = $count - 1;
+		$status->save();
+
+		StatusService::del($status->id);
+
+		if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) {
+			$this->remoteLikeDeliver();
+		}
+
+		$exists = Notification::whereProfileId($status->profile_id)
+				  ->whereActorId($actor->id)
+				  ->whereAction('group:like')
+				  ->whereItemId($status->id)
+				  ->whereItemType('App\Status')
+				  ->first();
+
+		if($exists) {
+			$exists->delete();
+		}
+
+		$like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first();
+
+		if(!$like) {
+			return;
+		}
+
+		$like->forceDelete();
+
+		return;
+	}
+
+	public function remoteLikeDeliver()
+	{
+		$like = $this->like;
+		$status = $this->like->status;
+		$actor = $this->like->actor;
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($like, new LikeTransformer());
+		$activity = $fractal->createData($resource)->toArray();
+
+		$url = $status->profile->sharedInbox ?? $status->profile->inbox_url;
+
+		Helpers::sendSignedObject($actor, $url, $activity);
+	}
+}

+ 58 - 0
app/Jobs/GroupsPipeline/DeleteCommentPipeline.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupComment;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class DeleteCommentPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $parent;
+    protected $status;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($parent, $status)
+    {
+        $this->parent = $parent;
+        $this->status = $status;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $parent = $this->parent;
+        $parent->reply_count = GroupComment::whereStatusId($parent->id)->count();
+        $parent->save();
+
+        return;
+    }
+}

+ 89 - 0
app/Jobs/GroupsPipeline/ImageResizePipeline.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Log;
+use Storage;
+use Image as Intervention;
+
+class ImageResizePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->media = $media;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $media = $this->media;
+
+        if(!$media) {
+            return;
+        }
+
+        if (!Storage::exists($media->media_path) || $media->skip_optimize) {
+            return;
+        }
+
+        $path = $media->media_path;
+        $file = storage_path('app/' . $path);
+        $quality = config_cache('pixelfed.image_quality');
+
+        $orientations = [
+            'square' => [
+                'width'  => 1080,
+                'height' => 1080,
+            ],
+            'landscape' => [
+                'width'  => 1920,
+                'height' => 1080,
+            ],
+            'portrait' => [
+                'width'  => 1080,
+                'height' => 1350,
+            ],
+        ];
+
+        try {
+            $img = Intervention::make($file);
+            $img->orientate();
+            $width = $img->width();
+            $height = $img->height();
+            $aspect = $width / $height;
+            $orientation = $aspect === 1 ? 'square' : ($aspect > 1 ? 'landscape' : 'portrait');
+            $ratio = $orientations[$orientation];
+            $img->resize($ratio['width'], $ratio['height']);
+            $img->save($file, $quality);
+        } catch (Exception $e) {
+            Log::error($e);
+        }
+    }
+}

+ 67 - 0
app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use Illuminate\Http\File;
+use Exception;
+use GuzzleHttp\Exception\ClientException;
+use Aws\S3\Exception\S3Exception;
+use GuzzleHttp\Exception\ConnectException;
+use League\Flysystem\UnableToWriteFile;
+
+class ImageS3DeletePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+    static $attempts = 1;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->media = $media;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $media = $this->media;
+
+        if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) {
+            return;
+        }
+
+        $fs = Storage::disk(config('filesystems.cloud'));
+
+        if(!$fs) {
+            return;
+        }
+
+        if($fs->exists($media->media_path)) {
+            $fs->delete($media->media_path);
+        }
+    }
+}

+ 107 - 0
app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use Illuminate\Http\File;
+use Exception;
+use GuzzleHttp\Exception\ClientException;
+use Aws\S3\Exception\S3Exception;
+use GuzzleHttp\Exception\ConnectException;
+use League\Flysystem\UnableToWriteFile;
+
+class ImageS3UploadPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+    static $attempts = 1;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->media = $media;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $media = $this->media;
+
+        if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) {
+            return;
+        }
+
+        $path = storage_path('app/' . $media->media_path);
+
+        $p = explode('/', $media->media_path);
+        $name = array_pop($p);
+        $storagePath = implode('/', $p);
+
+        $url =  (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ?
+            self::handleResilientStore($storagePath, $path, $name) :
+            self::handleStore($storagePath, $path, $name);
+
+        if($url && strlen($url) && str_starts_with($url, 'https://')) {
+            $media->cdn_url = $url;
+            $media->processed_at = now();
+            $media->version = 11;
+            $media->save();
+            Storage::disk('local')->delete($media->media_path);
+        }
+    }
+
+    protected function handleStore($storagePath, $path, $name)
+    {
+        return retry(3, function() use($storagePath, $path, $name) {
+            $baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local';
+            $disk = Storage::disk($baseDisk);
+            $file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
+            return $disk->url($file);
+        }, random_int(100, 500));
+    }
+
+    protected function handleResilientStore($storagePath, $path, $name)
+    {
+        $attempts = 0;
+        return retry(4, function() use($storagePath, $path, $name, $attempts) {
+            self::$attempts++;
+            usleep(100000);
+            $baseDisk = self::$attempts > 1 ? $this->getAltDriver() : config('filesystems.cloud');
+            try {
+                $disk = Storage::disk($baseDisk);
+                $file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
+            } catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {}
+            return $disk->url($file);
+        }, function (int $attempt, Exception $exception) {
+            return $attempt * 200;
+        });
+    }
+
+    protected function getAltDriver()
+    {
+        return config('filesystems.cloud');
+    }
+}

+ 47 - 0
app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class MemberJoinApprovedPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->member = $member;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $member = $this->member;
+        $member->approved_at = now();
+        $member->join_request = false;
+        $member->role = 'member';
+        $member->save();
+
+        GroupService::del($member->group_id);
+        GroupService::delSelf($member->group_id, $member->profile_id);
+    }
+}

+ 42 - 0
app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class MemberJoinRejectedPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->member = $member;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $member = $this->member;
+        $member->rejected_at = now();
+        $member->save();
+    }
+}

+ 115 - 0
app/Jobs/GroupsPipeline/NewCommentPipeline.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupComment;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class NewCommentPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $status;
+    protected $parent;
+    protected $entities;
+    protected $autolink;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($parent, GroupComment $status)
+    {
+        $this->parent = $parent;
+        $this->status = $status;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $profile = $this->status->profile;
+        $status = $this->status;
+
+        $parent = $this->parent;
+        $parent->reply_count = GroupComment::whereStatusId($parent->id)->count();
+        $parent->save();
+
+        if ($profile->no_autolink == false) {
+            $this->parseEntities();
+        }
+    }
+
+    public function parseEntities()
+    {
+        $this->extractEntities();
+    }
+
+    public function extractEntities()
+    {
+        $this->entities = Extractor::create()->extract($this->status->caption);
+        $this->autolinkStatus();
+    }
+
+    public function autolinkStatus()
+    {
+        $this->autolink = Autolink::create()->autolink($this->status->caption);
+        $this->storeHashtags();
+    }
+
+    public function storeHashtags()
+    {
+        $tags = array_unique($this->entities['hashtags']);
+        $status = $this->status;
+
+        foreach ($tags as $tag) {
+            if (mb_strlen($tag) > 124) {
+                continue;
+            }
+            DB::transaction(function () use ($status, $tag) {
+                $hashtag = GroupHashtag::firstOrCreate([
+                    'name' => $tag,
+                ]);
+
+                GroupPostHashtag::firstOrCreate(
+                    [
+                        'status_id' => $status->id,
+                        'group_id' => $status->group_id,
+                        'hashtag_id' => $hashtag->id,
+                        'profile_id' => $status->profile_id,
+                        'status_visibility' => $status->visibility,
+                    ]
+                );
+            });
+        }
+        $this->storeMentions();
+    }
+
+    public function storeMentions()
+    {
+        // todo
+    }
+}

+ 108 - 0
app/Jobs/GroupsPipeline/NewPostPipeline.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class NewPostPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $status;
+    protected $entities;
+    protected $autolink;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupPost $status)
+    {
+        $this->status = $status;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $profile = $this->status->profile;
+        $status = $this->status;
+
+        if ($profile->no_autolink == false) {
+            $this->parseEntities();
+        }
+    }
+
+    public function parseEntities()
+    {
+        $this->extractEntities();
+    }
+
+    public function extractEntities()
+    {
+        $this->entities = Extractor::create()->extract($this->status->caption);
+        $this->autolinkStatus();
+    }
+
+    public function autolinkStatus()
+    {
+        $this->autolink = Autolink::create()->autolink($this->status->caption);
+        $this->storeHashtags();
+    }
+
+    public function storeHashtags()
+    {
+        $tags = array_unique($this->entities['hashtags']);
+        $status = $this->status;
+
+        foreach ($tags as $tag) {
+            if (mb_strlen($tag) > 124) {
+                continue;
+            }
+            DB::transaction(function () use ($status, $tag) {
+                $hashtag = GroupHashtag::firstOrCreate([
+                    'name' => $tag,
+                ]);
+
+                GroupPostHashtag::firstOrCreate(
+                    [
+                        'status_id' => $status->id,
+                        'group_id' => $status->group_id,
+                        'hashtag_id' => $hashtag->id,
+                        'profile_id' => $status->profile_id,
+                        'status_visibility' => $status->visibility,
+                    ]
+                );
+            });
+        }
+        $this->storeMentions();
+    }
+
+    public function storeMentions()
+    {
+        // todo
+    }
+}

+ 1 - 1
app/Jobs/ImageOptimizePipeline/ImageOptimize.php

@@ -45,7 +45,7 @@ class ImageOptimize implements ShouldQueue
             return;
         }
 
-        if(config('pixelfed.optimize_image') == false) {
+        if((bool) config_cache('pixelfed.optimize_image') == false) {
         	ImageThumbnail::dispatch($media)->onQueue('mmo');
     		return;
     	} else {

+ 1 - 1
app/Jobs/ImageOptimizePipeline/ImageResize.php

@@ -51,7 +51,7 @@ class ImageResize implements ShouldQueue
             return;
         }
 
-        if(!config('pixelfed.optimize_image')) {
+        if((bool) config_cache('pixelfed.optimize_image') === false) {
         	ImageThumbnail::dispatch($media)->onQueue('mmo');
         	return;
         }

+ 1 - 1
app/Jobs/ImageOptimizePipeline/ImageUpdate.php

@@ -61,7 +61,7 @@ class ImageUpdate implements ShouldQueue
 			return;
 		}
 
-		if(config('pixelfed.optimize_image')) {
+		if((bool) config_cache('pixelfed.optimize_image')) {
 			if (in_array($media->mime, $this->protectedMimes) == true) {
 				ImageOptimizer::optimize($thumb);
 				if(!$media->skip_optimize) {

+ 2 - 0
app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php

@@ -72,10 +72,12 @@ class FetchNodeinfoPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessin
                 $instance->software = strtolower(strip_tags($software));
                 $instance->user_count = Profile::whereDomain($instance->domain)->count();
                 $instance->nodeinfo_last_fetched = now();
+                $instance->last_crawled_at = now();
                 $instance->save();
             }
         } else {
             $instance->delivery_timeout = 1;
+            $instance->last_crawled_at = now();
             $instance->delivery_next_after = now()->addHours(14);
             $instance->save();
         }

+ 49 - 46
app/Jobs/MediaPipeline/MediaDeletePipeline.php

@@ -3,27 +3,30 @@
 namespace App\Jobs\MediaPipeline;
 
 use App\Media;
+use App\Services\Media\MediaHlsService;
 use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\Middleware\WithoutOverlapping;
 use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Facades\Storage;
-use App\Services\Media\MediaHlsService;
-use Illuminate\Queue\Middleware\WithoutOverlapping;
-use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
 
-class MediaDeletePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
+class MediaDeletePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
-	protected $media;
+    protected $media;
 
     public $timeout = 300;
+
     public $tries = 3;
+
     public $maxExceptions = 1;
+
     public $failOnTimeout = true;
+
     public $deleteWhenMissingModels = true;
 
     /**
@@ -38,7 +41,7 @@ class MediaDeletePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
      */
     public function uniqueId(): string
     {
-        return 'media:purge-job:id-' . $this->media->id;
+        return 'media:purge-job:id-'.$this->media->id;
     }
 
     /**
@@ -51,58 +54,58 @@ class MediaDeletePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
         return [(new WithoutOverlapping("media:purge-job:id-{$this->media->id}"))->shared()->dontRelease()];
     }
 
-	public function __construct(Media $media)
-	{
-		$this->media = $media;
-	}
+    public function __construct(Media $media)
+    {
+        $this->media = $media;
+    }
 
-	public function handle()
-	{
-		$media = $this->media;
-		$path = $media->media_path;
-		$thumb = $media->thumbnail_path;
+    public function handle()
+    {
+        $media = $this->media;
+        $path = $media->media_path;
+        $thumb = $media->thumbnail_path;
 
-		if(!$path) {
-			return 1;
-		}
+        if (! $path) {
+            return 1;
+        }
 
-		$e = explode('/', $path);
-		array_pop($e);
-		$i = implode('/', $e);
+        $e = explode('/', $path);
+        array_pop($e);
+        $i = implode('/', $e);
 
-		if(config_cache('pixelfed.cloud_storage') == true) {
-			$disk = Storage::disk(config('filesystems.cloud'));
+        if ((bool) config_cache('pixelfed.cloud_storage') == true) {
+            $disk = Storage::disk(config('filesystems.cloud'));
 
-			if($path && $disk->exists($path)) {
-				$disk->delete($path);
-			}
+            if ($path && $disk->exists($path)) {
+                $disk->delete($path);
+            }
 
-			if($thumb && $disk->exists($thumb)) {
-				$disk->delete($thumb);
-			}
-		}
+            if ($thumb && $disk->exists($thumb)) {
+                $disk->delete($thumb);
+            }
+        }
 
-		$disk = Storage::disk(config('filesystems.local'));
+        $disk = Storage::disk(config('filesystems.local'));
 
-		if($path && $disk->exists($path)) {
-			$disk->delete($path);
-		}
+        if ($path && $disk->exists($path)) {
+            $disk->delete($path);
+        }
 
-		if($thumb && $disk->exists($thumb)) {
-			$disk->delete($thumb);
-		}
+        if ($thumb && $disk->exists($thumb)) {
+            $disk->delete($thumb);
+        }
 
-		if($media->hls_path != null) {
+        if ($media->hls_path != null) {
             $files = MediaHlsService::allFiles($media);
-            if($files && count($files)) {
-                foreach($files as $file) {
+            if ($files && count($files)) {
+                foreach ($files as $file) {
                     $disk->delete($file);
                 }
             }
-		}
+        }
 
-		$media->delete();
+        $media->delete();
 
-		return 1;
-	}
+        return 1;
+    }
 }

+ 61 - 60
app/Jobs/MediaPipeline/MediaFixLocalFilesystemCleanupPipeline.php

@@ -8,68 +8,69 @@ use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Facades\Storage;
 
 class MediaFixLocalFilesystemCleanupPipeline implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-	public $timeout = 1800;
-	public $tries = 5;
-	public $maxExceptions = 1;
-
-	public function handle()
-	{
-		if(config_cache('pixelfed.cloud_storage') == false) {
-			// Only run if cloud storage is enabled
-			return;
-		}
-
-		$disk = Storage::disk('local');
-		$cloud = Storage::disk(config('filesystems.cloud'));
-
-		Media::whereNotNull(['status_id', 'cdn_url', 'replicated_at'])
-		->chunk(20, function ($medias) use($disk, $cloud) {
-			foreach($medias as $media) {
-				if(!str_starts_with($media->media_path, 'public')) {
-					continue;
-				}
-
-				if($disk->exists($media->media_path) && $cloud->exists($media->media_path)) {
-					$disk->delete($media->media_path);
-				}
-
-				if($media->thumbnail_path) {
-					if($disk->exists($media->thumbnail_path)) {
-						$disk->delete($media->thumbnail_path);
-					}
-				}
-
-				$paths = explode('/', $media->media_path);
-				if(count($paths) === 7) {
-					array_pop($paths);
-					$baseDir = implode('/', $paths);
-
-					if(count($disk->allFiles($baseDir)) === 0) {
-						$disk->deleteDirectory($baseDir);
-
-						array_pop($paths);
-						$baseDir = implode('/', $paths);
-
-						if(count($disk->allFiles($baseDir)) === 0) {
-							$disk->deleteDirectory($baseDir);
-
-							array_pop($paths);
-							$baseDir = implode('/', $paths);
-
-							if(count($disk->allFiles($baseDir)) === 0) {
-								$disk->deleteDirectory($baseDir);
-							}
-						}
-					}
-				}
-			}
-		});
-	}
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public $timeout = 1800;
+
+    public $tries = 5;
+
+    public $maxExceptions = 1;
+
+    public function handle()
+    {
+        if ((bool) config_cache('pixelfed.cloud_storage') == false) {
+            // Only run if cloud storage is enabled
+            return;
+        }
+
+        $disk = Storage::disk('local');
+        $cloud = Storage::disk(config('filesystems.cloud'));
+
+        Media::whereNotNull(['status_id', 'cdn_url', 'replicated_at'])
+            ->chunk(20, function ($medias) use ($disk, $cloud) {
+                foreach ($medias as $media) {
+                    if (! str_starts_with($media->media_path, 'public')) {
+                        continue;
+                    }
+
+                    if ($disk->exists($media->media_path) && $cloud->exists($media->media_path)) {
+                        $disk->delete($media->media_path);
+                    }
+
+                    if ($media->thumbnail_path) {
+                        if ($disk->exists($media->thumbnail_path)) {
+                            $disk->delete($media->thumbnail_path);
+                        }
+                    }
+
+                    $paths = explode('/', $media->media_path);
+                    if (count($paths) === 7) {
+                        array_pop($paths);
+                        $baseDir = implode('/', $paths);
+
+                        if (count($disk->allFiles($baseDir)) === 0) {
+                            $disk->deleteDirectory($baseDir);
+
+                            array_pop($paths);
+                            $baseDir = implode('/', $paths);
+
+                            if (count($disk->allFiles($baseDir)) === 0) {
+                                $disk->deleteDirectory($baseDir);
+
+                                array_pop($paths);
+                                $baseDir = implode('/', $paths);
+
+                                if (count($disk->allFiles($baseDir)) === 0) {
+                                    $disk->deleteDirectory($baseDir);
+                                }
+                            }
+                        }
+                    }
+                }
+            });
+    }
 }

Неке датотеке нису приказане због велике количине промена