Browse Source

Merge branch 'staging' into l10n_staging

daniel 2 months ago
parent
commit
30416d59c1
100 changed files with 5432 additions and 1979 deletions
  1. 5 5
      .env.docker
  2. 1 0
      .env.example
  3. 65 0
      .github/ISSUE_TEMPLATE/bug-report.yml
  4. 8 0
      .github/ISSUE_TEMPLATE/config.yml
  5. 52 0
      .github/ISSUE_TEMPLATE/feature-request.yml
  6. 65 0
      .github/ISSUE_TEMPLATE/federation.yml
  7. 2 0
      .gitignore
  8. 67 2
      CHANGELOG.md
  9. 1 1
      CONTRIBUTING.md
  10. 2 2
      Dockerfile
  11. 12 0
      README.md
  12. 1 1
      app/Auth/BearerTokenResponse.php
  13. 46 22
      app/Console/Commands/AccountPostCountStatUpdate.php
  14. 5 3
      app/Console/Commands/CatchUnoptimizedMedia.php
  15. 170 0
      app/Console/Commands/CuratedOnboardingCommand.php
  16. 1 1
      app/Console/Commands/FixUsernames.php
  17. 0 2
      app/Console/Commands/ImportCities.php
  18. 15 4
      app/Console/Commands/InstanceUpdateTotalLocalPosts.php
  19. 113 0
      app/Console/Commands/Localization.php
  20. 127 0
      app/Console/Commands/MediaReplaceDomainCommand.php
  21. 0 2
      app/Console/Commands/PushGatewayRefresh.php
  22. 85 0
      app/Console/Commands/ReclaimUsername.php
  23. 5 5
      app/Console/Commands/TransformImports.php
  24. 1 1
      app/Console/Commands/UserAccountDelete.php
  25. 1 1
      app/Console/Kernel.php
  26. 1 1
      app/HashtagFollow.php
  27. 563 563
      app/Http/Controllers/AccountController.php
  28. 11 2
      app/Http/Controllers/Admin/AdminSettingsController.php
  29. 2 2
      app/Http/Controllers/AdminInviteController.php
  30. 371 59
      app/Http/Controllers/Api/ApiV1Controller.php
  31. 3 24
      app/Http/Controllers/Api/ApiV1Dot1Controller.php
  32. 7 7
      app/Http/Controllers/Api/ApiV2Controller.php
  33. 82 101
      app/Http/Controllers/Api/BaseApiController.php
  34. 1 1
      app/Http/Controllers/Api/V1/DomainBlockController.php
  35. 322 0
      app/Http/Controllers/AppRegisterController.php
  36. 2 2
      app/Http/Controllers/Auth/RegisterController.php
  37. 1 1
      app/Http/Controllers/CollectionController.php
  38. 1 3
      app/Http/Controllers/CommentController.php
  39. 13 5
      app/Http/Controllers/ComposeController.php
  40. 102 60
      app/Http/Controllers/CuratedRegisterController.php
  41. 503 0
      app/Http/Controllers/CustomFilterController.php
  42. 10 0
      app/Http/Controllers/CustomFilterKeywordController.php
  43. 10 0
      app/Http/Controllers/CustomFilterStatusController.php
  44. 133 284
      app/Http/Controllers/DirectMessageController.php
  45. 17 19
      app/Http/Controllers/DiscoverController.php
  46. 1 1
      app/Http/Controllers/FederationController.php
  47. 27 2
      app/Http/Controllers/GroupController.php
  48. 142 57
      app/Http/Controllers/ImportPostController.php
  49. 11 4
      app/Http/Controllers/MediaController.php
  50. 1 1
      app/Http/Controllers/ProfileController.php
  51. 6 2
      app/Http/Controllers/ProfileMigrationController.php
  52. 169 49
      app/Http/Controllers/PublicApiController.php
  53. 4 33
      app/Http/Controllers/RemoteAuthController.php
  54. 121 0
      app/Http/Controllers/RemoteOidcController.php
  55. 81 34
      app/Http/Controllers/ReportController.php
  56. 212 95
      app/Http/Controllers/Settings/ExportSettings.php
  57. 5 0
      app/Http/Controllers/SettingsController.php
  58. 1 1
      app/Http/Controllers/SiteController.php
  59. 1 1
      app/Http/Controllers/UserEmailForgotController.php
  60. 2 1
      app/Http/Middleware/VerifyCsrfToken.php
  61. 1 1
      app/Http/Requests/Status/StoreStatusEditRequest.php
  62. 57 56
      app/Jobs/ImageOptimizePipeline/ImageUpdate.php
  63. 1 0
      app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php
  64. 14 1
      app/Jobs/MentionPipeline/MentionPipeline.php
  65. 90 0
      app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
  66. 1 1
      app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php
  67. 93 7
      app/Jobs/StatusPipeline/NewStatusPipeline.php
  68. 1 1
      app/Jobs/StatusPipeline/StatusActivityPubDeliver.php
  69. 1 1
      app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php
  70. 106 71
      app/Jobs/StatusPipeline/StatusTagsPipeline.php
  71. 55 0
      app/Mail/InAppRegisterEmailVerify.php
  72. 2 0
      app/Media.php
  73. 10 0
      app/Models/AppRegister.php
  74. 412 0
      app/Models/CustomFilter.php
  75. 37 0
      app/Models/CustomFilterKeyword.php
  76. 23 0
      app/Models/CustomFilterStatus.php
  77. 25 0
      app/Models/UserOidcMapping.php
  78. 61 0
      app/Policies/CustomFilterPolicy.php
  79. 106 68
      app/Providers/AppServiceProvider.php
  80. 3 1
      app/Providers/AuthServiceProvider.php
  81. 25 0
      app/Rules/EmailNotBanned.php
  82. 57 0
      app/Rules/PixelfedUsername.php
  83. 62 0
      app/Rules/Webfinger.php
  84. 10 1
      app/Services/Account/AccountStatService.php
  85. 3 4
      app/Services/AdminStatsService.php
  86. 1 1
      app/Services/ImportService.php
  87. 1 0
      app/Services/LandingService.php
  88. 3 3
      app/Services/LikeService.php
  89. 22 8
      app/Services/MediaStorageService.php
  90. 4 110
      app/Services/PushNotificationService.php
  91. 103 99
      app/Services/RelationshipService.php
  92. 55 9
      app/Services/SearchApiV2Service.php
  93. 44 39
      app/Services/SnowflakeService.php
  94. 87 1
      app/Services/StatusService.php
  95. 21 0
      app/Services/UserOidcService.php
  96. 16 0
      app/Services/WebfingerService.php
  97. 12 12
      app/Transformer/ActivityPub/StatusTransformer.php
  98. 0 7
      app/Transformer/Api/AccountTransformer.php
  99. 17 10
      app/Transformer/Api/RelationshipTransformer.php
  100. 1 0
      app/Transformer/Api/StatusStatelessTransformer.php

+ 5 - 5
.env.docker

@@ -964,7 +964,7 @@ TZ="${APP_TIMEZONE}"
 # Combined with [DOCKER_APP_RUNTIME] and [PHP_VERSION] configured
 # elsewhere in this file, the final Docker tag is computed.
 # @dottie/validate required
-DOCKER_APP_RELEASE="branch-jippi-fork"
+DOCKER_APP_RELEASE="v0.12"
 
 # The PHP version to use for [web] and [worker] container
 #
@@ -981,7 +981,7 @@ DOCKER_APP_RELEASE="branch-jippi-fork"
 # *only* the version part. The rest of the full tag is derived from
 # the [DOCKER_APP_RUNTIME] and [PHP_DEBIAN_RELEASE] settings
 # @dottie/validate required
-DOCKER_APP_PHP_VERSION="8.2"
+DOCKER_APP_PHP_VERSION="8.3"
 
 # The container runtime to use.
 #
@@ -993,7 +993,7 @@ DOCKER_APP_RUNTIME="apache"
 #
 # Examlpe: [bookworm] or [bullseye]
 # @dottie/validate required,oneof=bookworm bullseye
-DOCKER_APP_DEBIAN_RELEASE="bullseye"
+DOCKER_APP_DEBIAN_RELEASE="bookworm"
 
 # The [php] Docker image base type
 #
@@ -1010,7 +1010,7 @@ DOCKER_APP_BASE_TYPE="apache"
 #   * "your/fork"                 to pull from a custom fork
 #
 # @dottie/validate required
-DOCKER_APP_IMAGE="ghcr.io/jippi/pixelfed"
+DOCKER_APP_IMAGE="ghcr.io/jippi/docker-pixelfed"
 
 # Pixelfed version (image tag) to pull from the registry.
 #
@@ -1270,7 +1270,7 @@ DOCKER_WORKER_HEALTHCHECK_INTERVAL="${DOCKER_ALL_DEFAULT_HEALTHCHECK_INTERVAL:?e
 #
 # @see https://hub.docker.com/r/nginxproxy/nginx-proxy
 # @dottie/validate required
-DOCKER_PROXY_VERSION="1.4"
+DOCKER_PROXY_VERSION="1.6"
 
 # How often Docker health check should run for [proxy] service
 # @dottie/validate required

+ 1 - 0
.env.example

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

+ 65 - 0
.github/ISSUE_TEMPLATE/bug-report.yml

@@ -0,0 +1,65 @@
+name: 🐞 Bug report
+title: "[Bug]: "
+labels: ["bug", "triage"]
+description: Report an issue with Pixelfed here
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this bug report!
+
+        Before you proceed:
+
+        - Make sure to check whether there are similar issues in the repository
+        - Make sure you're using the latest version of the app
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Required information
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: Please provide a clear, concise and descriptive explanation of what the bug is. Include screenshots or a video if possible. Tell us what were you expecting to happen instead of what is happening now.
+    validations:
+      required: true
+
+  - type: textarea
+    id: steps-to-reproduce
+    attributes:
+      label: Steps to reproduce
+      description: Provide a detailed list of steps that reproduce the issue.
+      placeholder: |
+        1.
+        2.
+        3.
+    validations:
+      required: true
+
+  - type: input
+    id: pixelfed-version
+    attributes:
+      label: Pixelfed version
+      description: What version of Pixelfed are you using?
+      placeholder: ex. 1.2.3
+    validations:
+      required: true
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Additonal information
+
+        Providing as much information as possible greatly helps us with reproducting the issues.
+
+  - type: dropdown
+    id: acknowledgements
+    attributes:
+      label: Acknowledgements
+      description: I searched for similar issues in the repository.
+      options:
+        - 'Yes'
+    validations:
+      required: true
+

+ 8 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Mobile Application Issues
+    url: https://github.com/pixelfed/pixelfed-rn/issues
+    about: Please create issues in the mobile app repo.
+  - name: Question
+    url: https://discord.gg/6Fy6AJMbMU
+    about: Please ask and answer questions in our Discord server

+ 52 - 0
.github/ISSUE_TEMPLATE/feature-request.yml

@@ -0,0 +1,52 @@
+name: 💡 Feature request
+title: "[Feature]: "
+labels: ["enhancement", "triage"]
+description: Suggest a new feature for Pixelfed here
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this feature request form!
+
+        Before you proceed:
+
+        - Make sure to check whether there are similar feature requests open in the repository
+        - Make sure this isn't currenly being worked on in a pull request
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Required information
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: Please provide a clear, concise and description of what the feature is.
+    validations:
+      required: true
+
+  - type: textarea
+    id: use-case
+    attributes:
+      label: Use-case
+      description: Please describe how this feature will benefit you and other users.
+    validations:
+      required: true
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Additonal information
+
+        Providing as much information as possible greatly helps us with reproducting the issues.
+
+  - type: dropdown
+    id: acknowledgements
+    attributes:
+      label: Acknowledgements
+      description: I searched for similar feature requests in the repository.
+      options:
+        - 'Yes'
+    validations:
+      required: true
+

+ 65 - 0
.github/ISSUE_TEMPLATE/federation.yml

@@ -0,0 +1,65 @@
+name: 🐞 Federation (ActivityPub)
+title: "[Federation]: "
+labels: ["activitypub", "triage"]
+description: Report an issue with Pixelfed and the Fediverse/ActivityPub here
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this bug report!
+
+        Before you proceed:
+
+        - Make sure to check whether there are similar issues in the repository
+        - Make sure you're using the latest version of the app
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Required information
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: Please provide a clear, concise and descriptive explanation of what the bug is. Include screenshots or a video if possible. Tell us what were you expecting to happen instead of what is happening now.
+    validations:
+      required: true
+
+  - type: textarea
+    id: steps-to-reproduce
+    attributes:
+      label: Steps to reproduce
+      description: Provide a detailed list of steps that reproduce the issue.
+      placeholder: |
+        1.
+        2.
+        3.
+    validations:
+      required: true
+
+  - type: input
+    id: pixelfed-version
+    attributes:
+      label: Pixelfed version
+      description: What version of Pixelfed are you using?
+      placeholder: ex. 1.2.3
+    validations:
+      required: true
+
+  - type: markdown
+    attributes:
+      value: |
+        ## Additonal information
+
+        Providing as much information as possible greatly helps us with reproducting the issues.
+
+  - type: dropdown
+    id: acknowledgements
+    attributes:
+      label: Acknowledgements
+      description: I searched for similar issues in the repository.
+      options:
+        - 'Yes'
+    validations:
+      required: true
+

+ 2 - 0
.gitignore

@@ -10,6 +10,8 @@
 /.gitconfig
 #/.gitignore
 /.idea
+/.phpunit.cache
+/.phpunit.result.cache
 /.vagrant
 /bootstrap/cache
 /docker-compose-state/

+ 67 - 2
CHANGELOG.md

@@ -1,6 +1,35 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+- Custom Filters ([#5928](https://github.com/pixelfed/pixelfed/pull/5928)) ([437d742ac](https://github.com/pixelfed/pixelfed/commit/437d742ac))
+
+### Updates
+- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
+- Update Profile.vue, fix pagination ([2ea107805](https://github.com/pixelfed/pixelfed/commit/2ea107805))
+- Update ProfileMigrationController, fix race condition by chaining batched jobs ([3001365025](https://github.com/pixelfed/pixelfed/commit/3001365025))
+- Update Instance total post, add optional estimation for huge status tables ([5a5821fe8](https://github.com/pixelfed/pixelfed/commit/5a5821fe8))
+- Update ApiV1Controller, fix notifications favourited/reblogged/bookmarked state. Fixes #5901 ([8a86808a0](https://github.com/pixelfed/pixelfed/commit/8a86808a0))
+- Update ApiV1Controller, fix relationship fields. Fixes #5900 ([245ab3bc4](https://github.com/pixelfed/pixelfed/commit/245ab3bc4))
+- Update instance config, return proper matrix limits. Fixes #4780 ([473201908](https://github.com/pixelfed/pixelfed/commit/473201908))
+- Update SearchApiV2Service, fix offset bug. Fixes #5875 ([0a98b7ad2](https://github.com/pixelfed/pixelfed/commit/0a98b7ad2))
+- Update ApiV1Controller, add better direct error message. Fixes #4789 ([658fe6898](https://github.com/pixelfed/pixelfed/commit/658fe6898))
+- Update DiscoverController, improve public hashtag feed. Fixes #5866 ([32fc3180c](https://github.com/pixelfed/pixelfed/commit/32fc3180c))
+- Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
+- Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
+- Update ReportController, fix type validation ([ccc7f2fc6](https://github.com/pixelfed/pixelfed/commit/ccc7f2fc6))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+
+## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Add app register email verify resends ([dbd1e17](https://github.com/pixelfed/pixelfed/commit/dbd1e17))
+- Add AVIF support ([7ddbe0c47](https://github.com/pixelfed/pixelfed/commit/7ddbe0c47))
+
+### Features
+- WebGL photo filters ([#5374](https://github.com/pixelfed/pixelfed/pull/5374))
 
 ### OAuth
 - Fix oauth oob (urn:ietf:wg:oauth:2.0:oob) support. ([8afbdb03](https://github.com/pixelfed/pixelfed/commit/8afbdb03))
@@ -19,7 +48,43 @@
 - Update StatusStatelessTransformer, refactor the caption field to be compliant with the MastoAPI. Fixes #5364 ([79039ba5](https://github.com/pixelfed/pixelfed/commit/79039ba5))
 - Update mailgun config, add endpoint and scheme ([271d5114](https://github.com/pixelfed/pixelfed/commit/271d5114))
 - Update search and status logic to fix postgres bugs ([8c39ef4](https://github.com/pixelfed/pixelfed/commit/8c39ef4))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
+- Update db, fix sqlite migrations ([#5379](https://github.com/pixelfed/pixelfed/pull/5379))
+- Update CatchUnoptimizedMedia command, make 1hr limit opt-in ([99b15b73](https://github.com/pixelfed/pixelfed/commit/99b15b73))
+- Update IG, fix Instagram import. Closes #5411 ([fd434aec](https://github.com/pixelfed/pixelfed/commit/fd434aec))
+- Update StatusTagsPipeline, fix hashtag bug and formatting ([d516b799](https://github.com/pixelfed/pixelfed/commit/d516b799))
+- Update CollectionController, fix showCollection signature ([4e1dd599](https://github.com/pixelfed/pixelfed/commit/4e1dd599))
+- Update ApiV1Dot1Controller, fix in-app registration ([56f17b99](https://github.com/pixelfed/pixelfed/commit/56f17b99))
+- Update VerifyCsrfToken middleware, add oauth token. Fixes #5426 ([79ebbc2d](https://github.com/pixelfed/pixelfed/commit/79ebbc2d))
+- Update AdminSettingsController, increase max photo size limit from 50MB to 1GB ([aa448354](https://github.com/pixelfed/pixelfed/commit/aa448354))
+- Update BearerTokenResponse, return scopes in /oauth/token endpoint. Fixes #5286 ([d8f5c302](https://github.com/pixelfed/pixelfed/commit/d8f5c302))
+- Update hashtag component, fix missing video thumbnails ([witten](https://github.com/witten)) ([#5427](https://github.com/pixelfed/pixelfed/pull/5427))
+- Update AP Status Transformer, fix inReplyTo. Fixes #5409 ([83cc932f](https://github.com/pixelfed/pixelfed/commit/83cc932f))
+- Update Data Export, refactor following/follower and statuses exports to allow accounts of any size with api entity instead of ap ([0d25917c](https://github.com/pixelfed/pixelfed/commit/0d25917c))
+- Update oauth/token, fix scope to be space separated string instead of array ([4ce6e610](https://github.com/pixelfed/pixelfed/commit/4ce6e610))
+- Update SearchApiV2Service, fix hashtag search ([83c1a7fd](https://github.com/pixelfed/pixelfed/commit/83c1a7fd))
+- Update AP Helpers, fix comment bug ([22eae69f](https://github.com/pixelfed/pixelfed/commit/22eae69f))
+- Update ComposeController, add max_media_attachments attribute ([17918cbe](https://github.com/pixelfed/pixelfed/commit/17918cbe))
+- Fix GroupController, move groups enabled check to each method to fix route:list ([f260572e](https://github.com/pixelfed/pixelfed/commit/f260572e))
+- Update MediaStorageService, handle local media deletes after successful S3 upload ([280f63dc](https://github.com/pixelfed/pixelfed/commit/280f63dc))
+- Update status twitter:card to summary_large_image for images/albums ([9a5a9f55](https://github.com/pixelfed/pixelfed/commit/9a5a9f55))
+- Update CuratedOnboarding, add new app:curated-onboarding command, extend email verification window to 7 days and fix resend verification mails ([49604210](https://github.com/pixelfed/pixelfed/commit/49604210))
+- Update DirectMessageController, fix performance issue ([4ec9f99](https://github.com/pixelfed/pixelfed/commit/4ec9f99))
+- Update App Register to expire codes after 4 hours instead of 60 minutes ([0844094b](https://github.com/pixelfed/pixelfed/commit/0844094b))
+- Update ApiV1Controller, fix max_id pagination on home and public timeline feeds ([38e17a06e](https://github.com/pixelfed/pixelfed/commit/38e17a06e))
+- Update Post component, rewrite local post urls ([d2f2a1b1c](https://github.com/pixelfed/pixelfed/commit/d2f2a1b1c))
+- Update Profile component, rewrite local profile urls ([dfbccaa19](https://github.com/pixelfed/pixelfed/commit/dfbccaa19))
+- Update AccountPostCountStatUpdate, fix memory leak ([134eb6324](https://github.com/pixelfed/pixelfed/commit/134eb6324))
+- Update snowflake config, allow custom datacenter/worker ids ([806e210f1](https://github.com/pixelfed/pixelfed/commit/806e210f1))
+- Update ApiV1Controller, return empty statuses feed for private accounts instead of 403 response ([cce657d9c](https://github.com/pixelfed/pixelfed/commit/cce657d9c))
+- Update DM config, allow new users to send DMs by default, with a new env variable to enforce a 72h limit ([717f17cde](https://github.com/pixelfed/pixelfed/commit/717f17cde))
+- Update ApiV1Controller, add pagination to conversations endpoint with min/max/since id pagination and link header support ([244e86bad](https://github.com/pixelfed/pixelfed/commit/244e86bad))
+- Update Direct message component, fix pagination ([e6ef64857](https://github.com/pixelfed/pixelfed/commit/e6ef64857))
+- Update ActivityPub helpers, improve private account handling ([75e7a678c](https://github.com/pixelfed/pixelfed/commit/75e7a678c))
+- Update ApiV1Controller, improve follower handling ([976a1873e](https://github.com/pixelfed/pixelfed/commit/976a1873e))
+- Update Inbox, improve Accept Follower handling ([3725c689e](https://github.com/pixelfed/pixelfed/commit/3725c689e))
+- Update Inbox handler, add Reject Follow support ([fbe76e37f](https://github.com/pixelfed/pixelfed/commit/fbe76e37f))
+- Update Inbox handler, improve Undo Follow logic ([5525369fe](https://github.com/pixelfed/pixelfed/commit/5525369fe))
+- Update ApiV1Controller, send UndoFollow when cancelling a follow request on remote accounts ([2cf301181](https://github.com/pixelfed/pixelfed/commit/2cf301181))
 
 ## [v0.12.4 (2024-11-08)](https://github.com/pixelfed/pixelfed/compare/v0.12.4...dev)
 

+ 1 - 1
CONTRIBUTING.md

@@ -8,7 +8,7 @@ However, if you file a bug report, your issue should contain a title and a clear
 Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem.
 
 ## Core Development Discussion
-Informal discussion regarding bugs, new features, and implementation of existing features takes place in the ```#pixelfed-dev``` channel on the Freenode IRC network.
+Informal discussion regarding bugs, new features, and implementation of existing features takes place on [discord](https://discord.gg/VDhM32hbUK) or in the [```#pixeldev```](https://matrix.to/#/#pixeldev:matrix.org) channel matrix.
 
 ## Branches
 If you want to contribute to this repository, please file your pull request against the `staging` branch. 

+ 2 - 2
Dockerfile

@@ -28,11 +28,11 @@ ARG DOTTIE_VERSION="v0.9.5"
 ###
 
 # See: https://hub.docker.com/_/php/tags
-ARG PHP_VERSION="8.1"
+ARG PHP_VERSION="8.3"
 
 # See: https://github.com/docker-library/docs/blob/master/php/README.md#image-variants
 ARG PHP_BASE_TYPE="apache"
-ARG PHP_DEBIAN_RELEASE="bullseye"
+ARG PHP_DEBIAN_RELEASE="bookworm"
 
 ARG RUNTIME_UID=33 # often called 'www-data'
 ARG RUNTIME_GID=33 # often called 'www-data'

+ 12 - 0
README.md

@@ -4,6 +4,12 @@
 <a href="https://circleci.com/gh/pixelfed/pixelfed"><img src="https://circleci.com/gh/pixelfed/pixelfed.svg?style=svg" alt="Build Status"></a>
 <a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/v/stable.svg" alt="Latest Stable Version"></a>
 <a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/license.svg" alt="License"></a>
+<a title="Crowdin" target="_blank" href="https://crowdin.com/project/pixelfed"><img src="https://badges.crowdin.net/pixelfed/localized.svg"></a>
+</p>
+
+<p align="center">
+<a href="http://kck.st/4g34fFb"><img src="https://img.shields.io/badge/dynamic/xml?url=https%3A%2F%2Fwww.kickstarter.com%2Fprojects%2Fpixelfed%2Fpixelfed-foundation-2024-real-ethical-social-networks%2Fwidget%2Fcard.html&query=%2F%2Fli%5B%40class%3D'js-amount-pledged'%5D%2F%2Fspan%5B%40class%3D'money'%5D&logo=kickstarter&label=Kickstarter&color=purple" alt="Kickstarter Campaign" /></a>
+<a href="https://fedidb.org/software/pixelfed"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.fedidb.org%2Fv1%2Fsoftware%2Fpixelfed&query=%24.monthly_actives&logo=pixelfed&logoColor=white&label=Monthly%20Active%20Users" alt="Monthly active users from FediDB" /></a>
 </p>
 
 ## Introduction
@@ -45,6 +51,12 @@ Discovery](https://nlnet.nl/discovery/), part of the [Next Generation
 Internet](https://ngi.eu) initiative.
 
 <p>This project is supported by:</p>
+<p>
+  <a href="https://www.fastly.com/fast-forward">
+    <img src="https://github.com/user-attachments/assets/f1499b1f-c05f-480a-a5d5-dbebcb0e20fd">
+  </a>
+</p>
+
 <p>
   <a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=pixelfed">
     <img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">

+ 1 - 1
app/Auth/BearerTokenResponse.php

@@ -11,13 +11,13 @@ class BearerTokenResponse extends \League\OAuth2\Server\ResponseTypes\BearerToke
      * AuthorizationServer::getResponseType() to pull in your version of
      * this class rather than the default.
      *
-     * @param AccessTokenEntityInterface $accessToken
      *
      * @return array
      */
     protected function getExtraParams(AccessTokenEntityInterface $accessToken)
     {
         return [
+            'scope' => implode(' ', array_map(fn ($scope) => $scope->getIdentifier(), $accessToken->getScopes())),
             'created_at' => time(),
         ];
     }

+ 46 - 22
app/Console/Commands/AccountPostCountStatUpdate.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
-use App\Services\AccountService;
+use App\Profile;
 use App\Services\Account\AccountStatService;
+use App\Services\AccountService;
 use App\Status;
-use App\Profile;
+use Illuminate\Console\Command;
 
 class AccountPostCountStatUpdate extends Command
 {
@@ -29,29 +29,53 @@ class AccountPostCountStatUpdate extends Command
      */
     public function handle()
     {
-        $ids = AccountStatService::getAllPostCountIncr();
-        if(!$ids || !count($ids)) {
+        $chunkSize = 100;
+        $lastId = 0;
+
+        while (true) {
+            $ids = AccountStatService::getPostCountChunk($lastId, $chunkSize);
+
+            if (empty($ids)) {
+                break;
+            }
+
+            foreach ($ids as $id) {
+                $this->processAccount($id);
+                $lastId = $id;
+            }
+
+            if (function_exists('gc_collect_cycles')) {
+                gc_collect_cycles();
+            }
+        }
+
+        return 0;
+    }
+
+    private function processAccount($id)
+    {
+        $acct = AccountService::get($id, true);
+        if (! $acct) {
+            AccountStatService::removeFromPostCount($id);
+
             return;
         }
-        foreach($ids as $id) {
-            $acct = AccountService::get($id, true);
-            if(!$acct) {
+
+        $statusCount = Status::whereProfileId($id)->count();
+        if ($statusCount != $acct['statuses_count']) {
+            $profile = Profile::find($id);
+            if (! $profile) {
                 AccountStatService::removeFromPostCount($id);
-                continue;
-            }
-            $statusCount = Status::whereProfileId($id)->count();
-            if($statusCount != $acct['statuses_count']) {
-                $profile = Profile::find($id);
-                if(!$profile) {
-                    AccountStatService::removeFromPostCount($id);
-                    continue;
-                }
-                $profile->status_count = $statusCount;
-                $profile->save();
-                AccountService::del($id);
+
+                return;
             }
-            AccountStatService::removeFromPostCount($id);
+
+            $profile->status_count = $statusCount;
+            $profile->save();
+
+            AccountService::del($id);
         }
-        return;
+
+        AccountStatService::removeFromPostCount($id);
     }
 }

+ 5 - 3
app/Console/Commands/CatchUnoptimizedMedia.php

@@ -40,10 +40,11 @@ class CatchUnoptimizedMedia extends Command
      */
     public function handle()
     {
+        $hasLimit = (bool) config('media.image_optimize.catch_unoptimized_media_hour_limit');
         Media::whereNull('processed_at')
-            ->where('created_at', '>', now()->subHours(1))
-            ->where('skip_optimize', '!=', true)
-            ->whereNull('remote_url')
+            ->when($hasLimit, function($q, $hasLimit) {
+                $q->where('created_at', '>', now()->subHours(1));
+            })->whereNull('remote_url')
             ->whereNotNull('status_id')
             ->whereNotNull('media_path')
             ->whereIn('mime', [
@@ -52,6 +53,7 @@ class CatchUnoptimizedMedia extends Command
             ])
             ->chunk(50, function($medias) {
                 foreach ($medias as $media) {
+					if ($media->skip_optimize) continue;
                     ImageOptimize::dispatch($media);
                 }
             });

+ 170 - 0
app/Console/Commands/CuratedOnboardingCommand.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Mail\CuratedRegisterConfirmEmail;
+use App\Models\CuratedRegister;
+use App\Models\CuratedRegisterActivity;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\search;
+use function Laravel\Prompts\select;
+use function Laravel\Prompts\table;
+
+class CuratedOnboardingCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:curated-onboarding';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Manage curated onboarding applications';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $this->line(' ');
+        $this->info('   Welcome to the Curated Onboarding manager');
+        $this->line(' ');
+
+        $action = select(
+            label: 'Select an action:',
+            options: ['Stats', 'Edit'],
+            default: 'Stats',
+            hint: 'You can manage this via the admin dashboard.'
+        );
+
+        switch ($action) {
+            case 'Stats':
+                return $this->stats();
+                break;
+
+            case 'Edit':
+                return $this->edit();
+                break;
+
+            default:
+                exit;
+                break;
+        }
+    }
+
+    protected function stats()
+    {
+        $total = CuratedRegister::count();
+        $approved = CuratedRegister::whereIsApproved(true)->whereIsRejected(false)->whereNotNull('email_verified_at')->count();
+        $awaitingMoreInfo = CuratedRegister::whereIsAwaitingMoreInfo(true)->whereIsRejected(false)->whereIsClosed(false)->whereNotNull('email_verified_at')->count();
+        $open = CuratedRegister::whereIsApproved(false)->whereIsRejected(false)->whereIsClosed(false)->whereNotNull('email_verified_at')->whereIsAwaitingMoreInfo(false)->count();
+        $nonVerified = CuratedRegister::whereIsApproved(false)->whereIsRejected(false)->whereIsClosed(false)->whereNull('email_verified_at')->whereIsAwaitingMoreInfo(false)->count();
+        table(
+            ['Total', 'Approved', 'Open', 'Awaiting More Info', 'Unverified Emails'],
+            [
+                [$total, $approved, $open, $awaitingMoreInfo, $nonVerified],
+            ]
+        );
+    }
+
+    protected function edit()
+    {
+        $id = search(
+            label: 'Search for a username or email',
+            options: fn (string $value) => strlen($value) > 0
+                ? CuratedRegister::where(function ($query) use ($value) {
+                    $query->whereLike('username', "%{$value}%")
+                        ->orWhereLike('email', "%{$value}%");
+                })->get()
+                    ->mapWithKeys(fn ($user) => [
+                      $user->id => "{$user->username} ({$user->email})",
+                  ])
+                    ->all()
+                : []
+        );
+
+        $register = CuratedRegister::findOrFail($id);
+        if ($register->is_approved) {
+            $status = 'Approved';
+        } elseif ($register->is_rejected) {
+            $status = 'Rejected';
+        } elseif ($register->is_closed) {
+            $status = 'Closed';
+        } elseif ($register->is_awaiting_more_info) {
+            $status = 'Awaiting more info';
+        } elseif ($register->user_has_responded) {
+            $status = 'Awaiting Admin Response';
+        } else {
+            $status = 'Unknown';
+        }
+        table(
+            ['Field', 'Value'],
+            [
+                ['ID', $register->id],
+                ['Username', $register->username],
+                ['Email', $register->email],
+                ['Status', $status],
+                ['Created At', $register->created_at->format('Y-m-d H:i')],
+                ['Updated At', $register->updated_at->format('Y-m-d H:i')],
+            ]
+        );
+        if (in_array($status, ['Approved', 'Rejected', 'Closed'])) {
+            return;
+        }
+
+        $options = ['Cancel', 'Delete'];
+
+        if ($register->email_verified_at == null) {
+            $options[] = 'Resend Email Verification';
+        }
+
+        $action = select(
+            label: 'Select an action:',
+            options: $options,
+            default: 'Cancel',
+        );
+
+        if ($action === 'Resend Email Verification') {
+            $confirmed = confirm('Are you sure you want to send another email to '.$register->email.' ?');
+
+            if (! $confirmed) {
+                $this->error('Aborting...');
+                exit;
+            }
+
+            DB::transaction(function () use ($register) {
+                $register->verify_code = Str::random(40);
+                $register->created_at = now();
+                $register->save();
+                Mail::to($register->email)->send(new CuratedRegisterConfirmEmail($register));
+                $this->info('Mail sent!');
+            });
+        } elseif ($action === 'Delete') {
+            $confirmed = confirm('Are you sure you want to delete the application from '.$register->email.' ?');
+
+            if (! $confirmed) {
+                $this->error('Aborting...');
+                exit;
+            }
+
+            DB::transaction(function () use ($register) {
+                CuratedRegisterActivity::whereRegisterId($register->id)->delete();
+                $register->delete();
+                $this->info('Successfully deleted!');
+            });
+        } else {
+            $this->info('Cancelled.');
+            exit;
+        }
+    }
+}

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

@@ -90,7 +90,7 @@ class FixUsernames extends Command
                         break;
 
                     case $opts[1]:
-                        $new = filter_var($old, FILTER_SANITIZE_STRING|FILTER_FLAG_STRIP_LOW);
+                        $new = htmlspecialchars($old, ENT_QUOTES, 'UTF-8');
                         if(strlen($new) < 6) {
                             $new = $new . '_' . str_random(4);
                         }

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

@@ -74,7 +74,6 @@ class ImportCities extends Command
      */
     public function handle()
     {
-        $old_memory_limit = ini_get('memory_limit');
         ini_set('memory_limit', '256M');
         $path = storage_path('app/cities.json');
 
@@ -137,7 +136,6 @@ class ImportCities extends Command
         $this->line('');
         $this->info('Successfully imported ' . $cityCount . ' entries!');
         $this->line('');
-        ini_set('memory_limit', $old_memory_limit);
         return;
     }
 

+ 15 - 4
app/Console/Commands/InstanceUpdateTotalLocalPosts.php

@@ -53,9 +53,8 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function initCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
         ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
@@ -68,12 +67,24 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function updateAndCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         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 getTotalLocalPosts()
+    {
+        if ((bool) config('instance.total_count_estimate') && config('database.default') === 'mysql') {
+            return DB::select("EXPLAIN SELECT COUNT(*) FROM statuses WHERE deleted_at IS NULL AND uri IS NULL and local = 1 AND type != 'share'")[0]->rows;
+        }
+
+        return DB::table('statuses')
+            ->whereNull('deleted_at')
+            ->where('local', true)
+            ->whereNot('type', 'share')
+            ->count();
+    }
 }

+ 113 - 0
app/Console/Commands/Localization.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class Localization extends Command
+{
+    protected $signature = 'localization:generate';
+
+    protected $description = 'Generate JSON files for all available localizations';
+
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    public function handle()
+    {
+        $languages = $this->discoverLangs();
+
+        foreach ($languages as $lang) {
+            $this->info("Processing {$lang} translations...");
+            $this->buildTranslations($lang);
+        }
+
+        $this->info('All language files have been processed successfully!');
+    }
+
+    protected function buildTranslations(string $lang)
+    {
+        $path = base_path("resources/lang/{$lang}");
+        $keys = [];
+        $kcount = 0;
+
+        if (! File::isDirectory($path)) {
+            $this->error("Directory not found: {$path}");
+
+            return;
+        }
+
+        foreach (new \DirectoryIterator($path) as $io) {
+            if ($io->isDot() || ! $io->isFile()) {
+                continue;
+            }
+
+            $key = $io->getBasename('.php');
+            try {
+                $translations = __($key, [], $lang);
+                $keys[$key] = [];
+
+                foreach ($translations as $k => $str) {
+                    $keys[$key][$k] = $str;
+                    $kcount++;
+                }
+
+                ksort($keys[$key]);
+            } catch (\Exception $e) {
+                $this->warn("Failed to process {$lang}/{$key}.php: {$e->getMessage()}");
+            }
+        }
+
+        $result = $this->prepareOutput($keys, $kcount);
+        $this->saveTranslations($result, $lang);
+    }
+
+    protected function prepareOutput(array $keys, int $keyCount): array
+    {
+        $output = $keys;
+        $hash = hash('sha256', json_encode($output));
+
+        $output['_meta'] = [
+            'key_count' => $keyCount,
+            'generated' => now()->toAtomString(),
+            'hash_sha256' => $hash,
+        ];
+
+        ksort($output);
+
+        return $output;
+    }
+
+    protected function saveTranslations(array $translations, string $lang)
+    {
+        $directory = public_path('_lang');
+        if (! File::isDirectory($directory)) {
+            File::makeDirectory($directory, 0755, true);
+        }
+
+        $filename = "{$directory}/{$lang}.json";
+        $contents = json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+        File::put($filename, $contents);
+        $this->info("Generated {$lang}.json");
+    }
+
+    protected function discoverLangs(): array
+    {
+        $path = base_path('resources/lang');
+        $languages = [];
+
+        foreach (new \DirectoryIterator($path) as $io) {
+            $name = $io->getFilename();
+
+            if (! $io->isDot() && $io->isDir() && $name !== 'vendor') {
+                $languages[] = $name;
+            }
+        }
+
+        return $languages;
+    }
+}

+ 127 - 0
app/Console/Commands/MediaReplaceDomainCommand.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Media;
+use App\Services\MediaService;
+use App\Services\StatusService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class MediaReplaceDomainCommand extends Command
+{
+    protected $signature = 'media:replacedomain {--original= : Original domain to replace} {--new= : New domain to use}';
+
+    protected $description = 'Replace CDN domain in media URLs and clear associated caches';
+
+    public function handle()
+    {
+        $originalDomain = $this->option('original');
+        $newDomain = $this->option('new');
+
+        if (! $originalDomain || ! $newDomain) {
+            $this->error('Both --original and --new options are required');
+
+            return 1;
+        }
+
+        if (! str_starts_with($originalDomain, 'https://')) {
+            $this->error('Original domain must start with https://');
+
+            return 1;
+        }
+
+        if (! str_starts_with($newDomain, 'https://')) {
+            $this->error('New domain must start with https://');
+
+            return 1;
+        }
+
+        $originalDomain = rtrim($originalDomain, '/');
+        $newDomain = rtrim($newDomain, '/');
+
+        if (preg_match('/[^a-zA-Z0-9\-\._\/:]/', $originalDomain) ||
+            preg_match('/[^a-zA-Z0-9\-\._\/:]/', $newDomain)) {
+            $this->error('Domains contain invalid characters');
+
+            return 1;
+        }
+
+        $sampleMedia = Media::where('cdn_url', 'LIKE', $originalDomain.'%')->first();
+
+        if (! $sampleMedia) {
+            $this->error('No media entries found with the specified domain.');
+
+            return 1;
+        }
+
+        $sampleNewUrl = str_replace($originalDomain, $newDomain, $sampleMedia->cdn_url);
+
+        $this->info('Please verify this URL transformation:');
+        $this->newLine();
+        $this->info('Original URL:');
+        $this->line($sampleMedia->cdn_url);
+        $this->info('Will be changed to:');
+        $this->line($sampleNewUrl);
+        $this->newLine();
+        $this->info('Please verify in your browser that both URLs are accessible.');
+
+        if (! $this->confirm('Do you want to proceed with the replacement?')) {
+            $this->info('Operation cancelled.');
+
+            return 0;
+        }
+
+        $query = Media::where('cdn_url', 'LIKE', $originalDomain.'%');
+        $count = $query->count();
+
+        $this->info("Found {$count} media entries to update.");
+
+        $bar = $this->output->createProgressBar($count);
+        $errors = [];
+
+        $query->chunkById(1000, function ($medias) use ($originalDomain, $newDomain, $bar, &$errors) {
+            foreach ($medias as $media) {
+                try {
+                    if (! str_starts_with($media->cdn_url, 'https://')) {
+                        $errors[] = "Media ID {$media->id} has invalid URL format: {$media->cdn_url}";
+                        $bar->advance();
+
+                        continue;
+                    }
+
+                    DB::transaction(function () use ($media, $originalDomain, $newDomain) {
+                        $media->cdn_url = str_replace($originalDomain, $newDomain, $media->cdn_url);
+                        $media->save();
+
+                        if ($media->status_id) {
+                            MediaService::del($media->status_id);
+                            StatusService::del($media->status_id);
+                        }
+                    });
+                    $bar->advance();
+                } catch (\Exception $e) {
+                    $errors[] = "Failed to update Media ID {$media->id}: {$e->getMessage()}";
+                    $bar->advance();
+                }
+            }
+        });
+
+        $bar->finish();
+        $this->newLine();
+
+        if (! empty($errors)) {
+            $this->newLine();
+            $this->warn('Completed with errors:');
+            foreach ($errors as $error) {
+                $this->error($error);
+            }
+
+            return 1;
+        }
+
+        $this->info('Domain replacement completed successfully.');
+
+        return 0;
+    }
+}

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

@@ -51,8 +51,6 @@ class PushGatewayRefresh extends Command
                 $recheck = NotificationAppGatewayService::forceSupportRecheck();
                 if ($recheck) {
                     $this->info('Success! Push Notifications are now active!');
-                    PushNotificationService::warmList('like');
-
                     return;
                 } else {
                     $this->error('Error, please ensure you have a valid API key.');

+ 85 - 0
app/Console/Commands/ReclaimUsername.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\User;
+use App\Profile;
+use Illuminate\Console\Command;
+use function Laravel\Prompts\search;
+use function Laravel\Prompts\text;
+use function Laravel\Prompts\confirm;
+
+class ReclaimUsername extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:reclaim-username';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Force delete a user and their profile to reclaim a username';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $username = search(
+            label: 'What username would you like to reclaim?',
+            options: fn (string $search) => strlen($search) > 0  ? $this->getUsernameOptions($search) : [],
+            required: true
+        );
+
+        $user = User::whereUsername($username)->withTrashed()->first();
+        $profile = Profile::whereUsername($username)->withTrashed()->first();
+
+        if (!$user && !$profile) {
+            $this->error("No user or profile found with username: {$username}");
+            return Command::FAILURE;
+        }
+
+        if ($user->delete_after === null || $user->status !== 'deleted') {
+            $this->error("Cannot reclaim an active account: {$username}");
+            return Command::FAILURE;
+        }
+
+        $confirm = confirm(
+            label: "Are you sure you want to force delete user and profile with username: {$username}?",
+            default: false
+        );
+
+        if (!$confirm) {
+            $this->info('Operation cancelled.');
+            return Command::SUCCESS;
+        }
+
+        if ($user) {
+            $user->forceDelete();
+            $this->info("User {$username} has been force deleted.");
+        }
+
+        if ($profile) {
+            $profile->forceDelete();
+            $this->info("Profile {$username} has been force deleted.");
+        }
+
+        $this->info('Username reclaimed successfully!');
+        return Command::SUCCESS;
+    }
+
+    private function getUsernameOptions(string $search = ''): array
+    {
+        return User::where('username', 'like', "{$search}%")
+            ->withTrashed()
+            ->whereNotNull('delete_after')
+            ->take(10)
+            ->pluck('username')
+            ->toArray();
+    }
+}

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

@@ -38,7 +38,7 @@ class TransformImports extends Command
             return;
         }
 
-        $ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(500)->get();
+        $ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(1500)->get();
 
         if (! $ips->count()) {
             return;
@@ -103,17 +103,17 @@ class TransformImports extends Command
                 continue;
             }
 
-            $caption = $ip->caption;
+            $caption = $ip->caption ?? "";
             $status = new Status;
             $status->profile_id = $pid;
             $status->caption = $caption;
             $status->type = $ip->post_type;
 
-            $status->scope = 'unlisted';
-            $status->visibility = 'unlisted';
+            $status->scope = 'public';
+            $status->visibility = 'public';
             $status->id = $idk['id'];
             $status->created_at = now()->parse($ip->creation_date);
-            $status->save();
+            $status->saveQuietly();
 
             foreach ($ip->media as $ipm) {
                 $fileName = last(explode('/', $ipm['uri']));

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

@@ -100,7 +100,7 @@ class UserAccountDelete extends Command
                             CURLOPT_HTTPHEADER => $headers,
                             CURLOPT_POSTFIELDS => $payload,
                             CURLOPT_HEADER => true,
-                            CURLOPT_SSL_VERIFYPEER => false,
+                            CURLOPT_SSL_VERIFYPEER => true,
                             CURLOPT_SSL_VERIFYHOST => false,
                         ],
                     ]);

+ 1 - 1
app/Console/Kernel.php

@@ -38,7 +38,7 @@ class Kernel extends ConsoleKernel
         }
 
         if (config('import.instagram.enabled')) {
-            $schedule->command('app:transform-imports')->everyTenMinutes()->onOneServer();
+            $schedule->command('app:transform-imports')->twiceDaily(13, 22)->onOneServer();
             $schedule->command('app:import-upload-garbage-collection')->hourlyAt(51)->onOneServer();
             $schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37)->onOneServer();
             $schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32)->onOneServer();

+ 1 - 1
app/HashtagFollow.php

@@ -12,7 +12,7 @@ class HashtagFollow extends Model
     	'hashtag_id'
     ];
 
-    const MAX_LIMIT = 250;
+    const MAX_LIMIT = 25;
 
     public function hashtag()
     {

File diff suppressed because it is too large
+ 563 - 563
app/Http/Controllers/AccountController.php


+ 11 - 2
app/Http/Controllers/Admin/AdminSettingsController.php

@@ -70,6 +70,7 @@ trait AdminSettingsController
             'type_gif' => 'nullable',
             'type_mp4' => 'nullable',
             'type_webp' => 'nullable',
+            'type_avif' => 'nullable',
             'admin_account_id' => 'nullable',
             'regs' => 'required|in:open,filtered,closed',
             'account_migration' => 'nullable',
@@ -128,6 +129,7 @@ trait AdminSettingsController
             'type_gif' => 'image/gif',
             'type_mp4' => 'video/mp4',
             'type_webp' => 'image/webp',
+            'type_avif' => 'image/avif',
         ];
 
         foreach ($mimes as $key => $value) {
@@ -600,7 +602,7 @@ trait AdminSettingsController
         $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',
+            'max_photo_size' => 'required|integer|min:100|max:1000000',
             'media_types' => 'required',
             'optimize_image' => 'required',
             'optimize_video' => 'required',
@@ -609,7 +611,7 @@ trait AdminSettingsController
         $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'])) {
+            if (! in_array($mediaType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'image/avif'])) {
                 return redirect()->back()->withErrors(['media_types' => 'Invalid media type']);
             }
         }
@@ -876,6 +878,13 @@ trait AdminSettingsController
                 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);
                 }
+
+                ConfigCacheService::put($dkey . 'key', $key);
+                ConfigCacheService::put($dkey . 'secret', $secret);
+                ConfigCacheService::put($dkey . 'region', $region);
+                ConfigCacheService::put($dkey . 'bucket', $bucket);
+                ConfigCacheService::put($dkey . 'endpoint', $endpoint);
+                ConfigCacheService::put($dkey . 'url', $url);
             }
             $res['changes'] = json_encode($changes);
         }

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

@@ -64,7 +64,7 @@ class AdminInviteController extends Controller
 		$usernameRules = [
 			'required',
 			'min:2',
-			'max:15',
+			'max:30',
 			'unique:users',
 			function ($attribute, $value, $fail) {
 				$dash = substr_count($value, '-');
@@ -152,7 +152,7 @@ class AdminInviteController extends Controller
 			'username' => [
 				'required',
 				'min:2',
-				'max:15',
+				'max:30',
 				'unique:users',
 				function ($attribute, $value, $fail) {
 					$dash = substr_count($value, '-');

+ 371 - 59
app/Http/Controllers/Api/ApiV1Controller.php

@@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\MediaPipeline\MediaDeletePipeline;
 use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Jobs\SharePipeline\SharePipeline;
 use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -34,6 +35,7 @@ use App\Jobs\VideoPipeline\VideoThumbnail;
 use App\Like;
 use App\Media;
 use App\Models\Conversation;
+use App\Models\CustomFilter;
 use App\Notification;
 use App\Profile;
 use App\Services\AccountService;
@@ -137,7 +139,10 @@ class ApiV1Controller extends Controller
             'redirect_uris' => 'required',
         ]);
 
-        $uris = implode(',', explode('\n', $request->redirect_uris));
+        $uris = collect(explode("\n", $request->redirect_uris))
+            ->map('urldecode')
+            ->filter()
+            ->join(',');
 
         $client = Passport::client()->forceFill([
             'user_id' => null,
@@ -744,7 +749,7 @@ class ApiV1Controller extends Controller
         } elseif ($profile['locked']) {
             $following = FollowerService::follows($pid, $profile['id']);
             if (! $following) {
-                return response('', 403);
+                return response()->json([]);
             }
             $visibility = ['public', 'unlisted', 'private'];
         } else {
@@ -760,7 +765,8 @@ class ApiV1Controller extends Controller
             'reblog_of_id',
             'type',
             'id',
-            'scope'
+            'scope',
+            'pinned_order'
         )
             ->whereProfileId($profile['id'])
             ->whereNull('in_reply_to_id')
@@ -810,13 +816,13 @@ class ApiV1Controller extends Controller
         abort_unless($request->user()->tokenCan('follow'), 403);
 
         $user = $request->user();
+        abort_if($user->profile_id == $id, 400, 'Invalid profile');
+
         abort_if($user->has_roles && ! UserRoleService::can('can-follow', $user->id), 403, 'Invalid permissions for this action');
 
         AccountService::setLastActive($user->id);
 
-        $target = Profile::where('id', '!=', $user->profile_id)
-            ->whereNull('status')
-            ->findOrFail($id);
+        $target = Profile::whereNull('status')->findOrFail($id);
 
         abort_if($target && $target->moved_to_profile_id, 400, 'Cannot follow an account that has moved!');
 
@@ -861,15 +867,20 @@ class ApiV1Controller extends Controller
             if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
                 (new FollowerController)->sendFollow($user->profile, $target);
             }
-        } else {
-            $follower = Follower::firstOrCreate([
-                'profile_id' => $user->profile_id,
+        } elseif ($remote == true) {
+            $follow = FollowRequest::firstOrCreate([
+                'follower_id' => $user->profile_id,
                 'following_id' => $target->id,
             ]);
 
-            if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
+            if (config('federation.activitypub.remoteFollow') == true) {
                 (new FollowerController)->sendFollow($user->profile, $target);
             }
+        } else {
+            $follower = Follower::firstOrCreate([
+                'profile_id' => $user->profile_id,
+                'following_id' => $target->id,
+            ]);
             FollowPipeline::dispatch($follower)->onQueue('high');
         }
 
@@ -906,10 +917,11 @@ class ApiV1Controller extends Controller
 
         $user = $request->user();
 
+        abort_if($user->profile_id == $id, 400, 'Invalid profile');
+
         AccountService::setLastActive($user->id);
 
-        $target = Profile::where('id', '!=', $user->profile_id)
-            ->whereNull('status')
+        $target = Profile::whereNull('status')
             ->findOrFail($id);
 
         $private = (bool) $target->is_private;
@@ -926,6 +938,9 @@ class ApiV1Controller extends Controller
             if ($followRequest) {
                 $followRequest->delete();
                 RelationshipService::refresh($target->id, $user->profile_id);
+                if ($target->domain) {
+                    UnfollowPipeline::dispatch($user->profile_id, $target->id)->onQueue('high');
+                }
             }
             $resource = new Fractal\Resource\Item($target, new RelationshipTransformer);
             $res = $this->fractal->createData($resource)->toArray();
@@ -1525,7 +1540,7 @@ class ApiV1Controller extends Controller
 
         $user = $request->user();
 
-        $res = FollowRequest::whereFollowingId($user->profile->id)
+        $res = FollowRequest::whereFollowingId($user->profile_id)
             ->limit($request->input('limit', 40))
             ->pluck('follower_id')
             ->map(function ($id) {
@@ -1717,13 +1732,14 @@ class ApiV1Controller extends Controller
                 'approval_required' => (bool) config_cache('instance.curated_registration.enabled'),
                 'contact_account' => $contact,
                 'rules' => $rules,
+                'mobile_registration' => (bool) config_cache('pixelfed.open_registration') && config('auth.in_app_registration'),
                 'configuration' => [
                     'media_attachments' => [
-                        'image_matrix_limit' => 16777216,
+                        'image_matrix_limit' => 2073600,
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'video_frame_rate_limit' => 120,
-                        'video_matrix_limit' => 2304000,
+                        'video_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                     ],
                     'polls' => [
@@ -1893,6 +1909,8 @@ class ApiV1Controller extends Controller
         switch ($media->mime) {
             case 'image/jpeg':
             case 'image/png':
+            case 'image/webp':
+            case 'image/avif':
                 ImageOptimize::dispatch($media)->onQueue('mmo');
                 break;
 
@@ -2121,6 +2139,8 @@ class ApiV1Controller extends Controller
         switch ($media->mime) {
             case 'image/jpeg':
             case 'image/png':
+            case 'image/webp':
+            case 'image/avif':
                 ImageOptimize::dispatch($media)->onQueue('mmo');
                 break;
 
@@ -2370,7 +2390,7 @@ class ApiV1Controller extends Controller
         if (empty($res)) {
             if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
                 Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-                NotificationService::warmCache($pid, 400, true);
+                NotificationWarmUserCache::dispatch($pid);
             }
         }
 
@@ -2422,6 +2442,15 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($n) use ($pid) {
+                if (isset($n['status'])) {
+                    $n['status']['favourited'] = (bool) LikeService::liked($pid, $n['status']['id']);
+                    $n['status']['reblogged'] = (bool) ReblogService::get($pid, $n['status']['id']);
+                    $n['status']['bookmarked'] = (bool) BookmarkService::get($pid, $n['status']['id']);
+                }
+
+                return $n;
+            })
             ->filter(function ($n) use ($types) {
                 if (! $types) {
                     return true;
@@ -2486,6 +2515,14 @@ class ApiV1Controller extends Controller
         ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
         AccountService::setLastActive($request->user()->id);
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('home', $filter->context);
+        });
+
         if (config('exp.cached_home_timeline')) {
             $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
             if ($min || $max) {
@@ -2522,6 +2559,23 @@ class ApiV1Controller extends Controller
                 ->filter(function ($s) use ($includeReblogs) {
                     return $includeReblogs ? true : $s['reblog'] == null;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->map(function ($status) use ($pid) {
                     if ($pid) {
@@ -2546,7 +2600,7 @@ class ApiV1Controller extends Controller
                 $minId = null;
             }
 
-            if ($maxId) {
+            if ($maxId && $res->count() >= $limit) {
                 $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
             }
 
@@ -2630,6 +2684,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         } else {
@@ -2684,6 +2755,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         }
@@ -2745,7 +2833,7 @@ class ApiV1Controller extends Controller
             $limit = 40;
         }
         $user = $request->user();
-
+        $pid = $user->profile_id;
         $remote = $request->has('remote') && $request->boolean('remote');
         $local = $request->boolean('local');
         $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
@@ -2758,6 +2846,14 @@ class ApiV1Controller extends Controller
         $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
         $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
         $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('public', $filter->context);
+        });
         if ($local && $remote) {
             $feed = Status::select(
                 'id',
@@ -2948,6 +3044,23 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($status) use ($homeFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values();
 
@@ -2969,7 +3082,7 @@ class ApiV1Controller extends Controller
             $minId = null;
         }
 
-        if ($maxId) {
+        if ($maxId && $res->count() >= $limit) {
             $link = '<'.$baseUrl.'max_id='.$minId.'>; rel="next"';
         }
 
@@ -2999,72 +3112,143 @@ class ApiV1Controller extends Controller
         abort_unless($request->user()->tokenCan('read'), 403);
 
         $this->validate($request, [
-            'limit' => 'min:1|max:40',
+            'limit' => 'sometimes|integer|min:1|max:40',
             'scope' => 'nullable|in:inbox,sent,requests',
+            'min_id' => 'nullable|integer',
+            'max_id' => 'nullable|integer',
+            'since_id' => 'nullable|integer',
         ]);
 
         $limit = $request->input('limit', 20);
+        if ($limit > 20) {
+            $limit = 20;
+        }
         $scope = $request->input('scope', 'inbox');
         $user = $request->user();
+        $min_id = $request->input('min_id');
+        $max_id = $request->input('max_id');
+        $since_id = $request->input('since_id');
+
         if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
             return [];
         }
+
         $pid = $user->profile_id;
 
-        if (config('database.default') == 'pgsql') {
-            $dms = DirectMessage::when($scope === 'inbox', function ($q, $scope) use ($pid) {
-                return $q->whereIsHidden(false)->where('to_id', $pid)->orWhere('from_id', $pid);
+        $isPgsql = config('database.default') == 'pgsql';
+
+        if ($isPgsql) {
+            $dms = DirectMessage::when($scope === 'inbox', function ($q) use ($pid) {
+                return $q->whereIsHidden(false)
+                    ->where(function ($query) use ($pid) {
+                        $query->where('to_id', $pid)
+                            ->orWhere('from_id', $pid);
+                    });
             })
-                ->when($scope === 'sent', function ($q, $scope) use ($pid) {
-                    return $q->whereFromId($pid)->groupBy(['to_id', 'id']);
+                ->when($scope === 'sent', function ($q) use ($pid) {
+                    return $q->whereFromId($pid)
+                        ->groupBy(['to_id', 'id']);
                 })
-                ->when($scope === 'requests', function ($q, $scope) use ($pid) {
-                    return $q->whereToId($pid)->whereIsHidden(true);
+                ->when($scope === 'requests', function ($q) use ($pid) {
+                    return $q->whereToId($pid)
+                        ->whereIsHidden(true);
                 });
         } else {
-            $dms = Conversation::when($scope === 'inbox', function ($q, $scope) use ($pid) {
+            $dms = Conversation::when($scope === 'inbox', function ($q) use ($pid) {
                 return $q->whereIsHidden(false)
-                    ->where('to_id', $pid)
-                    ->orWhere('from_id', $pid)
+                    ->where(function ($query) use ($pid) {
+                        $query->where('to_id', $pid)
+                            ->orWhere('from_id', $pid);
+                    })
                     ->orderByDesc('status_id')
                     ->groupBy(['to_id', 'from_id']);
             })
-                ->when($scope === 'sent', function ($q, $scope) use ($pid) {
-                    return $q->whereFromId($pid)->groupBy('to_id');
+                ->when($scope === 'sent', function ($q) use ($pid) {
+                    return $q->whereFromId($pid)
+                        ->groupBy('to_id');
                 })
-                ->when($scope === 'requests', function ($q, $scope) use ($pid) {
-                    return $q->whereToId($pid)->whereIsHidden(true);
+                ->when($scope === 'requests', function ($q) use ($pid) {
+                    return $q->whereToId($pid)
+                        ->whereIsHidden(true);
                 });
         }
 
-        $dms = $dms->orderByDesc('status_id')
-            ->simplePaginate($limit)
-            ->map(function ($dm) use ($pid) {
-                $from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id;
-                $res = [
-                    'id' => $dm->id,
-                    'unread' => false,
-                    'accounts' => [
-                        AccountService::getMastodon($from, true),
-                    ],
-                    'last_status' => StatusService::getDirectMessage($dm->status_id),
-                ];
+        if ($min_id) {
+            $dms = $dms->where('id', '>', $min_id);
+        }
+        if ($max_id) {
+            $dms = $dms->where('id', '<', $max_id);
+        }
+        if ($since_id) {
+            $dms = $dms->where('id', '>', $since_id);
+        }
 
-                return $res;
-            })
-            ->filter(function ($dm) {
-                if (! $dm || empty($dm['last_status']) || ! isset($dm['accounts']) || ! count($dm['accounts']) || ! isset($dm['accounts'][0]) || ! isset($dm['accounts'][0]['id'])) {
-                    return false;
-                }
+        $dms = $dms->orderByDesc('status_id')->orderBy('id');
 
-                return true;
+        $dmResults = $dms->limit($limit + 1)->get();
+
+        $hasNextPage = $dmResults->count() > $limit;
+
+        if ($hasNextPage) {
+            $dmResults = $dmResults->take($limit);
+        }
+
+        $transformedDms = $dmResults->map(function ($dm) use ($pid) {
+            $from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id;
+
+            return [
+                'id' => $dm->id,
+                'unread' => false,
+                'accounts' => [
+                    AccountService::getMastodon($from, true),
+                ],
+                'last_status' => StatusService::getDirectMessage($dm->status_id),
+            ];
+        })
+            ->filter(function ($dm) {
+                return $dm
+                    && ! empty($dm['last_status'])
+                    && isset($dm['accounts'])
+                    && count($dm['accounts'])
+                    && isset($dm['accounts'][0])
+                    && isset($dm['accounts'][0]['id']);
             })
-            ->unique(function ($item, $key) {
+            ->unique(function ($item) {
                 return $item['accounts'][0]['id'];
             })
             ->values();
 
-        return $this->json($dms);
+        $links = [];
+
+        if (! $transformedDms->isEmpty()) {
+            $baseUrl = url()->current().'?'.http_build_query(array_merge(
+                $request->except(['min_id', 'max_id', 'since_id']),
+                ['limit' => $limit]
+            ));
+
+            $firstId = $transformedDms->first()['id'];
+            $lastId = $transformedDms->last()['id'];
+
+            $firstLink = $baseUrl;
+            $links[] = '<'.$firstLink.'>; rel="first"';
+
+            if ($hasNextPage) {
+                $nextLink = $baseUrl.'&max_id='.$lastId;
+                $links[] = '<'.$nextLink.'>; rel="next"';
+            }
+
+            if ($max_id || $since_id) {
+                $prevLink = $baseUrl.'&min_id='.$firstId;
+                $links[] = '<'.$prevLink.'>; rel="prev"';
+            }
+        }
+
+        if (! empty($links)) {
+            return response()->json($transformedDms->toArray())
+                ->header('Link', implode(', ', $links));
+        }
+
+        return $this->json($transformedDms);
     }
 
     /**
@@ -3426,13 +3610,19 @@ class ApiV1Controller extends Controller
             'in_reply_to_id' => 'nullable',
             'media_ids' => 'sometimes|array|max:'.(int) config_cache('pixelfed.max_album_length'),
             'sensitive' => 'nullable',
-            'visibility' => 'string|in:private,unlisted,public',
+            'visibility' => 'string|in:private,unlisted,public,direct',
             'spoiler_text' => 'sometimes|max:140',
             'place_id' => 'sometimes|integer|min:1|max:128769',
             'collection_ids' => 'sometimes|array|max:3',
             'comments_disabled' => 'sometimes|boolean',
         ]);
 
+        if ($request->filled('visibility') && $request->input('visibility') === 'direct') {
+            return $this->json([
+                'error' => 'Direct visibility is not available.',
+            ], 400);
+        }
+
         if ($request->hasHeader('idempotency-key')) {
             $key = 'pf:api:v1:status:idempotency-key:'.$request->user()->id.':'.hash('sha1', $request->header('idempotency-key'));
             $exists = Cache::has($key);
@@ -3494,7 +3684,7 @@ class ApiV1Controller extends Controller
             return [];
         }
 
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = '';
         $content = $request->filled('status') ? strip_tags($request->input('status')) : $defaultCaption;
         $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false);
         $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null;
@@ -3687,7 +3877,7 @@ class ApiV1Controller extends Controller
             }
         }
 
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : '';
         $share = Status::firstOrCreate([
             'caption' => $defaultCaption,
             'rendered' => $defaultCaption,
@@ -3814,8 +4004,16 @@ class ApiV1Controller extends Controller
         $pe = $request->has(self::PF_API_ENTITY_KEY);
         $pid = $request->user()->profile_id;
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $tagFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('tags', $filter->context);
+        });
+
         if ($min || $max) {
-            $minMax = SnowflakeService::byDate(now()->subMonths(6));
+            $minMax = SnowflakeService::byDate(now()->subMonths(9));
             if ($min && intval($min) < $minMax) {
                 return [];
             }
@@ -3870,6 +4068,23 @@ class ApiV1Controller extends Controller
 
                 return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
             })
+            ->map(function ($status) use ($tagFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($tagFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values()
             ->toArray();
@@ -4347,4 +4562,101 @@ class ApiV1Controller extends Controller
             })
         );
     }
+
+    public function accountRemoveFollowById(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+
+        if ($pid === $id) {
+            return $this->json(['error' => 'Request invalid! target_id is same user id.'], 500);
+        }
+
+        $exists = Follower::whereProfileId($id)
+            ->whereFollowingId($pid)
+            ->first();
+
+        abort_unless($exists, 404);
+
+        $exists->delete();
+
+        RelationshipService::refresh($pid, $id);
+        RelationshipService::refresh($pid, $id);
+
+        UnfollowPipeline::dispatch($id, $pid)->onQueue('high');
+
+        Cache::forget('profile:following:'.$id);
+        Cache::forget('profile:followers:'.$id);
+        Cache::forget('profile:following:'.$pid);
+        Cache::forget('profile:followers:'.$pid);
+        Cache::forget('api:local:exp:rec:'.$pid);
+        Cache::forget('user:account:id:'.$id);
+        Cache::forget('user:account:id:'.$pid);
+        Cache::forget('profile:follower_count:'.$id);
+        Cache::forget('profile:follower_count:'.$pid);
+        Cache::forget('profile:following_count:'.$id);
+        Cache::forget('profile:following_count:'.$pid);
+        AccountService::del($pid);
+        AccountService::del($id);
+
+        $res = RelationshipService::get($id, $pid);
+        return $this->json($res);
+    }
+    /**
+     *  GET /api/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
 }

+ 3 - 24
app/Http/Controllers/Api/ApiV1Dot1Controller.php

@@ -519,7 +519,7 @@ class ApiV1Dot1Controller extends Controller
             'username' => [
                 'required',
                 'min:2',
-                'max:15',
+                'max:30',
                 'unique:users',
                 function ($attribute, $value, $fail) {
                     $dash = substr_count($value, '-');
@@ -629,9 +629,6 @@ class ApiV1Dot1Controller extends Controller
             abort_if(BouncerService::checkIp($request->ip()), 404);
         }
 
-        $rl = RateLimiter::attempt('pf:apiv1.1:iarc:'.$request->ip(), config('pixelfed.app_registration_confirm_rate_limit_attempts', 20), function () {}, config('pixelfed.app_registration_confirm_rate_limit_decay', 1800));
-        abort_if(! $rl, 429, 'Too many requests');
-
         $request->validate([
             'user_token' => 'required',
             'random_token' => 'required',
@@ -658,7 +655,7 @@ class ApiV1Dot1Controller extends Controller
         $user->last_active_at = now();
         $user->save();
 
-        $token = $user->createToken('Pixelfed', ['read', 'write', 'follow', 'admin:read', 'admin:write', 'push']);
+        $token = $user->createToken('Pixelfed', ['read', 'write', 'follow', 'push']);
 
         return response()->json([
             'access_token' => $token->accessToken,
@@ -1061,8 +1058,6 @@ class ApiV1Dot1Controller extends Controller
             'notify_comment' => false,
         ]);
 
-        PushNotificationService::removeMemberFromAll($request->user()->profile_id);
-
         $user = $request->user();
 
         return $this->json([
@@ -1148,31 +1143,15 @@ class ApiV1Dot1Controller extends Controller
 
         if ($request->filled('notify_like')) {
             $request->user()->update(['notify_like' => (bool) $request->boolean('notify_like')]);
-            $request->boolean('notify_like') == true ?
-                PushNotificationService::set('like', $pid) :
-                PushNotificationService::removeMember('like', $pid);
         }
         if ($request->filled('notify_follow')) {
             $request->user()->update(['notify_follow' => (bool) $request->boolean('notify_follow')]);
-            $request->boolean('notify_follow') == true ?
-                PushNotificationService::set('follow', $pid) :
-                PushNotificationService::removeMember('follow', $pid);
         }
         if ($request->filled('notify_mention')) {
             $request->user()->update(['notify_mention' => (bool) $request->boolean('notify_mention')]);
-            $request->boolean('notify_mention') == true ?
-                PushNotificationService::set('mention', $pid) :
-                PushNotificationService::removeMember('mention', $pid);
         }
         if ($request->filled('notify_comment')) {
             $request->user()->update(['notify_comment' => (bool) $request->boolean('notify_comment')]);
-            $request->boolean('notify_comment') == true ?
-                PushNotificationService::set('comment', $pid) :
-                PushNotificationService::removeMember('comment', $pid);
-        }
-
-        if ($request->boolean('notify_enabled') == false) {
-            PushNotificationService::removeMemberFromAll($request->user()->profile_id);
         }
 
         $user = $request->user();
@@ -1292,7 +1271,7 @@ class ApiV1Dot1Controller extends Controller
         if ($user->last_active_at == null) {
             return [];
         }
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = '';
         $content = $request->filled('status') ? strip_tags(Purify::clean($request->input('status'))) : $defaultCaption;
         $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false);
         $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null;

+ 7 - 7
app/Http/Controllers/Api/ApiV2Controller.php

@@ -101,10 +101,10 @@ class ApiV2Controller extends Controller
                     'media_attachments' => [
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'image_matrix_limit' => 3686400,
+                        'image_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'video_frame_rate_limit' => 240,
-                        'video_matrix_limit' => 3686400,
+                        'video_frame_rate_limit' => 120,
+                        'video_matrix_limit' => 2073600,
                     ],
                     'polls' => [
                         'max_options' => 0,
@@ -292,7 +292,7 @@ class ApiV2Controller extends Controller
             }
         }
 
-        $media = new Media();
+        $media = new Media;
         $media->status_id = null;
         $media->profile_id = $profile->id;
         $media->user_id = $user->id;
@@ -326,9 +326,9 @@ class ApiV2Controller extends Controller
         $user->save();
 
         Cache::forget($limitKey);
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Item($media, new MediaTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
         $res = $fractal->createData($resource)->toArray();
         $res['preview_url'] = $media->url().'?v='.time();
         $res['url'] = null;

+ 82 - 101
app/Http/Controllers/Api/BaseApiController.php

@@ -2,46 +2,22 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
-use App\Http\Controllers\{
-    Controller,
-    AvatarController
-};
-use Auth, Cache, Storage, URL;
-use Carbon\Carbon;
-use App\{
-    Avatar,
-    Like,
-    Media,
-    Notification,
-    Profile,
-    Status,
-    StatusArchived
-};
-use App\Transformer\Api\{
-    AccountTransformer,
-    NotificationTransformer,
-    MediaTransformer,
-    MediaDraftTransformer,
-    StatusTransformer,
-    StatusStatelessTransformer
-};
-use League\Fractal;
-use App\Util\Media\Filter;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Avatar;
+use App\Http\Controllers\AvatarController;
+use App\Http\Controllers\Controller;
 use App\Jobs\AvatarPipeline\AvatarOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\VideoPipeline\{
-    VideoOptimize,
-    VideoPostProcess,
-    VideoThumbnail
-};
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Services\AccountService;
 use App\Services\NotificationService;
-use App\Services\MediaPathService;
-use App\Services\MediaBlocklistService;
 use App\Services\StatusService;
+use App\Status;
+use App\StatusArchived;
+use App\Transformer\Api\StatusStatelessTransformer;
+use Auth;
+use Cache;
+use Illuminate\Http\Request;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 
 class BaseApiController extends Controller
 {
@@ -50,47 +26,47 @@ class BaseApiController extends Controller
     public function __construct()
     {
         // $this->middleware('auth');
-        $this->fractal = new Fractal\Manager();
-        $this->fractal->setSerializer(new ArraySerializer());
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
     }
 
     public function notifications(Request $request)
     {
-        abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$limit = $request->input('limit', 20);
-
-		$since = $request->input('since_id');
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-
-		if(!$since && !$min && !$max) {
-			$min = 1;
-		}
-
-		$maxId = null;
-		$minId = null;
-
-		if($max) {
-			$res = NotificationService::getMax($pid, $max, $limit);
-			$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		} else {
-			$res = NotificationService::getMin($pid, $min ?? $since, $limit);
-			$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		}
-
-        if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
-        	Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-        	NotificationService::warmCache($pid, 100, true);
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit', 20);
+
+        $since = $request->input('since_id');
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+
+        if (! $since && ! $min && ! $max) {
+            $min = 1;
+        }
+
+        $maxId = null;
+        $minId = null;
+
+        if ($max) {
+            $res = NotificationService::getMax($pid, $max, $limit);
+            $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        } else {
+            $res = NotificationService::getMin($pid, $min ?? $since, $limit);
+            $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        }
+
+        if (empty($res) && ! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+            Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+            NotificationWarmUserCache::dispatch($pid);
         }
 
         return response()->json($res);
@@ -98,17 +74,17 @@ class BaseApiController extends Controller
 
     public function avatarUpdate(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'upload'   => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
+            'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
         ]);
 
         try {
             $user = Auth::user();
             $profile = $user->profile;
             $file = $request->file('upload');
-            $path = (new AvatarController())->getPath($user, $file);
+            $path = (new AvatarController)->getPath($user, $file);
             $dir = $path['root'];
             $name = $path['name'];
             $public = $path['storage'];
@@ -129,13 +105,13 @@ class BaseApiController extends Controller
 
         return response()->json([
             'code' => 200,
-            'msg'  => 'Avatar successfully updated',
+            'msg' => 'Avatar successfully updated',
         ]);
     }
 
     public function verifyCredentials(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $user = $request->user();
         if ($user->status != null) {
@@ -143,47 +119,51 @@ class BaseApiController extends Controller
             abort(403);
         }
         $res = AccountService::get($user->profile_id);
+
         return response()->json($res);
     }
 
     public function accountLikes(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-        	'page' => 'sometimes|int|min:1|max:20',
-        	'limit' => 'sometimes|int|min:1|max:10'
+            'page' => 'sometimes|int|min:1|max:20',
+            'limit' => 'sometimes|int|min:1|max:10',
         ]);
 
         $user = $request->user();
         $limit = $request->input('limit', 10);
 
         $res = \DB::table('likes')
-        	->whereProfileId($user->profile_id)
-        	->latest()
-        	->simplePaginate($limit)
-        	->map(function($id) {
-        		$status = StatusService::get($id->status_id, false);
-        		$status['favourited'] = true;
-        		return $status;
-        	})
-        	->filter(function($post) {
-        		return $post && isset($post['account']);
-        	})
-        	->values();
+            ->whereProfileId($user->profile_id)
+            ->latest()
+            ->simplePaginate($limit)
+            ->map(function ($id) use ($user) {
+                $status = StatusService::get($id->status_id, false);
+                $status['favourited'] = true;
+                $status['reblogged'] = (bool) StatusService::isShared($id->status_id, $user->profile_id);
+
+                return $status;
+            })
+            ->filter(function ($post) {
+                return $post && isset($post['account']);
+            })
+            ->values();
+
         return response()->json($res);
     }
 
     public function archive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope === 'archived') {
+        if ($status->scope === 'archived') {
             return [200];
         }
 
@@ -204,14 +184,14 @@ class BaseApiController extends Controller
 
     public function unarchive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope !== 'archived') {
+        if ($status->scope !== 'archived') {
             return [200];
         }
 
@@ -231,16 +211,17 @@ class BaseApiController extends Controller
 
     public function archivedPosts(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $statuses = Status::whereProfileId($request->user()->profile_id)
             ->whereScope('archived')
             ->orderByDesc('id')
             ->simplePaginate(10);
 
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer);
+
         return $fractal->createData($resource)->toArray();
     }
 }

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

@@ -56,7 +56,7 @@ class DomainBlockController extends Controller
         abort_if(!$request->user(), 403);
 
         $this->validate($request, [
-            'domain' => 'required|active_url|min:1|max:120'
+            'domain' => 'required|min:1|max:120'
         ]);
 
         $pid = $request->user()->profile_id;

+ 322 - 0
app/Http/Controllers/AppRegisterController.php

@@ -0,0 +1,322 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Mail\InAppRegisterEmailVerify;
+use App\Models\AppRegister;
+use App\Services\AccountService;
+use App\User;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+use Laravel\Passport\RefreshTokenRepository;
+use Purify;
+
+class AppRegisterController extends Controller
+{
+    public function index(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        return view('auth.iar');
+    }
+
+    public function store(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $rules = [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email',
+        ];
+
+        if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+        }
+
+        $this->validate($request, $rules);
+
+        $email = strtolower($request->input('email'));
+        $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+
+        DB::beginTransaction();
+
+        $exists = AppRegister::whereEmail($email)->where('created_at', '>', now()->subHours(24))->count();
+
+        if ($exists && $exists > 3) {
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => 'Too many attempts, please try again later.',
+            ]);
+            DB::rollBack();
+
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        $registration = AppRegister::create([
+            'email' => $email,
+            'verify_code' => $code,
+            'uses' => 1,
+            'email_delivered_at' => now(),
+        ]);
+
+        try {
+            Mail::to($email)->send(new InAppRegisterEmailVerify($code));
+        } catch (\Exception $e) {
+            DB::rollBack();
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => 'Failed to send verification code',
+            ]);
+
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        DB::commit();
+
+        $queryParams = http_build_query([
+            'email' => $request->email,
+            'expires_in' => 3600,
+            'status' => 'success',
+        ]);
+
+        return redirect()->away("pixelfed://verifyEmail?{$queryParams}");
+    }
+
+    public function verifyCode(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $this->validate($request, [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email',
+            'verify_code' => ['required', 'digits:6', 'numeric'],
+        ]);
+
+        $email = strtolower($request->input('email'));
+        $code = $request->input('verify_code');
+
+        $exists = AppRegister::whereEmail($email)
+            ->whereVerifyCode($code)
+            ->where('created_at', '>', now()->subHours(4))
+            ->exists();
+
+        return response()->json([
+            'status' => $exists ? 'success' : 'error',
+        ]);
+    }
+
+    public function resendVerification(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        return view('auth.iar-resend');
+    }
+
+    public function resendVerificationStore(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $rules = [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email',
+        ];
+
+        if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+        }
+
+        $this->validate($request, $rules);
+
+        $email = strtolower($request->input('email'));
+        $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+
+        DB::beginTransaction();
+
+        $exists = AppRegister::whereEmail($email)->first();
+
+        if (! $exists || $exists->uses > 5) {
+            $errorMessage = $exists->uses > 5 ? 'Too many attempts have been made, please contact the admins.' : 'Email not found';
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => $errorMessage,
+            ]);
+            DB::rollBack();
+
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        $registration = $exists->update([
+            'verify_code' => $code,
+            'uses' => ($exists->uses + 1),
+            'email_delivered_at' => now(),
+        ]);
+
+        try {
+            Mail::to($email)->send(new InAppRegisterEmailVerify($code));
+        } catch (\Exception $e) {
+            DB::rollBack();
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => 'Failed to send verification code',
+            ]);
+
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        DB::commit();
+
+        $queryParams = http_build_query([
+            'email' => $request->email,
+            'expires_in' => 3600,
+            'status' => 'success',
+        ]);
+
+        return redirect()->away("pixelfed://verifyEmail?{$queryParams}");
+    }
+
+    public function onboarding(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $this->validate($request, [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email',
+            'verify_code' => ['required', 'digits:6', 'numeric'],
+            'username' => $this->validateUsernameRule(),
+            'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
+            'password' => 'required|string|min:'.config('pixelfed.min_password_length'),
+        ]);
+
+        $email = strtolower($request->input('email'));
+        $code = $request->input('verify_code');
+        $username = $request->input('username');
+        $name = $request->input('name');
+        $password = $request->input('password');
+
+        $exists = AppRegister::whereEmail($email)
+            ->whereVerifyCode($code)
+            ->where('created_at', '>', now()->subHours(4))
+            ->exists();
+
+        if (! $exists) {
+            return response()->json([
+                'status' => 'error',
+                'message' => 'Invalid verification code, please try again later.',
+            ]);
+        }
+
+        $user = User::create([
+            'name' => Purify::clean($name),
+            'username' => $username,
+            'email' => $email,
+            'password' => Hash::make($password),
+            'app_register_ip' => request()->ip(),
+            'register_source' => 'app',
+            'email_verified_at' => now(),
+        ]);
+
+        sleep(random_int(8, 10));
+        $user = User::findOrFail($user->id);
+        $token = $user->createToken('Pixelfed App', ['read', 'write', 'follow', 'push']);
+        $tokenModel = $token->token;
+        $clientId = $tokenModel->client_id;
+        $clientSecret = DB::table('oauth_clients')->where('id', $clientId)->value('secret');
+        $refreshTokenRepo = app(RefreshTokenRepository::class);
+        $refreshToken = $refreshTokenRepo->create([
+            'id' => Str::random(80),
+            'access_token_id' => $tokenModel->id,
+            'revoked' => false,
+            'expires_at' => now()->addDays(config('instance.oauth.refresh_expiration', 400)),
+        ]);
+
+        $expiresAt = $tokenModel->expires_at ?? now()->addDays(config('instance.oauth.token_expiration', 356));
+        $expiresIn = now()->diffInSeconds($expiresAt);
+        AppRegister::whereEmail($email)->delete();
+
+        return response()->json([
+            'status' => 'success',
+            'token_type' => 'Bearer',
+            'domain' => config('pixelfed.domain.app'),
+            'expires_in' => $expiresIn,
+            'access_token' => $token->accessToken,
+            'refresh_token' => $refreshToken->id,
+            'client_id' => $clientId,
+            'client_secret' => $clientSecret,
+            'scope' => ['read', 'write', 'follow', 'push'],
+            'user' => [
+                'pid' => (string) $user->profile_id,
+                'username' => $user->username,
+            ],
+            'account' => AccountService::get($user->profile_id, true),
+        ]);
+    }
+
+    protected function validateUsernameRule()
+    {
+        return [
+            'required',
+            'min:2',
+            'max:30',
+            '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.');
+                }
+            },
+        ];
+    }
+}

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

@@ -69,7 +69,7 @@ class RegisterController extends Controller
         $usernameRules = [
             'required',
             'min:2',
-            'max:15',
+            'max:30',
             'unique:users',
             function ($attribute, $value, $fail) {
                 $dash = substr_count($value, '-');
@@ -111,7 +111,7 @@ class RegisterController extends Controller
         $emailRules = [
             'required',
             'string',
-            'email',
+            'email:rfc,dns,spoof',
             'max:255',
             'unique:users',
             function ($attribute, $value, $fail) {

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

@@ -29,7 +29,7 @@ class CollectionController extends Controller
         return view('collection.create', compact('collection'));
     }
 
-    public function show(Request $request, int $id)
+    public function show(Request $request, $id)
     {
         $user = $request->user();
         $collection = CollectionService::getCollection($id);

+ 1 - 3
app/Http/Controllers/CommentController.php

@@ -55,14 +55,12 @@ class CommentController extends Controller
         }
 
         $reply = DB::transaction(function () use ($comment, $status, $profile, $nsfw) {
-            $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
-
             $scope = $profile->is_private == true ? 'private' : 'public';
             $reply = new Status;
             $reply->profile_id = $profile->id;
             $reply->is_nsfw = $nsfw;
             $reply->caption = Purify::clean($comment);
-            $reply->rendered = $defaultCaption;
+            $reply->rendered = "";
             $reply->in_reply_to_id = $status->id;
             $reply->in_reply_to_profile_id = $status->profile_id;
             $reply->scope = $scope;

+ 13 - 5
app/Http/Controllers/ComposeController.php

@@ -30,7 +30,6 @@ use App\Util\Media\License;
 use Auth;
 use Cache;
 use DB;
-use Purify;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use League\Fractal;
@@ -133,6 +132,7 @@ class ComposeController extends Controller
             case 'image/jpeg':
             case 'image/png':
             case 'image/webp':
+            case 'image/avif':
                 ImageOptimize::dispatch($media)->onQueue('mmo');
                 break;
 
@@ -239,7 +239,13 @@ class ComposeController extends Controller
         abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'q' => 'required|string|min:1|max:50',
+            'q' => [
+                'required',
+                'string',
+                'min:1',
+                'max:300',
+                new \App\Rules\WebFinger,
+            ],
         ]);
 
         $q = $request->input('q');
@@ -262,10 +268,11 @@ class ComposeController extends Controller
 
         $blocked->push($request->user()->profile_id);
 
+        $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
         $results = Profile::select('id', 'domain', 'username')
             ->whereNotIn('id', $blocked)
             ->whereNull('domain')
-            ->where('username', 'like', '%'.$q.'%')
+            ->where('username', $operator, '%'.$q.'%')
             ->limit(15)
             ->get()
             ->map(function ($r) {
@@ -570,7 +577,7 @@ class ComposeController extends Controller
             $status->cw_summary = $request->input('spoiler_text');
         }
 
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = '';
         $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
         $status->rendered = $defaultCaption;
         $status->scope = 'draft';
@@ -676,7 +683,7 @@ class ComposeController extends Controller
         $place = $request->input('place');
         $cw = $request->input('cw');
         $tagged = $request->input('tagged');
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : '';
 
         if ($place && is_array($place)) {
             $status->place_id = $place['id'];
@@ -773,6 +780,7 @@ class ComposeController extends Controller
         $default = [
             'default_license' => 1,
             'media_descriptions' => false,
+            'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
             'max_altext_length' => config_cache('pixelfed.max_altext_length'),
         ];
         $settings = AccountService::settings($uid);

+ 102 - 60
app/Http/Controllers/CuratedRegisterController.php

@@ -2,27 +2,28 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use App\User;
+use App\Jobs\CuratedOnboarding\CuratedOnboardingNotifyAdminNewApplicationPipeline;
+use App\Mail\CuratedRegisterConfirmEmail;
 use App\Models\CuratedRegister;
 use App\Models\CuratedRegisterActivity;
 use App\Services\EmailService;
-use App\Services\BouncerService;
 use App\Util\Lexer\RestrictedNames;
-use App\Mail\CuratedRegisterConfirmEmail;
-use App\Mail\CuratedRegisterNotifyAdmin;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Mail;
-use App\Jobs\CuratedOnboarding\CuratedOnboardingNotifyAdminNewApplicationPipeline;
+use Illuminate\Support\Str;
 
 class CuratedRegisterController extends Controller
 {
-    public function __construct()
+    public function preCheck($allowWhenDisabled = false)
     {
-        abort_unless((bool) config_cache('instance.curated_registration.enabled'), 404);
-
-        if((bool) config_cache('pixelfed.open_registration')) {
-            abort_if(config('instance.curated_registration.state.only_enabled_on_closed_reg'), 404);
+        if (! $allowWhenDisabled) {
+            abort_unless((bool) config_cache('instance.curated_registration.enabled'), 404);
+
+            if ((bool) config_cache('pixelfed.open_registration')) {
+                abort_if(config('instance.curated_registration.state.only_enabled_on_closed_reg'), 404);
+            } else {
+                abort_unless(config('instance.curated_registration.state.fallback_on_closed_reg'), 404);
+            }
         } else {
             abort_unless(config('instance.curated_registration.state.fallback_on_closed_reg'), 404);
         }
@@ -31,26 +32,32 @@ class CuratedRegisterController extends Controller
     public function index(Request $request)
     {
         abort_if($request->user(), 404);
+
         return view('auth.curated-register.index', ['step' => 1]);
     }
 
     public function concierge(Request $request)
     {
         abort_if($request->user(), 404);
+        $this->preCheck(true);
         $emailConfirmed = $request->session()->has('cur-reg-con.email-confirmed') &&
             $request->has('next') &&
             $request->session()->has('cur-reg-con.cr-id');
+
         return view('auth.curated-register.concierge', compact('emailConfirmed'));
     }
 
     public function conciergeResponseSent(Request $request)
     {
+        $this->preCheck(true);
+
         return view('auth.curated-register.user_response_sent');
     }
 
     public function conciergeFormShow(Request $request)
     {
         abort_if($request->user(), 404);
+        $this->preCheck(true);
         abort_unless(
             $request->session()->has('cur-reg-con.email-confirmed') &&
             $request->session()->has('cur-reg-con.cr-id') &&
@@ -58,18 +65,20 @@ class CuratedRegisterController extends Controller
         $crid = $request->session()->get('cur-reg-con.cr-id');
         $arid = $request->session()->get('cur-reg-con.ac-id');
         $showCaptcha = config('instance.curated_registration.captcha_enabled');
-        if($attempts = $request->session()->get('cur-reg-con-attempt')) {
+        if ($attempts = $request->session()->get('cur-reg-con-attempt')) {
             $showCaptcha = $attempts && $attempts >= 2;
         } else {
             $showCaptcha = false;
         }
         $activity = CuratedRegisterActivity::whereRegisterId($crid)->whereFromAdmin(true)->findOrFail($arid);
+
         return view('auth.curated-register.concierge_form', compact('activity', 'showCaptcha'));
     }
 
     public function conciergeFormStore(Request $request)
     {
         abort_if($request->user(), 404);
+        $this->preCheck(true);
         $request->session()->increment('cur-reg-con-attempt');
         abort_unless(
             $request->session()->has('cur-reg-con.email-confirmed') &&
@@ -80,9 +89,9 @@ class CuratedRegisterController extends Controller
         $rules = [
             'response' => 'required|string|min:5|max:1000',
             'crid' => 'required|integer|min:1',
-            'acid' => 'required|integer|min:1'
+            'acid' => 'required|integer|min:1',
         ];
-        if(config('instance.curated_registration.captcha_enabled') && $attempts >= 3) {
+        if (config('instance.curated_registration.captcha_enabled') && $attempts >= 3) {
             $rules['h-captcha-response'] = 'required|captcha';
             $messages['h-captcha-response.required'] = 'The captcha must be filled';
         }
@@ -92,7 +101,7 @@ class CuratedRegisterController extends Controller
         abort_if((string) $crid !== $request->input('crid'), 404);
         abort_if((string) $acid !== $request->input('acid'), 404);
 
-        if(CuratedRegisterActivity::whereRegisterId($crid)->whereReplyToId($acid)->exists()) {
+        if (CuratedRegisterActivity::whereRegisterId($crid)->whereReplyToId($acid)->exists()) {
             return redirect()->back()->withErrors(['code' => 'You already replied to this request.']);
         }
 
@@ -115,6 +124,7 @@ class CuratedRegisterController extends Controller
     public function conciergeStore(Request $request)
     {
         abort_if($request->user(), 404);
+        $this->preCheck(true);
         $rules = [
             'sid' => 'required_if:action,email|integer|min:1|max:20000000',
             'id' => 'required_if:action,email|integer|min:1|max:20000000',
@@ -124,7 +134,7 @@ class CuratedRegisterController extends Controller
             'response' => 'required_if:action,message|string|min:20|max:1000',
         ];
         $messages = [];
-        if(config('instance.curated_registration.captcha_enabled')) {
+        if (config('instance.curated_registration.captcha_enabled')) {
             $rules['h-captcha-response'] = 'required|captcha';
             $messages['h-captcha-response.required'] = 'The captcha must be filled';
         }
@@ -139,11 +149,11 @@ class CuratedRegisterController extends Controller
         $cr = CuratedRegister::whereIsClosed(false)->findOrFail($sid);
         $ac = CuratedRegisterActivity::whereRegisterId($cr->id)->whereFromAdmin(true)->findOrFail($id);
 
-        if(!hash_equals($ac->secret_code, $code)) {
+        if (! hash_equals($ac->secret_code, $code)) {
             return redirect()->back()->withErrors(['code' => 'Invalid code']);
         }
 
-        if(!hash_equals($cr->email, $email)) {
+        if (! hash_equals($cr->email, $email)) {
             return redirect()->back()->withErrors(['email' => 'Invalid email']);
         }
 
@@ -151,44 +161,58 @@ class CuratedRegisterController extends Controller
         $request->session()->put('cur-reg-con.cr-id', $cr->id);
         $request->session()->put('cur-reg-con.ac-id', $ac->id);
         $emailConfirmed = true;
+
         return redirect('/auth/sign_up/concierge/form');
     }
 
     public function confirmEmail(Request $request)
     {
-        if($request->user()) {
+        if ($request->user()) {
             return redirect(route('help.email-confirmation-issues'));
         }
+        $this->preCheck(true);
+
         return view('auth.curated-register.confirm_email');
     }
 
     public function emailConfirmed(Request $request)
     {
-        if($request->user()) {
+        if ($request->user()) {
             return redirect(route('help.email-confirmation-issues'));
         }
+        $this->preCheck(true);
+
         return view('auth.curated-register.email_confirmed');
     }
 
     public function resendConfirmation(Request $request)
     {
+        if ($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        $this->preCheck(true);
+
         return view('auth.curated-register.resend-confirmation');
     }
 
     public function resendConfirmationProcess(Request $request)
     {
+        if ($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        $this->preCheck(true);
         $rules = [
             'email' => [
                 'required',
                 'string',
                 app()->environment() === 'production' ? 'email:rfc,dns,spoof' : 'email',
                 'exists:curated_registers',
-            ]
+            ],
         ];
 
         $messages = [];
 
-        if(config('instance.curated_registration.captcha_enabled')) {
+        if (config('instance.curated_registration.captcha_enabled')) {
             $rules['h-captcha-response'] = 'required|captcha';
             $messages['h-captcha-response.required'] = 'The captcha must be filled';
         }
@@ -196,7 +220,7 @@ class CuratedRegisterController extends Controller
         $this->validate($request, $rules, $messages);
 
         $cur = CuratedRegister::whereEmail($request->input('email'))->whereIsClosed(false)->first();
-        if(!$cur) {
+        if (! $cur) {
             return redirect()->back()->withErrors(['email' => 'The selected email is invalid.']);
         }
 
@@ -204,7 +228,7 @@ class CuratedRegisterController extends Controller
             ->whereType('user_resend_email_confirmation')
             ->count();
 
-        if($totalCount && $totalCount >= config('instance.curated_registration.resend_confirmation_limit')) {
+        if ($totalCount && $totalCount >= config('instance.curated_registration.resend_confirmation_limit')) {
             return redirect()->back()->withErrors(['email' => 'You have re-attempted too many times. To proceed with your application, please <a href="/site/contact" class="text-white" style="text-decoration: underline;">contact the admin team</a>.']);
         }
 
@@ -213,75 +237,92 @@ class CuratedRegisterController extends Controller
             ->where('created_at', '>', now()->subHours(12))
             ->count();
 
-        if($count) {
+        if ($count) {
             return redirect()->back()->withErrors(['email' => 'You can only re-send the confirmation email once per 12 hours. Try again later.']);
         }
 
-        CuratedRegisterActivity::create([
-            'register_id' => $cur->id,
-            'type' => 'user_resend_email_confirmation',
-            'admin_only_view' => true,
-            'from_admin' => false,
-            'from_user' => false,
-            'action_required' => false,
-        ]);
+        DB::transaction(function () use ($cur) {
+            $cur->verify_code = Str::random(40);
+            $cur->created_at = now();
+            $cur->save();
+
+            CuratedRegisterActivity::create([
+                'register_id' => $cur->id,
+                'type' => 'user_resend_email_confirmation',
+                'admin_only_view' => true,
+                'from_admin' => false,
+                'from_user' => false,
+                'action_required' => false,
+            ]);
+
+            Mail::to($cur->email)->send(new CuratedRegisterConfirmEmail($cur));
+        });
 
-        Mail::to($cur->email)->send(new CuratedRegisterConfirmEmail($cur));
         return view('auth.curated-register.resent-confirmation');
-        return $request->all();
     }
 
     public function confirmEmailHandle(Request $request)
     {
+        if ($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        $this->preCheck(true);
         $rules = [
             'sid' => 'required',
-            'code' => 'required'
+            'code' => 'required',
         ];
         $messages = [];
-        if(config('instance.curated_registration.captcha_enabled')) {
+        if (config('instance.curated_registration.captcha_enabled')) {
             $rules['h-captcha-response'] = 'required|captcha';
             $messages['h-captcha-response.required'] = 'The captcha must be filled';
         }
         $this->validate($request, $rules, $messages);
 
         $cr = CuratedRegister::whereNull('email_verified_at')
-            ->where('created_at', '>', now()->subHours(24))
+            ->where('created_at', '>', now()->subDays(7))
             ->find($request->input('sid'));
-        if(!$cr) {
+        if (! $cr) {
             return redirect(route('help.email-confirmation-issues'));
         }
-        if(!hash_equals($cr->verify_code, $request->input('code'))) {
+        if (! hash_equals($cr->verify_code, $request->input('code'))) {
             return redirect(route('help.email-confirmation-issues'));
         }
         $cr->email_verified_at = now();
         $cr->save();
 
-        if(config('instance.curated_registration.notify.admin.on_verify_email.enabled')) {
+        if (config('instance.curated_registration.notify.admin.on_verify_email.enabled')) {
             CuratedOnboardingNotifyAdminNewApplicationPipeline::dispatch($cr);
         }
+
         return view('auth.curated-register.email_confirmed');
     }
 
     public function proceed(Request $request)
     {
+        if ($request->user()) {
+            return redirect(route('help.email-confirmation-issues'));
+        }
+        $this->preCheck(false);
         $this->validate($request, [
-            'step' => 'required|integer|in:1,2,3,4'
+            'step' => 'required|integer|in:1,2,3,4',
         ]);
         $step = $request->input('step');
 
-        switch($step) {
+        switch ($step) {
             case 1:
                 $step = 2;
                 $request->session()->put('cur-step', 1);
+
                 return view('auth.curated-register.index', compact('step'));
-            break;
+                break;
 
             case 2:
                 $this->stepTwo($request);
                 $step = 3;
                 $request->session()->put('cur-step', 2);
+
                 return view('auth.curated-register.index', compact('step'));
-            break;
+                break;
 
             case 3:
                 $this->stepThree($request);
@@ -289,27 +330,28 @@ class CuratedRegisterController extends Controller
                 $request->session()->put('cur-step', 3);
                 $verifiedEmail = true;
                 $request->session()->pull('cur-reg');
+
                 return view('auth.curated-register.index', compact('step', 'verifiedEmail'));
-            break;
+                break;
         }
     }
 
     protected function stepTwo($request)
     {
-        if($request->filled('reason')) {
+        if ($request->filled('reason')) {
             $request->session()->put('cur-reg.form-reason', $request->input('reason'));
         }
-        if($request->filled('username')) {
+        if ($request->filled('username')) {
             $request->session()->put('cur-reg.form-username', $request->input('username'));
         }
-        if($request->filled('email')) {
+        if ($request->filled('email')) {
             $request->session()->put('cur-reg.form-email', $request->input('email'));
         }
         $this->validate($request, [
             'username' => [
                 'required',
                 'min:2',
-                'max:15',
+                'max:30',
                 'unique:curated_registers',
                 'unique:users',
                 function ($attribute, $value, $fail) {
@@ -317,24 +359,24 @@ class CuratedRegisterController 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 (_).');
                     }
 
@@ -353,7 +395,7 @@ class CuratedRegisterController extends Controller
                 'unique:curated_registers',
                 function ($attribute, $value, $fail) {
                     $banned = EmailService::isBanned($value);
-                    if($banned) {
+                    if ($banned) {
                         return $fail('Email is invalid.');
                     }
                 },
@@ -361,7 +403,7 @@ class CuratedRegisterController extends Controller
             'password' => 'required|min:8',
             'password_confirmation' => 'required|same:password',
             'reason' => 'required|min:20|max:1000',
-            'agree' => 'required|accepted'
+            'agree' => 'required|accepted',
         ]);
         $request->session()->put('cur-reg.form-email', $request->input('email'));
         $request->session()->put('cur-reg.form-password', $request->input('password'));
@@ -379,11 +421,11 @@ class CuratedRegisterController extends Controller
                 'unique:curated_registers',
                 function ($attribute, $value, $fail) {
                     $banned = EmailService::isBanned($value);
-                    if($banned) {
+                    if ($banned) {
                         return $fail('Email is invalid.');
                     }
                 },
-            ]
+            ],
         ]);
         $cr = new CuratedRegister;
         $cr->email = $request->email;

+ 503 - 0
app/Http/Controllers/CustomFilterController.php

@@ -0,0 +1,503 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\CustomFilter;
+use App\Models\CustomFilterKeyword;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Validation\Rule;
+
+class CustomFilterController extends Controller
+{
+    // const ACTIVE_TYPES = ['home', 'public', 'tags', 'notifications', 'thread', 'profile', 'groups'];
+    const ACTIVE_TYPES = ['home', 'public', 'tags'];
+
+    public function index(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
+            ->unexpired()
+            ->with(['keywords'])
+            ->orderByDesc('updated_at')
+            ->get()
+            ->map(function ($filter) {
+                return [
+                    'id' => $filter->id,
+                    'title' => $filter->title,
+                    'context' => $filter->context,
+                    'expires_at' => $filter->expires_at,
+                    'filter_action' => $filter->filterAction,
+                    'keywords' => $filter->keywords->map(function ($keyword) {
+                        return [
+                            'id' => $keyword->id,
+                            'keyword' => $keyword->keyword,
+                            'whole_word' => (bool) $keyword->whole_word,
+                        ];
+                    }),
+                    'statuses' => [],
+                ];
+            });
+
+        return response()->json($filters);
+    }
+
+    public function show(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('view', $filter);
+
+        $filter->load(['keywords']);
+
+        $res = [
+            'id' => $filter->id,
+            'title' => $filter->title,
+            'context' => $filter->context,
+            'expires_at' => $filter->expires_at,
+            'filter_action' => $filter->filterAction,
+            'keywords' => $filter->keywords->map(function ($keyword) {
+                return [
+                    'id' => $keyword->id,
+                    'keyword' => $keyword->keyword,
+                    'whole_word' => (bool) $keyword->whole_word,
+                ];
+            }),
+            'statuses' => [],
+        ];
+
+        return response()->json($res);
+    }
+
+    public function store(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        Gate::authorize('create', CustomFilter::class);
+
+        $validatedData = $request->validate([
+            'title' => 'required|string|max:100',
+            'context' => 'required|array',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::getMaxKeywordsPerFilter(),
+            'keywords_attributes.*.keyword' => [
+                'required',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+        ]);
+        $profile_id = $request->user()->profile_id;
+        $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
+        $maxFiltersPerUser = CustomFilter::getMaxFiltersPerUser();
+
+        if (! $request->user()->is_admin && $userFilterCount >= $maxFiltersPerUser) {
+            return response()->json([
+                'error' => 'Filter limit exceeded',
+                'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
+            ], 422);
+        }
+
+        $rateKey = 'filters_created:'.$request->user()->id;
+        $maxFiltersPerHour = CustomFilter::getMaxCreatePerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxFiltersPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $requestedKeywords = array_map(function ($item) {
+                return mb_strtolower(trim($item['keyword']));
+            }, $validatedData['keywords_attributes']);
+
+            $existingKeywords = DB::table('custom_filter_keywords')
+                ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                ->where('custom_filters.profile_id', $profile_id)
+                ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                ->pluck('custom_filter_keywords.keyword')
+                ->toArray();
+
+            if (! empty($existingKeywords)) {
+                return response()->json([
+                    'error' => 'Duplicate keywords found',
+                    'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                ], 422);
+            }
+
+            $expiresAt = null;
+            if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
+                $expiresAt = now()->addSeconds($validatedData['expires_in']);
+            }
+
+            $action = CustomFilter::ACTION_WARN;
+            if (isset($validatedData['filter_action'])) {
+                $action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter = CustomFilter::create([
+                'title' => $validatedData['title'],
+                'context' => $validatedData['context'],
+                'action' => $action,
+                'expires_at' => $expiresAt,
+                'profile_id' => $request->user()->profile_id,
+            ]);
+
+            if (isset($validatedData['keywords_attributes'])) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    $keyword = trim($keywordData['keyword']);
+
+                    $filter->keywords()->create([
+                        'keyword' => $keyword,
+                        'whole_word' => (bool) $keywordData['whole_word'] ?? true,
+                    ]);
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$profile_id}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res, 200);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to create filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * Convert Mastodon filter_action string to internal action value
+     *
+     * @param  string  $filterAction
+     * @return int
+     */
+    private function filterActionToAction($filterAction)
+    {
+        switch ($filterAction) {
+            case 'warn':
+                return CustomFilter::ACTION_WARN;
+            case 'hide':
+                return CustomFilter::ACTION_HIDE;
+            case 'blur':
+                return CustomFilter::ACTION_BLUR;
+            default:
+                return CustomFilter::ACTION_WARN;
+        }
+    }
+
+    public function update(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        if ($filter->profile_id !== $pid) {
+            return response()->json(['error' => 'This action is unauthorized'], 401);
+        }
+        Gate::authorize('update', $filter);
+
+        $validatedData = $request->validate([
+            'title' => 'string|max:100',
+            'context' => 'array|max:10',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => [
+                'required',
+                'array',
+                'min:1',
+                function ($attribute, $value, $fail) {
+                    $activeKeywords = collect($value)->filter(function ($keyword) {
+                        return ! isset($keyword['_destroy']) || $keyword['_destroy'] !== true;
+                    })->count();
+
+                    if ($activeKeywords > CustomFilter::getMaxKeywordsPerFilter()) {
+                        $fail('You may not have more than '.CustomFilter::getMaxKeywordsPerFilter().' active keywords.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
+            'keywords_attributes.*.keyword' => [
+                'required_without:keywords_attributes.*.id',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+            'keywords_attributes.*._destroy' => 'boolean',
+        ]);
+
+        $rateKey = 'filters_updated:'.$request->user()->id;
+        $maxUpdatesPerHour = CustomFilter::getMaxUpdatesPerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxUpdatesPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $keywordIds = collect($validatedData['keywords_attributes'])->pluck('id')->filter()->toArray();
+            if (count($keywordIds) && ! CustomFilterKeyword::whereCustomFilterId($filter->id)->whereIn('id', $keywordIds)->count()) {
+                return response()->json([
+                    'error' => 'Record not found',
+                ], 404);
+            }
+
+            $requestedKeywords = [];
+            foreach ($validatedData['keywords_attributes'] as $item) {
+                if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
+                    $requestedKeywords[] = mb_strtolower(trim($item['keyword']));
+                }
+            }
+
+            if (! empty($requestedKeywords)) {
+                $existingKeywords = DB::table('custom_filter_keywords')
+                    ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                    ->where('custom_filters.profile_id', $pid)
+                    ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                    ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
+                    ->pluck('custom_filter_keywords.keyword')
+                    ->toArray();
+
+                if (! empty($existingKeywords)) {
+                    return response()->json([
+                        'error' => 'Duplicate keywords found',
+                        'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                    ], 422);
+                }
+            }
+
+            if (isset($validatedData['expires_in'])) {
+                if ($validatedData['expires_in'] > 0) {
+                    $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
+                } else {
+                    $filter->expires_at = null;
+                }
+            }
+
+            if (isset($validatedData['title'])) {
+                $filter->title = $validatedData['title'];
+            }
+
+            if (isset($validatedData['context'])) {
+                $filter->context = $validatedData['context'];
+            }
+
+            if (isset($validatedData['filter_action'])) {
+                $filter->action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter->save();
+
+            if (isset($validatedData['keywords_attributes'])) {
+                $existingKeywords = $filter->keywords()->pluck('id')->toArray();
+
+                $processedIds = [];
+
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    // Case 1: Explicit deletion with _destroy flag
+                    if (isset($keywordData['id']) && isset($keywordData['_destroy']) && (bool) $keywordData['_destroy']) {
+                        // Verify this ID belongs to this filter before deletion
+                        $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($kwf) {
+                            $kwf->delete();
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 2: Update existing keyword
+                    elseif (isset($keywordData['id'])) {
+                        // Skip if we've already processed this ID
+                        if (in_array($keywordData['id'], $processedIds)) {
+                            continue;
+                        }
+
+                        // Verify this ID belongs to this filter before updating
+                        $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if (! isset($keywordData['_destroy']) && $filter->keywords()->pluck('id')->search($keywordData['id']) === false) {
+                            return response()->json([
+                                'error' => 'Duplicate keywords found',
+                                'message' => 'The following keywords already exist: '.$keywordData['keyword'],
+                            ], 422);
+                        }
+
+                        if ($keyword) {
+                            $updateData = [];
+
+                            if (isset($keywordData['keyword'])) {
+                                $updateData['keyword'] = trim($keywordData['keyword']);
+                            }
+
+                            if (isset($keywordData['whole_word'])) {
+                                $updateData['whole_word'] = (bool) $keywordData['whole_word'];
+                            }
+
+                            if (! empty($updateData)) {
+                                $keyword->update($updateData);
+                            }
+
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 3: Create new keyword
+                    elseif (isset($keywordData['keyword'])) {
+                        // Check if we're about to exceed the keyword limit
+                        $existingKeywordCount = $filter->keywords()->count();
+                        $maxKeywordsPerFilter = CustomFilter::getMaxKeywordsPerFilter();
+
+                        if ($existingKeywordCount >= $maxKeywordsPerFilter) {
+                            return response()->json([
+                                'error' => 'Keyword limit exceeded',
+                                'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
+                            ], 422);
+                        }
+
+                        // Skip existing case-insensitive keywords
+                        if ($filter->keywords()->pluck('keyword')->search(mb_strtolower(trim($keywordData['keyword']))) !== false) {
+                            continue;
+                        }
+
+                        $filter->keywords()->create([
+                            'keyword' => trim($keywordData['keyword']),
+                            'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
+                        ]);
+                    }
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$pid}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to update filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    public function delete(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('delete', $filter);
+        $filter->delete();
+
+        return response()->json((object) [], 200);
+    }
+}

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

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

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

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

+ 133 - 284
app/Http/Controllers/DirectMessageController.php

@@ -44,257 +44,93 @@ class DirectMessageController extends Controller
         if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
             return [];
         }
+
         $profile = $user->profile_id;
         $action = $request->input('a', 'inbox');
-        $page = $request->input('page');
+        $page = $request->input('page', 1);
+        $limit = 8;
+        $offset = ($page - 1) * $limit;
+
+        $baseQuery = DirectMessage::select(
+            'id', 'type', 'to_id', 'from_id', 'status_id',
+            'is_hidden', 'meta', 'created_at', 'read_at'
+        )->with(['author', 'status', 'recipient']);
 
         if (config('database.default') == 'pgsql') {
-            if ($action == 'inbox') {
-                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-                    ->whereToId($profile)
-                    ->with(['author', 'status'])
+            $query = match ($action) {
+                'inbox' => $baseQuery->whereToId($profile)
                     ->whereIsHidden(false)
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->latest()
-                    ->get()
-                    ->unique('from_id')
-                    ->take(8)
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    })->values();
-            }
-
-            if ($action == 'sent') {
-                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-                    ->whereFromId($profile)
-                    ->with(['author', 'status'])
-                    ->orderBy('id', 'desc')
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->get()
-                    ->unique('to_id')
-                    ->take(8)
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    });
-            }
-
-            if ($action == 'filtered') {
-                $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at')
-                    ->whereToId($profile)
-                    ->with(['author', 'status'])
+                    ->orderBy('created_at', 'desc'),
+                'sent' => $baseQuery->whereFromId($profile)
+                    ->orderBy('created_at', 'desc'),
+                'filtered' => $baseQuery->whereToId($profile)
                     ->whereIsHidden(true)
-                    ->orderBy('id', 'desc')
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->get()
-                    ->unique('from_id')
-                    ->take(8)
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    });
-            }
-        } elseif (config('database.default') == 'mysql') {
-            if ($action == 'inbox') {
-                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-                    ->whereToId($profile)
-                    ->with(['author', 'status'])
-                    ->whereIsHidden(false)
-                    ->groupBy('from_id')
-                    ->latest()
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->limit(8)
-                    ->get()
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    });
-            }
+                    ->orderBy('created_at', 'desc'),
+                default => throw new \InvalidArgumentException('Invalid action')
+            };
 
-            if ($action == 'sent') {
-                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-                    ->whereFromId($profile)
-                    ->with(['author', 'status'])
-                    ->groupBy('to_id')
-                    ->orderBy('createdAt', 'desc')
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->limit(8)
-                    ->get()
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    });
-            }
+            $dms = $query->offset($offset)
+                ->limit($limit)
+                ->get();
 
-            if ($action == 'filtered') {
-                $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-                    ->whereToId($profile)
-                    ->with(['author', 'status'])
+            $dms = $action === 'sent' ?
+                   $dms->unique('to_id') :
+                   $dms->unique('from_id');
+        } else {
+            $query = match ($action) {
+                'inbox' => $baseQuery->whereToId($profile)
+                    ->whereIsHidden(false)
+                    ->groupBy('from_id', 'id', 'type', 'to_id', 'status_id',
+                        'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->orderBy('created_at', 'desc'),
+                'sent' => $baseQuery->whereFromId($profile)
+                    ->groupBy('to_id', 'id', 'type', 'from_id', 'status_id',
+                        'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->orderBy('created_at', 'desc'),
+                'filtered' => $baseQuery->whereToId($profile)
                     ->whereIsHidden(true)
-                    ->groupBy('from_id')
-                    ->orderBy('createdAt', 'desc')
-                    ->when($page, function ($q, $page) {
-                        if ($page > 1) {
-                            return $q->offset($page * 8 - 8);
-                        }
-                    })
-                    ->limit(8)
-                    ->get()
-                    ->map(function ($r) use ($profile) {
-                        return $r->from_id !== $profile ? [
-                            'id' => (string) $r->from_id,
-                            'name' => $r->author->name,
-                            'username' => $r->author->username,
-                            'avatar' => $r->author->avatarUrl(),
-                            'url' => $r->author->url(),
-                            'isLocal' => (bool) ! $r->author->domain,
-                            'domain' => $r->author->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ] : [
-                            'id' => (string) $r->to_id,
-                            'name' => $r->recipient->name,
-                            'username' => $r->recipient->username,
-                            'avatar' => $r->recipient->avatarUrl(),
-                            'url' => $r->recipient->url(),
-                            'isLocal' => (bool) ! $r->recipient->domain,
-                            'domain' => $r->recipient->domain,
-                            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
-                            'lastMessage' => $r->status->caption,
-                            'messages' => [],
-                        ];
-                    });
-            }
+                    ->groupBy('from_id', 'id', 'type', 'to_id', 'status_id',
+                        'is_hidden', 'meta', 'created_at', 'read_at')
+                    ->orderBy('created_at', 'desc'),
+                default => throw new \InvalidArgumentException('Invalid action')
+            };
+
+            $dms = $query->offset($offset)
+                ->limit($limit)
+                ->get();
         }
 
-        return response()->json($dms->all());
+        $mappedDms = $dms->map(function ($r) use ($action) {
+            if ($action === 'sent') {
+                return [
+                    'id' => (string) $r->to_id,
+                    'name' => $r->recipient->name,
+                    'username' => $r->recipient->username,
+                    'avatar' => $r->recipient->avatarUrl(),
+                    'url' => $r->recipient->url(),
+                    'isLocal' => (bool) ! $r->recipient->domain,
+                    'domain' => $r->recipient->domain,
+                    'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                    'lastMessage' => $r->status->caption,
+                    'messages' => [],
+                ];
+            }
+
+            return [
+                'id' => (string) $r->from_id,
+                'name' => $r->author->name,
+                'username' => $r->author->username,
+                'avatar' => $r->author->avatarUrl(),
+                'url' => $r->author->url(),
+                'isLocal' => (bool) ! $r->author->domain,
+                'domain' => $r->author->domain,
+                'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+                'lastMessage' => $r->status->caption,
+                'messages' => [],
+            ];
+        });
+
+        return response()->json($mappedDms->values());
     }
 
     public function create(Request $request)
@@ -308,7 +144,9 @@ class DirectMessageController extends Controller
         $user = $request->user();
         abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
         if (! $user->is_admin) {
-            abort_if($user->created_at->gt(now()->subHours(72)), 400, 'You need to wait a bit before you can DM another account');
+            if ((bool) ! config_cache('instance.allow_new_account_dms')) {
+                abort_if($user->created_at->gt(now()->subHours(72)), 400, 'You need to wait a bit before you can DM another account');
+            }
         }
         $profile = $user->profile;
         $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
@@ -410,19 +248,35 @@ class DirectMessageController extends Controller
             'max_id' => 'sometimes|integer',
             'min_id' => 'sometimes|integer',
         ]);
+
         $user = $request->user();
-        abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action');
+        abort_if(
+            $user->has_roles && ! UserRoleService::can('can-direct-message', $user->id),
+            403,
+            'Invalid permissions for this action'
+        );
 
         $uid = $user->profile_id;
         $pid = $request->input('pid');
         $max_id = $request->input('max_id');
         $min_id = $request->input('min_id');
 
-        $r = Profile::findOrFail($pid);
+        $profile = Profile::findOrFail($pid);
+
+        $query = DirectMessage::select(
+            'id',
+            'is_hidden',
+            'from_id',
+            'to_id',
+            'type',
+            'status_id',
+            'meta',
+            'created_at',
+            'read_at'
+        )->with(['status']);
 
         if ($min_id) {
-            $res = DirectMessage::select('*')
-                ->where('id', '>', $min_id)
+            $res = $query->where('id', '>', $min_id)
                 ->where(function ($query) use ($pid, $uid) {
                     $query->where('from_id', $pid)->where('to_id', $uid);
                 })->orWhere(function ($query) use ($pid, $uid) {
@@ -433,8 +287,7 @@ class DirectMessageController extends Controller
                 ->get()
                 ->reverse();
         } elseif ($max_id) {
-            $res = DirectMessage::select('*')
-                ->where('id', '<', $max_id)
+            $res = $query->where('id', '<', $max_id)
                 ->where(function ($query) use ($pid, $uid) {
                     $query->where('from_id', $pid)->where('to_id', $uid);
                 })->orWhere(function ($query) use ($pid, $uid) {
@@ -444,7 +297,7 @@ class DirectMessageController extends Controller
                 ->take(8)
                 ->get();
         } else {
-            $res = DirectMessage::where(function ($query) use ($pid, $uid) {
+            $res = $query->where(function ($query) use ($pid, $uid) {
                 $query->where('from_id', $pid)->where('to_id', $uid);
             })->orWhere(function ($query) use ($pid, $uid) {
                 $query->where('from_id', $uid)->where('to_id', $pid);
@@ -454,46 +307,42 @@ class DirectMessageController extends Controller
                 ->get();
         }
 
-        $res = $res->filter(function ($s) {
-            return $s && $s->status;
-        })
-            ->map(function ($s) use ($uid) {
-                return [
-                    'id' => (string) $s->id,
-                    'hidden' => (bool) $s->is_hidden,
-                    'isAuthor' => $uid == $s->from_id,
-                    'type' => $s->type,
-                    'text' => $s->status->caption,
-                    'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
-                    'carousel' => MediaService::get($s->status_id),
-                    'created_at' => $s->created_at->format('c'),
-                    'timeAgo' => $s->created_at->diffForHumans(null, null, true),
-                    'seen' => $s->read_at != null,
-                    'reportId' => (string) $s->status_id,
-                    'meta' => json_decode($s->meta, true),
-                ];
-            })
-            ->values();
+        $messages = $res->filter(function ($message) {
+            return $message && $message->status;
+        })->map(function ($message) use ($uid) {
+            return [
+                'id' => (string) $message->id,
+                'hidden' => (bool) $message->is_hidden,
+                'isAuthor' => $uid == $message->from_id,
+                'type' => $message->type,
+                'text' => $message->status->caption,
+                'media' => $message->status->firstMedia() ? $message->status->firstMedia()->url() : null,
+                'carousel' => MediaService::get($message->status_id),
+                'created_at' => $message->created_at->format('c'),
+                'timeAgo' => $message->created_at->diffForHumans(null, null, true),
+                'seen' => $message->read_at != null,
+                'reportId' => (string) $message->status_id,
+                'meta' => is_string($message->meta) ? json_decode($message->meta, true) : $message->meta,
+            ];
+        })->values();
 
         $filters = UserFilterService::mutes($uid);
 
-        $w = [
-            'id' => (string) $r->id,
-            'name' => $r->name,
-            'username' => $r->username,
-            'avatar' => $r->avatarUrl(),
-            'url' => $r->url(),
-            'muted' => in_array($r->id, $filters),
-            'isLocal' => (bool) ! $r->domain,
-            'domain' => $r->domain,
-            'created_at' => $r->created_at->format('c'),
-            'updated_at' => $r->updated_at->format('c'),
-            'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+        return response()->json([
+            'id' => (string) $profile->id,
+            'name' => $profile->name,
+            'username' => $profile->username,
+            'avatar' => $profile->avatarUrl(),
+            'url' => $profile->url(),
+            'muted' => in_array($profile->id, $filters),
+            'isLocal' => (bool) ! $profile->domain,
+            'domain' => $profile->domain,
+            'created_at' => $profile->created_at->format('c'),
+            'updated_at' => $profile->updated_at->format('c'),
+            'timeAgo' => $profile->created_at->diffForHumans(null, true, true),
             'lastMessage' => '',
-            'messages' => $res,
-        ];
-
-        return response()->json($w, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+            'messages' => $messages,
+        ], 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     }
 
     public function delete(Request $request)

+ 17 - 19
app/Http/Controllers/DiscoverController.php

@@ -57,11 +57,11 @@ class DiscoverController extends Controller
 
         $this->validate($request, [
             'hashtag' => 'required|string|min:1|max:124',
-            'page' => 'nullable|integer|min:1|max:'.($user ? 29 : 3),
+            'page' => 'nullable|integer|min:1',
         ]);
 
         $page = $request->input('page') ?? '1';
-        $end = $page > 1 ? $page * 9 : 0;
+        $end = $page > 1 ? $page * 9 : (($page * 9) + 9);
         $tag = $request->input('hashtag');
 
         if (config('database.default') === 'pgsql') {
@@ -80,6 +80,18 @@ class DiscoverController extends Controller
             'name' => $hashtag->name,
             'url' => $hashtag->url(),
         ];
+
+        $res['tags'] = [];
+
+        if ($page >= 8) {
+            if ($user) {
+                if ($page >= 29) {
+                    return $res;
+                }
+            } else {
+                return $res;
+            }
+        }
         if ($user) {
             $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
@@ -99,23 +111,8 @@ class DiscoverController extends Controller
                 })
                 ->values();
         } else {
-            if ($page != 1) {
-                $res['tags'] = [];
-
-                return $res;
-            }
-            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page;
-            $tags = Cache::remember($key, 43200, function () use ($hashtag, $page, $end) {
-                return collect(StatusHashtagService::get($hashtag->id, $page, $end))
-                    ->filter(function ($tag) {
-                        if (! $tag['status']['local']) {
-                            return false;
-                        }
-
-                        return true;
-                    })
-                    ->values();
-            });
+            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page.':end'.$end;
+            $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
                 ->filter(function ($tag) {
                     if (! StatusService::get($tag['status']['id'])) {
@@ -190,6 +187,7 @@ class DiscoverController extends Controller
         })->filter(function ($s) use ($filtered) {
             return
                 $s &&
+                isset($s['account'], $s['account']['id']) &&
                 ! in_array($s['account']['id'], $filtered) &&
                 isset($s['account']);
         })->values();

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

@@ -79,7 +79,7 @@ class FederationController extends Controller
         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) {
+                if (strlen($username) > 30) {
                     return response('', 400);
                 }
                 $stripped = str_replace(['_', '.', '-'], '', $username);

+ 27 - 2
app/Http/Controllers/GroupController.php

@@ -27,11 +27,11 @@ class GroupController extends GroupFederationController
     public function __construct()
     {
         $this->middleware('auth');
-        abort_unless(config('groups.enabled'), 404);
     }
 
     public function index(Request $request)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
 
         return view('layouts.spa');
@@ -39,6 +39,7 @@ class GroupController extends GroupFederationController
 
     public function home(Request $request)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
 
         return view('layouts.spa');
@@ -46,6 +47,7 @@ class GroupController extends GroupFederationController
 
     public function show(Request $request, $id, $path = false)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::find($id);
 
         if (! $group || $group->status) {
@@ -61,6 +63,7 @@ class GroupController extends GroupFederationController
 
     public function showStatus(Request $request, $gid, $sid)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::find($gid);
         $pid = optional($request->user())->profile_id ?? false;
 
@@ -81,6 +84,7 @@ class GroupController extends GroupFederationController
 
     public function getGroup(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::whereNull('status')->findOrFail($id);
         $pid = optional($request->user())->profile_id ?? false;
 
@@ -91,6 +95,7 @@ class GroupController extends GroupFederationController
 
     public function showStatusLikes(Request $request, $id, $sid)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::findOrFail($id);
         $user = $request->user();
         $pid = $user->profile_id;
@@ -114,6 +119,7 @@ class GroupController extends GroupFederationController
 
     public function groupSettings(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -125,6 +131,7 @@ class GroupController extends GroupFederationController
 
     public function joinGroup(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
         abort_if($group->isMember($pid), 404);
@@ -159,6 +166,7 @@ class GroupController extends GroupFederationController
 
     public function updateGroup(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         $this->validate($request, [
             'description' => 'nullable|max:500',
             'membership' => 'required|in:all,local,private',
@@ -257,11 +265,14 @@ class GroupController extends GroupFederationController
 
     protected function toJson($group, $pid = false)
     {
+        abort_unless(config('groups.enabled'), 404);
+
         return GroupService::get($group->id, $pid);
     }
 
     public function groupLeave(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
 
         $pid = $request->user()->profile_id;
@@ -281,6 +292,7 @@ class GroupController extends GroupFederationController
 
     public function cancelJoinRequest(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
 
         $pid = $request->user()->profile_id;
@@ -299,6 +311,7 @@ class GroupController extends GroupFederationController
 
     public function metaBlockSearch(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -332,6 +345,7 @@ class GroupController extends GroupFederationController
 
     public function reportCreate(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -368,7 +382,7 @@ class GroupController extends GroupFederationController
             'You already reported this'
         );
 
-        $report = new GroupReport();
+        $report = new GroupReport;
         $report->group_id = $group->id;
         $report->profile_id = $pid;
         $report->type = $type;
@@ -399,6 +413,7 @@ class GroupController extends GroupFederationController
 
     public function reportAction(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -477,6 +492,7 @@ class GroupController extends GroupFederationController
 
     public function getMemberInteractionLimits(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -492,6 +508,7 @@ class GroupController extends GroupFederationController
 
     public function updateMemberInteractionLimits(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $group = Group::findOrFail($id);
         $pid = $request->user()->profile_id;
@@ -553,6 +570,7 @@ class GroupController extends GroupFederationController
 
     public function showProfile(Request $request, $id, $pid)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = Group::find($id);
 
         if (! $group || $group->status) {
@@ -564,6 +582,7 @@ class GroupController extends GroupFederationController
 
     public function showProfileByUsername(Request $request, $id, $pid)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         if (! $request->user()) {
             return redirect("/{$pid}");
@@ -597,6 +616,7 @@ class GroupController extends GroupFederationController
 
     public function groupInviteLanding(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort(404, 'Not yet implemented');
         $group = Group::findOrFail($id);
 
@@ -605,6 +625,7 @@ class GroupController extends GroupFederationController
 
     public function groupShortLinkRedirect(Request $request, $hid)
     {
+        abort_unless(config('groups.enabled'), 404);
         $gid = HashidService::decode($hid);
         $group = Group::findOrFail($gid);
 
@@ -613,6 +634,7 @@ class GroupController extends GroupFederationController
 
     public function groupInviteClaim(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         $group = GroupService::get($id);
         abort_if(! $group || empty($group), 404);
 
@@ -621,6 +643,7 @@ class GroupController extends GroupFederationController
 
     public function groupMemberInviteCheck(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $pid = $request->user()->profile_id;
         $group = Group::findOrFail($id);
@@ -636,6 +659,7 @@ class GroupController extends GroupFederationController
 
     public function groupMemberInviteAccept(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $pid = $request->user()->profile_id;
         $group = Group::findOrFail($id);
@@ -661,6 +685,7 @@ class GroupController extends GroupFederationController
 
     public function groupMemberInviteDecline(Request $request, $id)
     {
+        abort_unless(config('groups.enabled'), 404);
         abort_if(! $request->user(), 404);
         $pid = $request->user()->profile_id;
         $group = Group::findOrFail($id);

+ 142 - 57
app/Http/Controllers/ImportPostController.php

@@ -29,6 +29,8 @@ class ImportPostController extends Controller
 
             'allow_video_posts' => config('import.instagram.allow_video_posts'),
 
+            'allow_image_webp' => config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp'),
+
             'permissions' => [
                 'admins_only' => config('import.instagram.permissions.admins_only'),
                 'admin_follows_only' => config('import.instagram.permissions.admin_follows_only'),
@@ -101,67 +103,95 @@ class ImportPostController extends Controller
 
         $uid = $request->user()->id;
         $pid = $request->user()->profile_id;
+        $successCount = 0;
+        $errors = [];
+
         foreach($request->input('files') as $file) {
-            $media = $file['media'];
-            $c = collect($media);
-            $postHash = hash('sha256', $c->toJson());
-            $exts = $c->map(function($m) {
-                $fn = last(explode('/', $m['uri']));
-                return last(explode('.', $fn));
-            });
-            $postType = 'photo';
-
-            if($exts->count() > 1) {
-                if($exts->contains('mp4')) {
-                    if($exts->contains('jpg', 'png')) {
-                        $postType = 'photo:video:album';
-                    } else {
-                        $postType = 'video:album';
-                    }
-                } else {
-                    $postType = 'photo:album';
+            try {
+                $media = $file['media'];
+                $c = collect($media);
+
+                $firstUri = isset($media[0]['uri']) ? $media[0]['uri'] : '';
+                $postHash = hash('sha256', $c->toJson() . $firstUri);
+
+                $exists = ImportPost::where('user_id', $uid)
+                    ->where('post_hash', $postHash)
+                    ->exists();
+
+                if ($exists) {
+                    $errors[] = "Duplicate post detected. Skipping...";
+                    continue;
                 }
-            } else {
-                if(in_array($exts[0], ['jpg', 'png'])) {
-                    $postType = 'photo';
-                } else if(in_array($exts[0], ['mp4'])) {
-                    $postType = 'video';
+
+                $exts = $c->map(function($m) {
+                    $fn = last(explode('/', $m['uri']));
+                    return last(explode('.', $fn));
+                });
+
+                $postType = $this->determinePostType($exts);
+
+                $ip = new ImportPost;
+                $ip->user_id = $uid;
+                $ip->profile_id = $pid;
+                $ip->post_hash = $postHash;
+                $ip->service = 'instagram';
+                $ip->post_type = $postType;
+                $ip->media_count = $c->count();
+
+                $ip->media = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'title' => $this->formatHashtags($m['title'] ?? ''),
+                        'creation_timestamp' => $m['creation_timestamp'] ?? null
+                    ];
+                })->toArray();
+
+                $ip->caption = $c->count() > 1 ?
+                    $this->formatHashtags($file['title'] ?? '') :
+                    $this->formatHashtags($ip->media[0]['title'] ?? '');
+
+                $originalFilename = last(explode('/', $ip->media[0]['uri'] ?? ''));
+                $ip->filename = $this->sanitizeFilename($originalFilename);
+
+                $ip->metadata = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
+                    ];
+                })->toArray();
+
+                $creationTimestamp = $c->count() > 1 ?
+                    ($file['creation_timestamp'] ?? null) :
+                    ($media[0]['creation_timestamp'] ?? null);
+
+                if ($creationTimestamp) {
+                    $ip->creation_date = now()->parse($creationTimestamp);
+                    $ip->creation_year = $ip->creation_date->format('y');
+                    $ip->creation_month = $ip->creation_date->format('m');
+                    $ip->creation_day = $ip->creation_date->format('d');
+                } else {
+                    $ip->creation_date = now();
+                    $ip->creation_year = now()->format('y');
+                    $ip->creation_month = now()->format('m');
+                    $ip->creation_day = now()->format('d');
                 }
-            }
 
-            $ip = new ImportPost;
-            $ip->user_id = $uid;
-            $ip->profile_id = $pid;
-            $ip->post_hash = $postHash;
-            $ip->service = 'instagram';
-            $ip->post_type = $postType;
-            $ip->media_count = $c->count();
-            $ip->media = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'title' => $this->formatHashtags($m['title']),
-                    'creation_timestamp' => $m['creation_timestamp']
-                ];
-            })->toArray();
-            $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
-            $ip->filename = last(explode('/', $ip->media[0]['uri']));
-            $ip->metadata = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
-                ];
-            })->toArray();
-            $ip->creation_date = $c->count() > 1 ? now()->parse($file['creation_timestamp']) : now()->parse($media[0]['creation_timestamp']);
-            $ip->creation_year = now()->parse($ip->creation_date)->format('y');
-            $ip->creation_month = now()->parse($ip->creation_date)->format('m');
-            $ip->creation_day = now()->parse($ip->creation_date)->format('d');
-            $ip->save();
-
-            ImportService::getImportedFiles($pid, true);
-            ImportService::getPostCount($pid, true);
+                $ip->save();
+                $successCount++;
+
+                ImportService::getImportedFiles($pid, true);
+                ImportService::getPostCount($pid, true);
+            } catch (\Exception $e) {
+                $errors[] = $e->getMessage();
+                \Log::error('Import error: ' . $e->getMessage());
+                continue;
+            }
         }
+
         return [
-            'msg' => 'Success'
+            'success' => true,
+            'msg' => 'Successfully imported ' . $successCount . ' posts',
+            'errors' => $errors
         ];
     }
 
@@ -171,7 +201,17 @@ class ImportPostController extends Controller
 
         $this->checkPermissions($request);
 
-        $mimes = config('import.instagram.allow_video_posts') ? 'mimetypes:image/png,image/jpeg,video/mp4' : 'mimetypes:image/png,image/jpeg';
+        $allowedMimeTypes = ['image/png', 'image/jpeg'];
+
+        if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
+            $allowedMimeTypes[] = 'image/webp';
+        }
+
+        if (config('import.instagram.allow_video_posts')) {
+            $allowedMimeTypes[] = 'video/mp4';
+        }
+
+        $mimes = 'mimetypes:' . implode(',', $allowedMimeTypes);
 
         $this->validate($request, [
             'file' => 'required|array|max:10',
@@ -184,7 +224,12 @@ class ImportPostController extends Controller
         ]);
 
         foreach($request->file('file') as $file) {
-            $fileName = $file->getClientOriginalName();
+            $extension = $file->getClientOriginalExtension();
+
+            $originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
+            $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+            $fileName = $safeFilename . '.' . $extension;
+
             $file->storeAs('imports/' . $request->user()->id . '/', $fileName);
         }
 
@@ -195,6 +240,46 @@ class ImportPostController extends Controller
         ];
     }
 
+
+    private function determinePostType($exts)
+    {
+        if ($exts->count() > 1) {
+            if ($exts->contains('mp4')) {
+                if ($exts->contains('jpg', 'png', 'webp')) {
+                    return 'photo:video:album';
+                } else {
+                    return 'video:album';
+                }
+            } else {
+                return 'photo:album';
+            }
+        } else {
+            if ($exts->isEmpty()) {
+                return 'photo';
+            }
+
+            $ext = $exts[0];
+
+            if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'])) {
+                return 'photo';
+            } else if (in_array($ext, ['mp4'])) {
+                return 'video';
+            } else {
+                return 'photo';
+            }
+        }
+    }
+
+    private function sanitizeFilename($filename)
+    {
+        $parts = explode('.', $filename);
+        $extension = array_pop($parts);
+        $originalName = implode('.', $parts);
+
+        $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+        return $safeFilename . '.' . $extension;
+    }
+
     protected function checkPermissions($request, $abortOnFail = true)
     {
         $user = $request->user();

+ 11 - 4
app/Http/Controllers/MediaController.php

@@ -9,7 +9,6 @@ class MediaController extends Controller
 {
     public function index(Request $request)
     {
-        //return view('settings.drive.index');
         abort(404);
     }
 
@@ -20,13 +19,21 @@ class MediaController extends Controller
 
     public function fallbackRedirect(Request $request, $pid, $mhash, $uhash, $f)
     {
-        abort_if(! (bool) config_cache('pixelfed.cloud_storage'), 404);
+        if (! (bool) config_cache('pixelfed.cloud_storage')) {
+            return redirect('/storage/no-preview.png', 302);
+        }
+
         $path = 'public/m/_v2/'.$pid.'/'.$mhash.'/'.$uhash.'/'.$f;
+
         $media = Media::whereProfileId($pid)
             ->whereMediaPath($path)
             ->whereNotNull('cdn_url')
-            ->firstOrFail();
+            ->first();
+
+        if (! $media) {
+            return redirect('/storage/no-preview.png', 302);
+        }
 
-        return redirect()->away($media->cdn_url);
+        return redirect()->away($media->cdn_url, 302);
     }
 }

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

@@ -344,7 +344,7 @@ class ProfileController extends Controller
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 
-        if (strlen($username) > 15 || strlen($username) < 2) {
+        if (strlen($username) > 30 || strlen($username) < 2) {
             return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
         }
 

+ 6 - 2
app/Http/Controllers/ProfileMigrationController.php

@@ -63,8 +63,12 @@ class ProfileMigrationController extends Controller
         AccountService::del($user->profile_id);
 
         Bus::batch([
-            new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
-            new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            [
+                new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
+            ],
+            [
+                new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            ]
         ])->onQueue('follow')->dispatch();
 
         return redirect()->back()->with(['status' => 'Succesfully migrated account!']);

+ 169 - 49
app/Http/Controllers/PublicApiController.php

@@ -35,6 +35,11 @@ class PublicApiController extends Controller
         $this->fractal->setSerializer(new ArraySerializer);
     }
 
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
     protected function getUserData($user)
     {
         if (! $user) {
@@ -667,10 +672,8 @@ class PublicApiController extends Controller
             'only_media' => 'nullable',
             'pinned' => 'nullable',
             'exclude_replies' => 'nullable',
-            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
             'limit' => 'nullable|integer|min:1|max:24',
+            'cursor' => 'nullable',
         ]);
 
         $user = $request->user();
@@ -683,77 +686,194 @@ class PublicApiController extends Controller
         }
 
         $limit = $request->limit ?? 9;
-        $max_id = $request->max_id;
-        $min_id = $request->min_id;
         $scope = ['photo', 'photo:album', 'video', 'video:album'];
         $onlyMedia = $request->input('only_media', true);
+        $pinned = $request->filled('pinned') && $request->boolean('pinned') == true;
+        $hasCursor = $request->filled('cursor');
+
+        $visibility = $this->determineVisibility($profile, $user);
+
+        if (empty($visibility)) {
+            return response()->json([]);
+        }
+
+        $result = collect();
+        $remainingLimit = $limit;
+
+        if ($pinned && ! $hasCursor) {
+            $pinnedStatuses = Status::whereProfileId($profile['id'])
+                ->whereNotNull('pinned_order')
+                ->orderBy('pinned_order')
+                ->get();
+
+            $pinnedResult = $this->processStatuses($pinnedStatuses, $user, $onlyMedia);
+            $result = $pinnedResult;
+
+            $remainingLimit = max(1, $limit - $pinnedResult->count());
+        }
+
+        $paginator = Status::whereProfileId($profile['id'])
+            ->whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->when($pinned, function ($query) {
+                return $query->whereNull('pinned_order');
+            })
+            ->whereIn('type', $scope)
+            ->whereIn('scope', $visibility)
+            ->orderByDesc('id')
+            ->cursorPaginate($remainingLimit)
+            ->withQueryString();
+
+        $headers = $this->generatePaginationHeaders($paginator);
+        $regularStatuses = $this->processStatuses($paginator->items(), $user, $onlyMedia);
+        $result = $result->concat($regularStatuses);
+
+        return response()->json($result, 200, $headers);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
 
-        if (! $min_id && ! $max_id) {
-            $min_id = 1;
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
+
+    private function determineVisibility($profile, $user)
+    {
+        if (! $profile || ! isset($profile['id'])) {
+            return [];
+        }
+
+        if ($user && $profile['id'] == $user->profile_id) {
+            return ['public', 'unlisted', 'private'];
         }
 
         if ($profile['locked']) {
             if (! $user) {
-                return response()->json([]);
+                return [];
             }
+
             $pid = $user->profile_id;
-            $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                $following = Follower::whereProfileId($pid)->pluck('following_id');
+            $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                return $following->push($pid)->toArray();
-            });
-            $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : [];
+            return $isFollowing ? ['public', 'unlisted', 'private'] : ['public'];
         } else {
             if ($user) {
                 $pid = $user->profile_id;
-                $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                    $following = Follower::whereProfileId($pid)->pluck('following_id');
+                $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                    return $following->push($pid)->toArray();
-                });
-                $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+                return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
             } else {
-                $visibility = ['public', 'unlisted'];
+                return ['public', 'unlisted'];
             }
         }
-        $dir = $min_id ? '>' : '<';
-        $id = $min_id ?? $max_id;
-        $res = Status::whereProfileId($profile['id'])
-            ->whereNull('in_reply_to_id')
-            ->whereNull('reblog_of_id')
-            ->whereIn('type', $scope)
-            ->where('id', $dir, $id)
-            ->whereIn('scope', $visibility)
-            ->limit($limit)
-            ->orderByDesc('id')
-            ->get()
-            ->map(function ($s) use ($user) {
-                try {
-                    $status = StatusService::get($s->id, false);
-                } catch (\Exception $e) {
-                    $status = false;
+    }
+
+    private function processStatuses($statuses, $user, $onlyMedia)
+    {
+        return collect($statuses)->map(function ($status) use ($user) {
+            try {
+                $mastodonStatus = StatusService::get($status->id, false);
+                if (! $mastodonStatus) {
+                    return null;
                 }
-                if ($user && $status) {
-                    $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+
+                if ($user) {
+                    $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id);
+                    $mastodonStatus['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status->id);
+                    $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id);
+                }
+
+                return $mastodonStatus;
+            } catch (\Exception $e) {
+                return null;
+            }
+        })
+            ->filter(function ($status) use ($onlyMedia) {
+                if (! $status) {
+                    return false;
                 }
 
-                return $status;
-            })
-            ->filter(function ($s) use ($onlyMedia) {
                 if ($onlyMedia) {
-                    if (
-                        ! isset($s['media_attachments']) ||
-                        ! is_array($s['media_attachments']) ||
-                        empty($s['media_attachments'])
-                    ) {
-                        return false;
-                    }
+                    return isset($status['media_attachments']) &&
+                           is_array($status['media_attachments']) &&
+                           ! empty($status['media_attachments']);
                 }
 
-                return $s;
+                return true;
             })
             ->values();
+    }
 
-        return response()->json($res);
+    /**
+     * Generate pagination link headers from paginator
+     */
+    private function generatePaginationHeaders($paginator)
+    {
+        $link = null;
+
+        if ($paginator->onFirstPage()) {
+            if ($paginator->hasMorePages()) {
+                $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        } else {
+            if ($paginator->previousPageUrl()) {
+                $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+            }
+
+            if ($paginator->hasMorePages()) {
+                $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        }
+
+        return isset($link) ? ['Link' => $link] : [];
     }
 }

+ 4 - 33
app/Http/Controllers/RemoteAuthController.php

@@ -14,6 +14,7 @@ use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
+use App\Rules\PixelfedUsername;
 use InvalidArgumentException;
 use Purify;
 
@@ -358,38 +359,8 @@ class RemoteAuthController extends Controller
             'username' => [
                 'required',
                 'min:2',
-                'max:15',
-                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.');
-                    }
-                },
+                'max:30',
+                new PixelfedUsername(),
             ],
         ]);
         $username = strtolower($request->input('username'));
@@ -489,7 +460,7 @@ class RemoteAuthController extends Controller
             'username' => [
                 'required',
                 'min:2',
-                'max:15',
+                'max:30',
                 'unique:users,username',
                 function ($attribute, $value, $fail) {
                     $dash = substr_count($value, '-');

+ 121 - 0
app/Http/Controllers/RemoteOidcController.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserOidcMapping;
+use Purify;
+use App\Services\EmailService;
+use App\Services\UserOidcService;
+use App\User;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use App\Rules\EmailNotBanned;
+use App\Rules\PixelfedUsername;
+
+class RemoteOidcController extends Controller
+{
+    protected $fractal;
+
+    public function start(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        $url = $provider->getAuthorizationUrl([
+            'scope' => $provider->getDefaultScopes(),
+        ]);
+
+        $request->session()->put('oauth2state', $provider->getState());
+
+        return redirect($url);
+    }
+
+    public function handleCallback(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        abort_unless($request->input("state"), 400);
+        abort_unless($request->input("code"), 400);
+
+        abort_unless(hash_equals($request->session()->pull('oauth2state'), $request->input("state")), 400, "invalid state");
+
+        $accessToken = $provider->getAccessToken('authorization_code', [
+            'code' => $request->get('code')
+        ]);
+
+        $userInfo = $provider->getResourceOwner($accessToken);
+        $userInfoId = $userInfo->getId();
+        $userInfoData = $userInfo->toArray();
+
+        $mappedUser = UserOidcMapping::where('oidc_id', $userInfoId)->first();
+        if ($mappedUser) {
+            $this->guarder()->login($mappedUser->user);
+            return redirect('/');
+        }
+
+        abort_if(EmailService::isBanned($userInfoData["email"]), 400, 'Banned email.');
+
+        $user = $this->createUser([
+            'username' => $userInfoData[config('remote-auth.oidc.field_username')],
+            'name' => $userInfoData["name"] ?? $userInfoData["display_name"] ?? $userInfoData[config('remote-auth.oidc.field_username')] ?? null,
+            'email' => $userInfoData["email"],
+        ]);
+
+        UserOidcMapping::create([
+            'user_id' => $user->id,
+            'oidc_id' => $userInfoId,
+        ]);
+
+        return redirect('/');
+    }
+
+    protected function createUser($data)
+    {
+        $this->validate(new Request($data), [
+            'email' => [
+                'required',
+                'string',
+                'email:strict,filter_unicode,dns,spoof',
+                'max:255',
+                'unique:users',
+                new EmailNotBanned(),
+            ],
+            'username' => [
+                'required',
+                'min:2',
+                'max:30',
+                'unique:users,username',
+                new PixelfedUsername(),
+            ],
+            'name' => 'nullable|max:30',
+        ]);
+
+        event(new Registered($user = User::create([
+            'name' => Purify::clean($data['name']),
+            'username' => $data['username'],
+            'email' => $data['email'],
+            'password' => Hash::make(Str::password()),
+            'email_verified_at' => now(),
+            'app_register_ip' => request()->ip(),
+            'register_source' => 'oidc',
+        ])));
+
+        $this->guarder()->login($user);
+
+        return $user;
+    }
+
+    protected function guarder()
+    {
+        return Auth::guard();
+    }
+}

+ 81 - 34
app/Http/Controllers/ReportController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
+use App\Models\Group;
 use App\Profile;
 use App\Report;
 use App\Status;
-use App\User;
 use Auth;
 use Illuminate\Http\Request;
-use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
 
 class ReportController extends Controller
 {
@@ -22,10 +22,33 @@ class ReportController extends Controller
     public function showForm(Request $request)
     {
         $this->validate($request, [
-          'type'    => 'required|alpha_dash',
-          'id'      => 'required|integer|min:1',
+            'type' => 'required|alpha_dash|in:comment,group,post,user',
+            'id' => 'required|integer|min:1',
         ]);
 
+        $type = $request->input('type');
+        $id = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        switch ($request->input('type')) {
+            case 'post':
+            case 'comment':
+                Status::findOrFail($id);
+                break;
+
+            case 'user':
+                Profile::findOrFail($id);
+                break;
+
+            case 'group':
+                Group::where('profile_id', '!=', $pid)->findOrFail($id);
+                break;
+
+            default:
+                // code...
+                break;
+        }
+
         return view('report.form');
     }
 
@@ -87,10 +110,10 @@ class ReportController extends Controller
     public function formStore(Request $request)
     {
         $this->validate($request, [
-            'report'  => 'required|alpha_dash',
-            'type'    => 'required|alpha_dash',
-            'id'      => 'required|integer|min:1',
-            'msg'     => 'nullable|string|max:150',
+            'report' => 'required|alpha_dash',
+            'type' => 'required|alpha_dash',
+            'id' => 'required|integer|min:1',
+            'msg' => 'nullable|string|max:150',
         ]);
 
         $profile = Auth::user()->profile;
@@ -101,8 +124,8 @@ class ReportController extends Controller
         $object = null;
         $types = [
             // original 3
-            'spam', 
-            'sensitive', 
+            'spam',
+            'sensitive',
             'abusive',
 
             // new
@@ -110,38 +133,62 @@ class ReportController extends Controller
             'copyright',
             'impersonation',
             'scam',
-            'terrorism'
+            'terrorism',
         ];
 
-        if (!in_array($reportType, $types)) {
-            if($request->wantsJson()) {
+        if (! in_array($reportType, $types)) {
+            if ($request->wantsJson()) {
                 return abort(400, 'Invalid report type');
             } else {
                 return redirect('/timeline')->with('error', 'Invalid report type');
             }
         }
 
+        $rpid = null;
+
         switch ($object_type) {
-        case 'post':
-          $object = Status::findOrFail($object_id);
-          $object_type = 'App\Status';
-          $exists = Report::whereUserId(Auth::id())
+            case 'post':
+                $object = Status::findOrFail($object_id);
+                $object_type = 'App\Status';
+                $exists = Report::whereUserId(Auth::id())
                     ->whereObjectId($object->id)
                     ->whereObjectType('App\Status')
                     ->count();
-          break;
 
-        default:
-            if($request->wantsJson()) {
-                return abort(400, 'Invalid report type');
-            } else {
-                return redirect('/timeline')->with('error', 'Invalid report type');
-            }
-          break;
-      }
+                $rpid = $object->profile_id;
+                break;
+
+            case 'user':
+                $object = Profile::findOrFail($object_id);
+                $object_type = 'App\Profile';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Profile')
+                    ->count();
+                $rpid = $object->id;
+                break;
+
+            case 'group':
+                $object = Group::findOrFail($object_id);
+                $object_type = 'App\Models\Group';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Models\Group')
+                    ->count();
+                $rpid = $object->profile_id;
+                break;
+
+            default:
+                if ($request->wantsJson()) {
+                    return abort(400, 'Invalid report type');
+                } else {
+                    return redirect('/timeline')->with('error', 'Invalid report type');
+                }
+                break;
+        }
 
         if ($exists !== 0) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You have already reported this!');
@@ -149,28 +196,28 @@ class ReportController extends Controller
         }
 
         if ($object->profile_id == $profile->id) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You cannot report your own content!');
             }
         }
 
-        $report = new Report();
+        $report = new Report;
         $report->profile_id = $profile->id;
         $report->user_id = Auth::id();
         $report->object_id = $object->id;
         $report->object_type = $object_type;
-        $report->reported_profile_id = $object->profile_id;
+        $report->reported_profile_id = $rpid;
         $report->type = $request->input('report');
         $report->message = e($request->input('msg'));
         $report->save();
 
-        if(config('instance.reports.email.enabled')) {
-			ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
-		}
+        if (config('instance.reports.email.enabled')) {
+            ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
+        }
 
-        if($request->wantsJson()) {
+        if ($request->wantsJson()) {
             return response()->json(200);
         } else {
             return redirect('/timeline')->with('status', 'Report successfully sent!');

+ 212 - 95
app/Http/Controllers/Settings/ExportSettings.php

@@ -2,29 +2,27 @@
 
 namespace App\Http\Controllers\Settings;
 
-use App\AccountLog;
-use App\Following;
-use App\Report;
 use App\Status;
+use App\Transformer\ActivityPub\ProfileTransformer;
+use App\Transformer\Api\StatusTransformer as StatusApiTransformer;
 use App\UserFilter;
-use Auth, Cookie, DB, Cache, Purify;
-use Carbon\Carbon;
+use Auth;
+use Cache;
 use Illuminate\Http\Request;
-use App\Transformer\ActivityPub\{
-	ProfileTransformer,
-	StatusTransformer
-};
-use App\Transformer\Api\StatusTransformer as StatusApiTransformer;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use Storage;
 
 trait ExportSettings
 {
-	public function __construct()
-	{
-		$this->middleware('auth');
-	}
+    private const CHUNK_SIZE = 1000;
+
+    private const STORAGE_BASE = 'user_exports';
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
 
     public function dataExport()
     {
@@ -33,47 +31,146 @@ trait ExportSettings
 
     public function exportAccount()
     {
-    	$data = Cache::remember('account:export:profile:actor:'.Auth::user()->profile->id, now()->addMinutes(60), function() {
-			$profile = Auth::user()->profile;
-			$fractal = new Fractal\Manager();
-			$fractal->setSerializer(new ArraySerializer());
-			$resource = new Fractal\Resource\Item($profile, new ProfileTransformer());
-			return $fractal->createData($resource)->toArray();
-    	});
-
-    	return response()->streamDownload(function () use ($data) {
-    		echo json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
-    	}, 'account.json', [
-    		'Content-Type' => 'application/json'
-    	]);
+        $profile = Auth::user()->profile;
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Item($profile, new ProfileTransformer);
+
+        $data = $fractal->createData($resource)->toArray();
+
+        return response()->streamDownload(function () use ($data) {
+            echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+        }, 'account.json', [
+            'Content-Type' => 'application/json',
+        ]);
     }
 
     public function exportFollowing()
     {
-        $data = Cache::remember('account:export:profile:following:'.Auth::user()->profile->id, now()->addMinutes(60), function() {
-            return Auth::user()->profile->following()->get()->map(function($i) {
-                return $i->url();
-            });
-        });
-        return response()->streamDownload(function () use($data) {
-            echo $data;
-        }, 'following.json', [
-    		'Content-Type' => 'application/json'
-    	]);
+        $profile = Auth::user()->profile;
+        $userId = Auth::id();
+
+        $userExportPath = 'user_exports/'.$userId;
+        $filename = 'pixelfed-following.json';
+        $tempPath = $userExportPath.'/'.$filename;
+
+        if (! Storage::exists($userExportPath)) {
+            Storage::makeDirectory($userExportPath);
+        }
+
+        try {
+            Storage::put($tempPath, '[');
+
+            $profile->following()
+                ->chunk(1000, function ($following) use ($tempPath) {
+                    $urls = $following->map(function ($follow) {
+                        return $follow->url();
+                    });
+
+                    $json = json_encode($urls,
+                        JSON_PRETTY_PRINT |
+                        JSON_UNESCAPED_SLASHES |
+                        JSON_UNESCAPED_UNICODE
+                    );
+
+                    $json = trim($json, '[]');
+                    if (Storage::size($tempPath) > 1) {
+                        $json = ','.$json;
+                    }
+
+                    Storage::append($tempPath, $json);
+                });
+
+            Storage::append($tempPath, ']');
+
+            return response()->stream(
+                function () use ($tempPath) {
+                    $handle = fopen(Storage::path($tempPath), 'rb');
+                    while (! feof($handle)) {
+                        echo fread($handle, 8192);
+                        flush();
+                    }
+                    fclose($handle);
+
+                    Storage::delete($tempPath);
+                },
+                200,
+                [
+                    'Content-Type' => 'application/json',
+                    'Content-Disposition' => 'attachment; filename="pixelfed-following.json"',
+                ]
+            );
+
+        } catch (\Exception $e) {
+            if (Storage::exists($tempPath)) {
+                Storage::delete($tempPath);
+            }
+            throw $e;
+        }
     }
 
     public function exportFollowers()
     {
-        $data = Cache::remember('account:export:profile:followers:'.Auth::user()->profile->id, now()->addMinutes(60), function() {
-            return Auth::user()->profile->followers()->get()->map(function($i) {
-                return $i->url();
-            });
-        });
-        return response()->streamDownload(function () use($data) {
-            echo $data;
-        }, 'followers.json', [
-    		'Content-Type' => 'application/json'
-    	]);
+        $profile = Auth::user()->profile;
+        $userId = Auth::id();
+
+        $userExportPath = 'user_exports/'.$userId;
+        $filename = 'pixelfed-followers.json';
+        $tempPath = $userExportPath.'/'.$filename;
+
+        if (! Storage::exists($userExportPath)) {
+            Storage::makeDirectory($userExportPath);
+        }
+
+        try {
+            Storage::put($tempPath, '[');
+
+            $profile->followers()
+                ->chunk(1000, function ($followers) use ($tempPath) {
+                    $urls = $followers->map(function ($follower) {
+                        return $follower->url();
+                    });
+
+                    $json = json_encode($urls,
+                        JSON_PRETTY_PRINT |
+                        JSON_UNESCAPED_SLASHES |
+                        JSON_UNESCAPED_UNICODE
+                    );
+
+                    $json = trim($json, '[]');
+                    if (Storage::size($tempPath) > 1) {
+                        $json = ','.$json;
+                    }
+
+                    Storage::append($tempPath, $json);
+                });
+
+            Storage::append($tempPath, ']');
+
+            return response()->stream(
+                function () use ($tempPath) {
+                    $handle = fopen(Storage::path($tempPath), 'rb');
+                    while (! feof($handle)) {
+                        echo fread($handle, 8192);
+                        flush();
+                    }
+                    fclose($handle);
+
+                    Storage::delete($tempPath);
+                },
+                200,
+                [
+                    'Content-Type' => 'application/json',
+                    'Content-Disposition' => 'attachment; filename="pixelfed-followers.json"',
+                ]
+            );
+
+        } catch (\Exception $e) {
+            if (Storage::exists($tempPath)) {
+                Storage::delete($tempPath);
+            }
+            throw $e;
+        }
     }
 
     public function exportMuteBlockList()
@@ -82,63 +179,83 @@ trait ExportSettings
         $exists = UserFilter::select('id')
             ->whereUserId($profile->id)
             ->exists();
-        if(!$exists) {
+        if (! $exists) {
             return redirect()->back();
         }
-        $data = Cache::remember('account:export:profile:muteblocklist:'.Auth::user()->profile->id, now()->addMinutes(60), function() use($profile) {
+        $data = Cache::remember('account:export:profile:muteblocklist:'.Auth::user()->profile->id, now()->addMinutes(60), function () use ($profile) {
             return json_encode([
                 'muted' => $profile->mutedProfileUrls(),
-                'blocked' => $profile->blockedProfileUrls()
+                'blocked' => $profile->blockedProfileUrls(),
             ], JSON_PRETTY_PRINT);
         });
-        return response()->streamDownload(function () use($data) {
+
+        return response()->streamDownload(function () use ($data) {
             echo $data;
         }, 'muted-and-blocked-accounts.json', [
-    		'Content-Type' => 'application/json'
-    	]);
+            'Content-Type' => 'application/json',
+        ]);
     }
 
     public function exportStatuses(Request $request)
     {
-    	$this->validate($request, [
-    		'type' => 'required|string|in:ap,api'
-    	]);
-    	$limit = 500;
-
-    	$profile = Auth::user()->profile;
-    	$type = 'ap';
-
-    	$count = Status::select('id')->whereProfileId($profile->id)->count();
-    	if($count > $limit) {
-    		// fire background job
-    		return redirect('/settings/data-export')->with(['status' => 'You have more than '.$limit.' statuses, we do not support full account export yet.']);
-    	}
-
-    	$filename = 'outbox.json';
-		if($type == 'ap') {
-			$data = Cache::remember('account:export:profile:statuses:ap:'.Auth::user()->profile->id, now()->addHours(1), function() {
-				$profile = Auth::user()->profile->statuses;
-				$fractal = new Fractal\Manager();
-				$fractal->setSerializer(new ArraySerializer());
-				$resource = new Fractal\Resource\Collection($profile, new StatusTransformer());
-				return $fractal->createData($resource)->toArray();
-			});
-		} else {
-			$filename = 'api-statuses.json';
-			$data = Cache::remember('account:export:profile:statuses:api:'.Auth::user()->profile->id, now()->addHours(1), function() {
-				$profile = Auth::user()->profile->statuses;
-				$fractal = new Fractal\Manager();
-				$fractal->setSerializer(new ArraySerializer());
-				$resource = new Fractal\Resource\Collection($profile, new StatusApiTransformer());
-				return $fractal->createData($resource)->toArray();
-			});
-		}
-
-    	return response()->streamDownload(function () use ($data, $filename) {
-    		echo json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
-    	}, $filename, [
-    		'Content-Type' => 'application/json'
-    	]);
-    }
+        $profile = Auth::user()->profile;
+        $userId = Auth::id();
+        $userExportPath = self::STORAGE_BASE.'/'.$userId;
+        $filename = 'pixelfed-statuses.json';
+        $tempPath = $userExportPath.'/'.$filename;
+
+        if (! Storage::exists($userExportPath)) {
+            Storage::makeDirectory($userExportPath);
+        }
+
+        Storage::put($tempPath, '[');
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+
+        try {
+            Status::whereProfileId($profile->id)
+                ->chunk(self::CHUNK_SIZE, function ($statuses) use ($fractal, $tempPath) {
+                    $resource = new Fractal\Resource\Collection($statuses, new StatusApiTransformer);
+                    $data = $fractal->createData($resource)->toArray();
+
+                    $json = json_encode($data,
+                        JSON_PRETTY_PRINT |
+                        JSON_UNESCAPED_SLASHES |
+                        JSON_UNESCAPED_UNICODE
+                    );
 
-}
+                    $json = trim($json, '[]');
+                    if (Storage::size($tempPath) > 1) {
+                        $json = ','.$json;
+                    }
+
+                    Storage::append($tempPath, $json);
+                });
+
+            Storage::append($tempPath, ']');
+
+            return response()->stream(
+                function () use ($tempPath) {
+                    $handle = fopen(Storage::path($tempPath), 'rb');
+                    while (! feof($handle)) {
+                        echo fread($handle, 8192);
+                        flush();
+                    }
+                    fclose($handle);
+                    Storage::delete($tempPath);
+                },
+                200,
+                [
+                    'Content-Type' => 'application/json',
+                    'Content-Disposition' => 'attachment; filename="pixelfed-statuses.json"',
+                ]
+            );
+
+        } catch (\Exception $e) {
+            if (Storage::exists($tempPath)) {
+                Storage::delete($tempPath);
+            }
+            throw $e;
+        }
+    }
+}

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

@@ -350,4 +350,9 @@ class SettingsController extends Controller
 
         return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
     }
+
+    public function filtersHome(Request $request)
+    {
+        return view('settings.filters.home');
+    }
 }

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

@@ -119,7 +119,7 @@ class SiteController extends Controller
     public function followIntent(Request $request)
     {
         $this->validate($request, [
-            'user' => 'string|min:1|max:15|exists:users,username',
+            'user' => 'string|min:1|max:30|exists:users,username',
         ]);
         $profile = Profile::whereUsername($request->input('user'))->firstOrFail();
         $user = $request->user();

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

@@ -27,7 +27,7 @@ class UserEmailForgotController extends Controller
     public function store(Request $request)
     {
         $rules = [
-            'username' => 'required|min:2|max:15|exists:users'
+            'username' => 'required|min:2|max:30|exists:users'
         ];
 
         $messages = [

+ 2 - 1
app/Http/Middleware/VerifyCsrfToken.php

@@ -12,6 +12,7 @@ class VerifyCsrfToken extends Middleware
      * @var array
      */
     protected $except = [
-        '/api/v1/*'
+        '/api/v1/*',
+        'oauth/token'
     ];
 }

+ 1 - 1
app/Http/Requests/Status/StoreStatusEditRequest.php

@@ -44,7 +44,7 @@ class StoreStatusEditRequest extends FormRequest
     public function rules(): array
     {
         return [
-            'status' => 'sometimes|max:'.config('pixelfed.max_caption_length', 500),
+            'status' => 'sometimes|max:'.config_cache('pixelfed.max_caption_length', 500),
             'spoiler_text' => 'nullable|string|max:140',
             'sensitive' => 'sometimes|boolean',
             'media_ids' => [

+ 57 - 56
app/Jobs/ImageOptimizePipeline/ImageUpdate.php

@@ -16,70 +16,71 @@ use App\Jobs\MediaPipeline\MediaStoragePipeline;
 
 class ImageUpdate implements ShouldQueue
 {
-	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
-	protected $media;
+    protected $media;
 
-	protected $protectedMimes = [
-		'image/jpeg',
-		'image/png',
-		'image/webp'
-	];
+    protected $protectedMimes = [
+        'image/jpeg',
+        'image/png',
+        'image/webp',
+        'image/avif'
+    ];
 
-	/**
-	 * 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(Media $media)
-	{
-		$this->media = $media;
-	}
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Media $media)
+    {
+        $this->media = $media;
+    }
 
-	/**
-	 * Execute the job.
-	 *
-	 * @return void
-	 */
-	public function handle()
-	{
-		$media = $this->media;
-		if(!$media) {
-			return;
-		}
-		$path = storage_path('app/'.$media->media_path);
-		$thumb = storage_path('app/'.$media->thumbnail_path);
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $media = $this->media;
+        if(!$media) {
+            return;
+        }
+        $path = storage_path('app/'.$media->media_path);
+        $thumb = storage_path('app/'.$media->thumbnail_path);
 
-		if (!is_file($path)) {
-			return;
-		}
+        if (!is_file($path)) {
+            return;
+        }
 
-		if((bool) config_cache('pixelfed.optimize_image')) {
-			if (in_array($media->mime, $this->protectedMimes) == true) {
-				ImageOptimizer::optimize($thumb);
-				if(!$media->skip_optimize) {
-					ImageOptimizer::optimize($path);
-				}
-			}
-		}
+        if((bool) config_cache('pixelfed.optimize_image')) {
+            if (in_array($media->mime, $this->protectedMimes) == true) {
+                ImageOptimizer::optimize($thumb);
+                if(!$media->skip_optimize) {
+                    ImageOptimizer::optimize($path);
+                }
+            }
+        }
 
-		if (!is_file($path) || !is_file($thumb)) {
-			return;
-		}
+        if (!is_file($path) || !is_file($thumb)) {
+            return;
+        }
 
-		$photo_size = filesize($path);
-		$thumb_size = filesize($thumb);
-		$total = ($photo_size + $thumb_size);
-		$media->size = $total;
-		$media->save();
+        $photo_size = filesize($path);
+        $thumb_size = filesize($thumb);
+        $total = ($photo_size + $thumb_size);
+        $media->size = $total;
+        $media->save();
 
-		MediaStoragePipeline::dispatch($media);
-	}
+        MediaStoragePipeline::dispatch($media);
+    }
 }

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

@@ -73,6 +73,7 @@ class FetchNodeinfoPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessin
                 $instance->user_count = Profile::whereDomain($instance->domain)->count();
                 $instance->nodeinfo_last_fetched = now();
                 $instance->last_crawled_at = now();
+                $instance->delivery_timeout = 0;
                 $instance->save();
             }
         } else {

+ 14 - 1
app/Jobs/MentionPipeline/MentionPipeline.php

@@ -5,11 +5,15 @@ namespace App\Jobs\MentionPipeline;
 use App\Mention;
 use App\Notification;
 use App\Status;
+use App\User;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
+use App\Jobs\PushNotificationPipeline\MentionPushNotifyPipeline;
+use App\Services\NotificationAppGatewayService;
+use App\Services\PushNotificationService;
 use App\Services\StatusService;
 
 class MentionPipeline implements ShouldQueue
@@ -57,7 +61,7 @@ class MentionPipeline implements ShouldQueue
                   ->count();
 
         if ($actor->id === $target || $exists !== 0) {
-            return true;
+            return;
         }
 
         Notification::firstOrCreate(
@@ -71,5 +75,14 @@ class MentionPipeline implements ShouldQueue
         );
 
         StatusService::del($status->id);
+
+        if (NotificationAppGatewayService::enabled()) {
+            if (PushNotificationService::check('mention', $target)) {
+                $user = User::whereProfileId($target)->first();
+                if ($user && $user->expo_token && $user->notify_enabled) {
+                    MentionPushNotifyPipeline::dispatch($user->expo_token, $actor->username)->onQueue('pushnotify');
+                }
+            }
+        }
     }
 }

+ 90 - 0
app/Jobs/NotificationPipeline/NotificationWarmUserCache.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Jobs\NotificationPipeline;
+
+use App\Services\NotificationService;
+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 Illuminate\Support\Facades\Log;
+
+class NotificationWarmUserCache implements ShouldBeUnique, ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * The profile ID to warm cache for.
+     *
+     * @var int
+     */
+    public $pid;
+
+    /**
+     * The number of times the job may be attempted.
+     *
+     * @var int
+     */
+    public $tries = 3;
+
+    /**
+     * The number of seconds to wait before retrying the job.
+     * This creates exponential backoff: 10s, 30s, 90s
+     *
+     * @var array
+     */
+    public $backoff = [10, 30, 90];
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600; // 1 hour
+
+    /**
+     * The maximum number of unhandled exceptions to allow before failing.
+     *
+     * @var int
+     */
+    public $maxExceptions = 2;
+
+    /**
+     * Create a new job instance.
+     *
+     * @param  int  $pid  The profile ID to warm cache for
+     * @return void
+     */
+    public function __construct(int $pid)
+    {
+        $this->pid = $pid;
+    }
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'notifications:profile_warm_cache:'.$this->pid;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        try {
+            NotificationService::warmCache($this->pid, 100, true);
+        } catch (\Exception $e) {
+            Log::error('Failed to warm notification cache', [
+                'profile_id' => $this->pid,
+                'exception' => get_class($e),
+                'message' => $e->getMessage(),
+                'attempt' => $this->attempts(),
+            ]);
+            throw $e;
+        }
+    }
+}

+ 1 - 1
app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php

@@ -117,7 +117,7 @@ class ProfileMigrationDeliverMoveActivityPipeline implements ShouldBeUniqueUntil
                             CURLOPT_HTTPHEADER => $headers,
                             CURLOPT_POSTFIELDS => $payload,
                             CURLOPT_HEADER => true,
-                            CURLOPT_SSL_VERIFYPEER => false,
+                            CURLOPT_SSL_VERIFYPEER => true,
                             CURLOPT_SSL_VERIFYHOST => false,
                         ],
                     ]);

+ 93 - 7
app/Jobs/StatusPipeline/NewStatusPipeline.php

@@ -2,21 +2,21 @@
 
 namespace App\Jobs\StatusPipeline;
 
+use App\Media;
 use App\Status;
-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 Illuminate\Support\Facades\Log;
 
 class NewStatusPipeline implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
-    
+
     /**
      * Delete the job if its models no longer exist.
      *
@@ -24,9 +24,27 @@ class NewStatusPipeline implements ShouldQueue
      */
     public $deleteWhenMissingModels = true;
 
-    public $timeout = 5;
-    public $tries = 1;
-    
+    /**
+     * Increased timeout to handle cloud storage operations
+     *
+     * @var int
+     */
+    public $timeout = 30;
+
+    /**
+     * Number of times to attempt the job
+     *
+     * @var int
+     */
+    public $tries = 3;
+
+    /**
+     * Backoff periods between retries (in seconds)
+     *
+     * @var array
+     */
+    public $backoff = [30, 60, 120];
+
     /**
      * Create a new job instance.
      *
@@ -44,6 +62,74 @@ class NewStatusPipeline implements ShouldQueue
      */
     public function handle()
     {
-        StatusEntityLexer::dispatch($this->status);
+        // Skip media check if cloud storage isn't enabled or fast processing is on
+        if (! config_cache('pixelfed.cloud_storage') || config('pixelfed.media_fast_process')) {
+            $this->dispatchFederation();
+
+            return;
+        }
+
+        // Check for media still processing
+        $stillProcessing = Media::whereStatusId($this->status->id)
+            ->whereNull('cdn_url')
+            ->exists();
+
+        if ($stillProcessing) {
+            // Get the oldest processing media item
+            $oldestProcessingMedia = Media::whereStatusId($this->status->id)
+                ->whereNull('cdn_url')
+                ->oldest()
+                ->first();
+
+            // If media has been processing for more than 10 minutes, proceed anyway
+            if ($oldestProcessingMedia && $oldestProcessingMedia->replicated_at && $oldestProcessingMedia->replicated_at->diffInMinutes(now()) > 10) {
+                if (config('federation.activitypub.delivery.logger.enabled')) {
+                    Log::warning('Media processing timeout for status '.$this->status->id.'. Proceeding with federation.');
+                }
+                $this->dispatchFederation();
+
+                return;
+            }
+
+            // Release job back to queue with delay of 30 seconds
+            $this->release(30);
+
+            return;
+        }
+
+        // All media processed, proceed with federation
+        $this->dispatchFederation();
+    }
+
+    /**
+     * Dispatch the federation job
+     *
+     * @return void
+     */
+    protected function dispatchFederation()
+    {
+        try {
+            StatusEntityLexer::dispatch($this->status);
+        } catch (\Exception $e) {
+            if (config('federation.activitypub.delivery.logger.enabled')) {
+                Log::error('Federation dispatch failed for status '.$this->status->id.': '.$e->getMessage());
+            }
+            throw $e;
+        }
+    }
+
+    /**
+     * Handle a job failure.
+     *
+     * @return void
+     */
+    public function failed(\Throwable $exception)
+    {
+        if (config('federation.activitypub.delivery.logger.enabled')) {
+            Log::error('NewStatusPipeline failed for status '.$this->status->id, [
+                'exception' => $exception->getMessage(),
+                'trace' => $exception->getTraceAsString(),
+            ]);
+        }
     }
 }

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

@@ -126,7 +126,7 @@ class StatusActivityPubDeliver implements ShouldQueue
 							CURLOPT_HTTPHEADER => $headers,
 							CURLOPT_POSTFIELDS => $payload,
 							CURLOPT_HEADER => true,
-							CURLOPT_SSL_VERIFYPEER => false,
+							CURLOPT_SSL_VERIFYPEER => true,
 							CURLOPT_SSL_VERIFYHOST => false
 						]
 					]);

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

@@ -106,7 +106,7 @@ class StatusLocalUpdateActivityPubDeliverPipeline implements ShouldQueue
 							CURLOPT_HTTPHEADER => $headers,
 							CURLOPT_POSTFIELDS => $payload,
 							CURLOPT_HEADER => true,
-							CURLOPT_SSL_VERIFYPEER => false,
+							CURLOPT_SSL_VERIFYPEER => true,
 							CURLOPT_SSL_VERIFYHOST => false
 						]
 					]);

+ 106 - 71
app/Jobs/StatusPipeline/StatusTagsPipeline.php

@@ -2,27 +2,28 @@
 
 namespace App\Jobs\StatusPipeline;
 
-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\Hashtag;
+use App\Jobs\MentionPipeline\MentionPipeline;
+use App\Mention;
 use App\Services\AccountService;
 use App\Services\CustomEmojiService;
 use App\Services\StatusService;
-use App\Jobs\MentionPipeline\MentionPipeline;
-use App\Mention;
-use App\Hashtag;
-use App\StatusHashtag;
 use App\Services\TrendingHashtagService;
+use App\StatusHashtag;
 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 Illuminate\Support\Facades\DB;
 
 class StatusTagsPipeline implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $activity;
+
     protected $status;
 
     /**
@@ -46,92 +47,126 @@ class StatusTagsPipeline implements ShouldQueue
         $res = $this->activity;
         $status = $this->status;
 
-        if(isset($res['tag']['type'], $res['tag']['name'])) {
+        if (isset($res['tag']['type'], $res['tag']['name'])) {
             $res['tag'] = [$res['tag']];
         }
 
         $tags = collect($res['tag']);
 
         // Emoji
-        $tags->filter(function($tag) {
+        $tags->filter(function ($tag) {
             return $tag && isset($tag['id'], $tag['icon'], $tag['name'], $tag['type']) && $tag['type'] == 'Emoji';
         })
-        ->map(function($tag) {
-            CustomEmojiService::import($tag['id'], $this->status->id);
-        });
+            ->map(function ($tag) {
+                CustomEmojiService::import($tag['id'], $this->status->id);
+            });
 
         // Hashtags
-        $tags->filter(function($tag) {
+        $tags->filter(function ($tag) {
             return $tag && $tag['type'] == 'Hashtag' && isset($tag['href'], $tag['name']);
         })
-        ->map(function($tag) use($status) {
-            $name = substr($tag['name'], 0, 1) == '#' ?
-                substr($tag['name'], 1) : $tag['name'];
+            ->map(function ($tag) use ($status) {
+                $name = substr($tag['name'], 0, 1) == '#' ?
+                    substr($tag['name'], 1) : $tag['name'];
 
-            $banned = TrendingHashtagService::getBannedHashtagNames();
+                $banned = TrendingHashtagService::getBannedHashtagNames();
 
-            if(count($banned)) {
-                if(in_array(strtolower($name), array_map('strtolower', $banned))) {
-                    return;
+                if (count($banned)) {
+                    if (in_array(strtolower($name), array_map('strtolower', $banned))) {
+                        return;
+                    }
                 }
-            }
-
-            if(config('database.default') === 'pgsql') {
-                $hashtag = Hashtag::where('name', 'ilike', $name)
-                    ->orWhere('slug', 'ilike', str_slug($name, '-', false))
-                    ->first();
-
-                if(!$hashtag) {
-                    $hashtag = Hashtag::updateOrCreate([
-                        'slug' => str_slug($name, '-', false),
-                        'name' => $name
-                    ]);
+
+                if (config('database.default') === 'pgsql') {
+                    $hashtag = DB::transaction(function () use ($name) {
+                        $baseSlug = str_slug($name, '-', false);
+                        $slug = $baseSlug;
+                        $counter = 1;
+
+                        $existing = Hashtag::where('name', $name)
+                            ->lockForUpdate()
+                            ->first();
+
+                        if ($existing) {
+                            if ($existing->slug !== $slug) {
+                                while (Hashtag::where('slug', $slug)
+                                    ->where('name', '!=', $name)
+                                    ->exists()) {
+                                    $slug = $baseSlug.'-'.$counter++;
+                                }
+                                $existing->slug = $slug;
+                                $existing->save();
+                            }
+
+                            return $existing;
+                        }
+
+                        while (Hashtag::where('slug', $slug)->exists()) {
+                            $slug = $baseSlug.'-'.$counter++;
+                        }
+
+                        return Hashtag::create([
+                            'name' => $name,
+                            'slug' => $slug,
+                        ]);
+                    });
+                } else {
+                    $hashtag = DB::transaction(function () use ($name) {
+                        $baseSlug = str_slug($name, '-', false);
+                        $slug = $baseSlug;
+                        $counter = 1;
+
+                        while (Hashtag::where('slug', $slug)
+                            ->where('name', '!=', $name)
+                            ->exists()) {
+                            $slug = $baseSlug.'-'.$counter++;
+                        }
+
+                        return Hashtag::updateOrCreate(
+                            ['name' => $name],
+                            ['slug' => $slug]
+                        );
+                    });
                 }
-            } else {
-                $hashtag = Hashtag::updateOrCreate([
-                    'slug' => str_slug($name, '-', false),
-                    'name' => $name
-                ]);
-            }
 
-            StatusHashtag::firstOrCreate([
-                'status_id' => $status->id,
-                'hashtag_id' => $hashtag->id,
-                'profile_id' => $status->profile_id,
-                'status_visibility' => $status->scope
-            ]);
-        });
+                StatusHashtag::firstOrCreate([
+                    'status_id' => $status->id,
+                    'hashtag_id' => $hashtag->id,
+                    'profile_id' => $status->profile_id,
+                    'status_visibility' => $status->scope,
+                ]);
+            });
 
         // Mentions
-        $tags->filter(function($tag) {
+        $tags->filter(function ($tag) {
             return $tag &&
                 $tag['type'] == 'Mention' &&
                 isset($tag['href']) &&
                 substr($tag['href'], 0, 8) === 'https://';
         })
-        ->map(function($tag) use($status) {
-            if(Helpers::validateLocalUrl($tag['href'])) {
-                $parts = explode('/', $tag['href']);
-                if(!$parts) {
-                    return;
-                }
-                $pid = AccountService::usernameToId(end($parts));
-                if(!$pid) {
-                    return;
-                }
-            } else {
-                $acct = Helpers::profileFetch($tag['href']);
-                if(!$acct) {
-                    return;
+            ->map(function ($tag) use ($status) {
+                if (Helpers::validateLocalUrl($tag['href'])) {
+                    $parts = explode('/', $tag['href']);
+                    if (! $parts) {
+                        return;
+                    }
+                    $pid = AccountService::usernameToId(end($parts));
+                    if (! $pid) {
+                        return;
+                    }
+                } else {
+                    $acct = Helpers::profileFetch($tag['href']);
+                    if (! $acct) {
+                        return;
+                    }
+                    $pid = $acct->id;
                 }
-                $pid = $acct->id;
-            }
-            $mention = new Mention;
-            $mention->status_id = $status->id;
-            $mention->profile_id = $pid;
-            $mention->save();
-            MentionPipeline::dispatch($status, $mention);
-        });
+                $mention = new Mention;
+                $mention->status_id = $status->id;
+                $mention->profile_id = $pid;
+                $mention->save();
+                MentionPipeline::dispatch($status, $mention);
+            });
 
         StatusService::refresh($status->id);
     }

+ 55 - 0
app/Mail/InAppRegisterEmailVerify.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+class InAppRegisterEmailVerify extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    public $code;
+
+    /**
+     * Create a new message instance.
+     */
+    public function __construct($code)
+    {
+        $this->code = $code;
+    }
+
+    /**
+     * Get the message envelope.
+     */
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: config('pixelfed.domain.app') . __('auth.verifyYourEmailAddress'),
+        );
+    }
+
+    /**
+     * Get the message content definition.
+     */
+    public function content(): Content
+    {
+        return new Content(
+            markdown: 'emails.iar.email_verify',
+        );
+    }
+
+    /**
+     * Get the attachments for the message.
+     *
+     * @return array<int, \Illuminate\Mail\Mailables\Attachment>
+     */
+    public function attachments(): array
+    {
+        return [];
+    }
+}

+ 2 - 0
app/Media.php

@@ -22,6 +22,8 @@ class Media extends Model
     protected $casts = [
         'srcset' => 'array',
         'deleted_at' => 'datetime',
+        'skip_optimize' => 'boolean',
+        'replicated_at' => 'datetime',
     ];
 
     public function status()

+ 10 - 0
app/Models/AppRegister.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class AppRegister extends Model
+{
+    protected $guarded = [];
+}

+ 412 - 0
app/Models/CustomFilter.php

@@ -0,0 +1,412 @@
+<?php
+
+namespace App\Models;
+
+use App\Profile;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Cache;
+
+class CustomFilter extends Model
+{
+    public $shouldInvalidateCache = false;
+
+    protected $fillable = [
+        'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'context' => 'array',
+        'expires_at' => 'datetime',
+        'action' => 'integer',
+    ];
+
+    protected $guarded = ['shouldInvalidateCache'];
+
+    const VALID_CONTEXTS = [
+        'home',
+        'notifications',
+        'public',
+        'thread',
+        'account',
+    ];
+
+    const MAX_STATUSES_PER_FILTER = 10;
+
+    const EXPIRATION_DURATIONS = [
+        1800,   // 30 minutes
+        3600,   // 1 hour
+        21600,  // 6 hours
+        43200,  // 12 hours
+        86400,  // 1 day
+        604800, // 1 week
+    ];
+
+    const ACTION_WARN = 0;
+
+    const ACTION_HIDE = 1;
+
+    const ACTION_BLUR = 2;
+
+    protected static ?int $maxContentScanLimit = null;
+
+    protected static ?int $maxFiltersPerUser = null;
+
+    protected static ?int $maxKeywordsPerFilter = null;
+
+    protected static ?int $maxKeywordsLength = null;
+
+    protected static ?int $maxPatternLength = null;
+
+    protected static ?int $maxCreatePerHour = null;
+
+    protected static ?int $maxUpdatesPerHour = null;
+
+    public function account()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function keywords()
+    {
+        return $this->hasMany(CustomFilterKeyword::class);
+    }
+
+    public function statuses()
+    {
+        return $this->hasMany(CustomFilterStatus::class);
+    }
+
+    public function toFilterArray()
+    {
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'context' => $this->context,
+            'expires_at' => $this->expires_at,
+            'filter_action' => $this->filterAction,
+        ];
+    }
+
+    public function getFilterActionAttribute()
+    {
+        switch ($this->action) {
+            case 0:
+                return 'warn';
+                break;
+
+            case 1:
+                return 'hide';
+                break;
+
+            case 2:
+                return 'blur';
+                break;
+        }
+    }
+
+    public function getTitleAttribute()
+    {
+        return $this->phrase;
+    }
+
+    public function setTitleAttribute($value)
+    {
+        $this->attributes['phrase'] = $value;
+    }
+
+    public function setFilterActionAttribute($value)
+    {
+        $this->attributes['action'] = $value;
+    }
+
+    public function setIrreversibleAttribute($value)
+    {
+        $this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
+    }
+
+    public function getIrreversibleAttribute()
+    {
+        return $this->action === self::ACTION_HIDE;
+    }
+
+    public function getExpiresInAttribute()
+    {
+        if ($this->expires_at === null) {
+            return null;
+        }
+
+        $now = now();
+        foreach (self::EXPIRATION_DURATIONS as $duration) {
+            if ($now->addSeconds($duration)->gte($this->expires_at)) {
+                return $duration;
+            }
+        }
+
+        return null;
+    }
+
+    public function scopeUnexpired($query)
+    {
+        return $query->where(function ($q) {
+            $q->whereNull('expires_at')
+                ->orWhere('expires_at', '>', now());
+        });
+    }
+
+    public function isExpired()
+    {
+        return $this->expires_at !== null && $this->expires_at->isPast();
+    }
+
+    protected static function boot()
+    {
+        parent::boot();
+
+        static::saving(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::updating(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::deleting(function ($model) {
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::saved(function ($model) {
+            $model->invalidateCache();
+        });
+
+        static::deleted(function ($model) {
+            $model->invalidateCache();
+        });
+    }
+
+    protected function prepareContextForStorage()
+    {
+        if (is_array($this->context)) {
+            $this->context = array_values(array_filter(array_map('trim', $this->context)));
+        }
+    }
+
+    protected function invalidateCache()
+    {
+        if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
+            return;
+        }
+
+        $this->shouldInvalidateCache = false;
+
+        Cache::forget("filters:v3:{$this->profile_id}");
+    }
+
+    public static function getMaxContentScanLimit(): int
+    {
+        if (self::$maxContentScanLimit === null) {
+            self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
+        }
+
+        return self::$maxContentScanLimit;
+    }
+
+    public static function getMaxFiltersPerUser(): int
+    {
+        if (self::$maxFiltersPerUser === null) {
+            self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
+        }
+
+        return self::$maxFiltersPerUser;
+    }
+
+    public static function getMaxKeywordsPerFilter(): int
+    {
+        if (self::$maxKeywordsPerFilter === null) {
+            self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
+        }
+
+        return self::$maxKeywordsPerFilter;
+    }
+
+    public static function getMaxKeywordLength(): int
+    {
+        if (self::$maxKeywordsLength === null) {
+            self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
+        }
+
+        return self::$maxKeywordsLength;
+    }
+
+    public static function getMaxPatternLength(): int
+    {
+        if (self::$maxPatternLength === null) {
+            self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
+        }
+
+        return self::$maxPatternLength;
+    }
+
+    public static function getMaxCreatePerHour(): int
+    {
+        if (self::$maxCreatePerHour === null) {
+            self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
+        }
+
+        return self::$maxCreatePerHour;
+    }
+
+    public static function getMaxUpdatesPerHour(): int
+    {
+        if (self::$maxUpdatesPerHour === null) {
+            self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
+        }
+
+        return self::$maxUpdatesPerHour;
+    }
+
+    /**
+     * Get cached filters for an account with simplified, secure approach
+     *
+     * @param  int  $profileId  The profile ID
+     * @return Collection The collection of filters
+     */
+    public static function getCachedFiltersForAccount($profileId)
+    {
+        $activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
+            $filtersHash = [];
+
+            $keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
+                $query->unexpired()->where('profile_id', $profileId);
+            }])->get();
+
+            $keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
+                $filter = $keywords->first()->customFilter;
+
+                if (! $filter) {
+                    return;
+                }
+
+                $maxPatternsPerFilter = self::getMaxFiltersPerUser();
+                $keywordsToProcess = $keywords->take($maxPatternsPerFilter);
+
+                $regexPatterns = $keywordsToProcess->map(function ($keyword) {
+                    $pattern = preg_quote($keyword->keyword, '/');
+
+                    if ($keyword->whole_word) {
+                        $pattern = '\b'.$pattern.'\b';
+                    }
+
+                    return $pattern;
+                })->toArray();
+
+                if (empty($regexPatterns)) {
+                    return;
+                }
+
+                $combinedPattern = implode('|', $regexPatterns);
+                $maxPatternLength = self::getMaxPatternLength();
+                if (strlen($combinedPattern) > $maxPatternLength) {
+                    $combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
+                }
+
+                $filtersHash[$filterId] = [
+                    'keywords' => '/'.$combinedPattern.'/i',
+                    'filter' => $filter,
+                ];
+            });
+
+            // $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
+            //     $query->unexpired()->where('profile_id', $profileId);
+            // }])->get();
+
+            // $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
+            //     $filter = $statuses->first()->customFilter;
+
+            //     if (! $filter) {
+            //         return;
+            //     }
+
+            //     if (! isset($filtersHash[$filterId])) {
+            //         $filtersHash[$filterId] = ['filter' => $filter];
+            //     }
+
+            //     $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
+            //     $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
+            // });
+
+            return array_map(function ($item) {
+                $filter = $item['filter'];
+                unset($item['filter']);
+
+                return [$filter, $item];
+            }, $filtersHash);
+        });
+
+        return collect($activeFilters)->reject(function ($item) {
+            [$filter, $rules] = $item;
+
+            return $filter->isExpired();
+        })->toArray();
+    }
+
+    /**
+     * Apply cached filters to a status with reasonable safety measures
+     *
+     * @param  array  $cachedFilters  The cached filters
+     * @param  mixed  $status  The status to check
+     * @return array The filter matches
+     */
+    public static function applyCachedFilters($cachedFilters, $status)
+    {
+        $results = [];
+
+        foreach ($cachedFilters as [$filter, $rules]) {
+            $keywordMatches = [];
+            $statusMatches = null;
+
+            if (isset($rules['keywords'])) {
+                $text = strip_tags($status['content']);
+
+                $maxContentLength = self::getMaxContentScanLimit();
+                if (mb_strlen($text) > $maxContentLength) {
+                    $text = mb_substr($text, 0, $maxContentLength);
+                }
+
+                try {
+                    preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
+                    if (! empty($matches[0])) {
+                        $maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
+                        $keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
+                    }
+                } catch (\Throwable $e) {
+                    \Log::error('Filter regex error: '.$e->getMessage(), [
+                        'filter_id' => $filter->id,
+                    ]);
+                }
+            }
+
+            // if (isset($rules['status_ids'])) {
+            //     $statusId = $status->id;
+            //     $reblogId = $status->reblog_of_id ?? null;
+
+            //     $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
+            //     if (! empty($matchingIds)) {
+            //         $statusMatches = $matchingIds;
+            //     }
+            // }
+
+            if (! empty($keywordMatches) || ! empty($statusMatches)) {
+                $results[] = [
+                    'filter' => $filter->toFilterArray(),
+                    'keyword_matches' => $keywordMatches ?: null,
+                    'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
+                ];
+            }
+        }
+
+        return $results;
+    }
+}

+ 37 - 0
app/Models/CustomFilterKeyword.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterKeyword extends Model
+{
+    protected $fillable = [
+        'keyword', 'whole_word', 'custom_filter_id',
+    ];
+
+    protected $casts = [
+        'whole_word' => 'boolean',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function setKeywordAttribute($value)
+    {
+        $this->attributes['keyword'] = mb_strtolower(trim($value));
+    }
+
+    public function toRegex()
+    {
+        $pattern = preg_quote($this->keyword, '/');
+
+        if ($this->whole_word) {
+            $pattern = '\b'.$pattern.'\b';
+        }
+
+        return '/'.$pattern.'/i';
+    }
+}

+ 23 - 0
app/Models/CustomFilterStatus.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use App\Status;
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterStatus extends Model
+{
+    protected $fillable = [
+        'custom_filter_id', 'status_id',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function status()
+    {
+        return $this->belongsTo(Status::class);
+    }
+}

+ 25 - 0
app/Models/UserOidcMapping.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
+class UserOidcMapping extends Model
+{
+    use HasFactory;
+
+    public $timestamps = true;
+
+    protected $fillable = [
+        'user_id',
+        'oidc_id',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+}

+ 61 - 0
app/Policies/CustomFilterPolicy.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\CustomFilter;
+use App\User;
+
+class CustomFilterPolicy
+{
+    /**
+     * Determine whether the user can view any models.
+     */
+    public function viewAny(User $user): bool
+    {
+        return false;
+    }
+
+    /**
+     * Determine whether the user can view the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function view(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can create models.
+     */
+    public function create(User $user): bool
+    {
+        return CustomFilter::whereProfileId($user->profile_id)->count() <= 100;
+    }
+
+    /**
+     * Determine whether the user can update the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function update(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can delete the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function delete(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+}

+ 106 - 68
app/Providers/AppServiceProvider.php

@@ -2,81 +2,119 @@
 
 namespace App\Providers;
 
-use App\Observers\{
-	AvatarObserver,
-	FollowerObserver,
-	HashtagFollowObserver,
-	LikeObserver,
-	NotificationObserver,
-	ModLogObserver,
-	ProfileObserver,
-    StatusHashtagObserver,
-    StatusObserver,
-	UserObserver,
-	UserFilterObserver,
-};
-use App\{
-	Avatar,
-	Follower,
-	HashtagFollow,
-	Like,
-	Notification,
-	ModLog,
-	Profile,
-	StatusHashtag,
-    Status,
-	User,
-	UserFilter
-};
-use Auth, Horizon, URL;
-use Illuminate\Support\Facades\Blade;
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Support\ServiceProvider;
+use App\Avatar;
+use App\Follower;
+use App\HashtagFollow;
+use App\Like;
+use App\ModLog;
+use App\Notification;
+use App\Observers\AvatarObserver;
+use App\Observers\FollowerObserver;
+use App\Observers\HashtagFollowObserver;
+use App\Observers\LikeObserver;
+use App\Observers\ModLogObserver;
+use App\Observers\NotificationObserver;
+use App\Observers\ProfileObserver;
+use App\Observers\StatusHashtagObserver;
+use App\Observers\StatusObserver;
+use App\Observers\UserFilterObserver;
+use App\Observers\UserObserver;
+use App\Profile;
+use App\Services\AccountService;
+use App\Services\UserOidcService;
+use App\Status;
+use App\StatusHashtag;
+use App\User;
+use App\UserFilter;
+use Auth;
+use Horizon;
+use Illuminate\Database\Eloquent\Model;
 use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Validator;
-use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\ServiceProvider;
+use Laravel\Pulse\Facades\Pulse;
+use Illuminate\Http\Request;
+use URL;
 
 class AppServiceProvider extends ServiceProvider
 {
-	/**
-	 * Bootstrap any application services.
-	 *
-	 * @return void
-	 */
-	public function boot()
-	{
-		if(config('instance.force_https_urls', true)) {
-			URL::forceScheme('https');
-		}
+    /**
+     * Bootstrap any application services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        if (config('instance.force_https_urls', true)) {
+            URL::forceScheme('https');
+        }
 
-		Schema::defaultStringLength(191);
-		Paginator::useBootstrap();
-		Avatar::observe(AvatarObserver::class);
-		Follower::observe(FollowerObserver::class);
-		HashtagFollow::observe(HashtagFollowObserver::class);
-		Like::observe(LikeObserver::class);
-		Notification::observe(NotificationObserver::class);
-		ModLog::observe(ModLogObserver::class);
-		Profile::observe(ProfileObserver::class);
-		StatusHashtag::observe(StatusHashtagObserver::class);
-		User::observe(UserObserver::class);
+        Schema::defaultStringLength(191);
+        Paginator::useBootstrap();
+        Avatar::observe(AvatarObserver::class);
+        Follower::observe(FollowerObserver::class);
+        HashtagFollow::observe(HashtagFollowObserver::class);
+        Like::observe(LikeObserver::class);
+        Notification::observe(NotificationObserver::class);
+        ModLog::observe(ModLogObserver::class);
+        Profile::observe(ProfileObserver::class);
+        StatusHashtag::observe(StatusHashtagObserver::class);
+        User::observe(UserObserver::class);
         Status::observe(StatusObserver::class);
-		UserFilter::observe(UserFilterObserver::class);
-		Horizon::auth(function ($request) {
-			return Auth::check() && $request->user()->is_admin;
-		});
-		Validator::includeUnvalidatedArrayKeys();
+        UserFilter::observe(UserFilterObserver::class);
+        Horizon::auth(function ($request) {
+            return Auth::check() && $request->user()->is_admin;
+        });
+        Validator::includeUnvalidatedArrayKeys();
+
+        Gate::define('viewPulse', function (User $user) {
+            return $user->is_admin === 1;
+        });
+
+        if (config('pulse.enabled', false)) {
+            Pulse::user(function ($user) {
+                $acct = AccountService::get($user->profile_id, true);
+
+                return $acct ? [
+                    'name' => $acct['username'],
+                    'extra' => $user->email,
+                    'avatar' => $acct['avatar'],
+                ] : [
+                    'name' => $user->username,
+                    'extra' => 'DELETED',
+                    'avatar' => '/storage/avatars/default.jpg',
+                ];
+            });
+        }
+
+        RateLimiter::for('app-signup', function (Request $request) {
+            return Limit::perDay(100)->by($request->ip());
+        });
+
+        RateLimiter::for('app-code-verify', function (Request $request) {
+            return Limit::perHour(20)->by($request->ip());
+        });
+
+        RateLimiter::for('app-code-resend', function (Request $request) {
+            return Limit::perHour(10)->by($request->ip());
+        });
 
-		// Model::preventLazyLoading(true);
-	}
+        // Model::preventLazyLoading(true);
+    }
 
-	/**
-	 * Register any application services.
-	 *
-	 * @return void
-	 */
-	public function register()
-	{
-		//
-	}
+    /**
+     * Register any application services.
+     *
+     * @return void
+     */
+    public function register()
+    {
+        $this->app->bind(UserOidcService::class, function() {
+            return UserOidcService::build();
+        });
+    }
 }

+ 3 - 1
app/Providers/AuthServiceProvider.php

@@ -5,6 +5,8 @@ namespace App\Providers;
 use Gate;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Laravel\Passport\Passport;
+use App\Models\CustomFilter;
+use App\Policies\CustomFilterPolicy;
 
 class AuthServiceProvider extends ServiceProvider
 {
@@ -14,7 +16,7 @@ class AuthServiceProvider extends ServiceProvider
      * @var array
      */
     protected $policies = [
-        // 'App\Model' => 'App\Policies\ModelPolicy',
+        CustomFilter::class => CustomFilterPolicy::class,
     ];
 
     /**

+ 25 - 0
app/Rules/EmailNotBanned.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Services\EmailService;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class EmailNotBanned implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if (EmailService::isBanned($value)) {
+            $fail('Email is invalid.');
+        }
+    }
+}

+ 57 - 0
app/Rules/PixelfedUsername.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class PixelfedUsername implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $dash = substr_count($value, '-');
+        $underscore = substr_count($value, '_');
+        $period = substr_count($value, '.');
+
+        if (ends_with($value, ['.php', '.js', '.css'])) {
+            $fail('Username is invalid.');
+            return;
+        }
+
+        if (($dash + $underscore + $period) > 1) {
+            $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+            return;
+        }
+
+        if (! ctype_alnum($value[0])) {
+            $fail('Username is invalid. Must start with a letter or number.');
+            return;
+        }
+
+        if (! ctype_alnum($value[strlen($value) - 1])) {
+            $fail('Username is invalid. Must end with a letter or number.');
+            return;
+        }
+
+        $val = str_replace(['_', '.', '-'], '', $value);
+        if (! ctype_alnum($val)) {
+            $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+            return;
+        }
+
+        $restricted = RestrictedNames::get();
+        if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+            $fail('Username cannot be used.');
+            return;
+        }
+    }
+}

+ 62 - 0
app/Rules/Webfinger.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class WebFinger implements Rule
+{
+    /**
+     * Determine if the validation rule passes.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @return bool
+     */
+    public function passes($attribute, $value)
+    {
+        if (! is_string($value)) {
+            return false;
+        }
+
+        $mention = $value;
+        if (str_starts_with($mention, '@')) {
+            $mention = substr($mention, 1);
+        }
+
+        $parts = explode('@', $mention);
+        if (count($parts) !== 2) {
+            return false;
+        }
+
+        [$username, $domain] = $parts;
+
+        if (empty($username) ||
+            ! preg_match('/^[a-zA-Z0-9_.-]+$/', $username) ||
+            strlen($username) >= 80) {
+            return false;
+        }
+
+        if (empty($domain) ||
+            ! str_contains($domain, '.') ||
+            ! preg_match('/^[a-zA-Z0-9.-]+$/', $domain) ||
+            strlen($domain) >= 255) {
+            return false;
+        }
+
+        // Optional: Check if domain resolves (can be enabled for stricter validation)
+        // return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA') || checkdnsrr($domain, 'MX');
+
+        return true;
+    }
+
+    /**
+     * Get the validation error message.
+     *
+     * @return string
+     */
+    public function message()
+    {
+        return 'The :attribute must be a valid WebFinger address (username@domain.tld or @username@domain.tld)';
+    }
+}

+ 10 - 1
app/Services/Account/AccountStatService.php

@@ -2,7 +2,6 @@
 
 namespace App\Services\Account;
 
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Redis;
 
 class AccountStatService
@@ -28,4 +27,14 @@ class AccountStatService
     {
         return Redis::zrange(self::REFRESH_CACHE_KEY, 0, $limit);
     }
+
+    public static function getPostCountChunk($lastId, $count)
+    {
+        return Redis::zrangebyscore(
+            self::REFRESH_CACHE_KEY,
+            '('.$lastId,
+            '+inf',
+            ['limit' => [0, $count]]
+        );
+    }
 }

+ 3 - 4
app/Services/AdminStatsService.php

@@ -90,7 +90,6 @@ class AdminStatsService
 
     protected static function additionalData()
     {
-        $day = config('database.default') == 'pgsql' ? 'DATE_PART(\'day\',' : 'day(';
         $ttl = now()->addHours(24);
 
         return Cache::remember('admin:dashboard:home:data:v0:24hr', $ttl, function () {
@@ -99,8 +98,8 @@ class AdminStatsService
                 'statuses' => PrettyNumber::convert(intval(StatusService::totalLocalStatuses())),
                 'statuses_monthly' => PrettyNumber::convert(Status::where('created_at', '>', now()->subMonth())->count()),
                 'profiles' => PrettyNumber::convert(Profile::count()),
-                'users' => PrettyNumber::convert(User::count()),
-                'users_monthly' => PrettyNumber::convert(User::where('created_at', '>', now()->subMonth())->count()),
+                'users' => PrettyNumber::convert(User::whereNull('status')->count()),
+                'users_monthly' => PrettyNumber::convert(User::where('created_at', '>', now()->subMonth())->whereNull('status')->count()),
                 'instances' => PrettyNumber::convert(Instance::count()),
                 'media' => PrettyNumber::convert(Media::count()),
                 'storage' => Media::sum('size'),
@@ -116,7 +115,7 @@ class AdminStatsService
             return [
                 'statuses' => PrettyNumber::convert(intval(StatusService::totalLocalStatuses())),
                 'profiles' => PrettyNumber::convert(Profile::count()),
-                'users' => PrettyNumber::convert(User::count()),
+                'users' => PrettyNumber::convert(User::whereNull('status')->count()),
                 'instances' => PrettyNumber::convert(Instance::count()),
             ];
         });

+ 1 - 1
app/Services/ImportService.php

@@ -14,7 +14,7 @@ class ImportService
         if($userId > 999999) {
             return;
         }
-        if($year < 9 || $year > 23) {
+        if($year < 9 || $year > (int) now()->addYear()->format('y')) {
             return;
         }
         if($month < 1 || $month > 12) {

+ 1 - 0
app/Services/LandingService.php

@@ -52,6 +52,7 @@ class LandingService
             'domain' => config('pixelfed.domain.app'),
             'show_directory' => (bool) config_cache('instance.landing.show_directory'),
             'show_explore_feed' => (bool) config_cache('instance.landing.show_explore'),
+            'show_legal_notice_link' => (bool) config('instance.has_legal_notice'),
             'open_registration' => (bool) $openReg,
             'curated_onboarding' => (bool) config_cache('instance.curated_registration.enabled'),
             'version' => config('pixelfed.version'),

+ 3 - 3
app/Services/LikeService.php

@@ -79,13 +79,13 @@ class LikeService {
 
 		$res = Cache::remember('pf:services:likes:liked_by:' . $status->id, 86400, function() use($status, $empty) {
 			$like = Like::whereStatusId($status->id)->first();
-			if(!$like) {
+			if(!$like || !$like->profile_id) {
 				return $empty;
 			}
 			$id = $like->profile_id;
-			$profile = ProfileService::get($id, true);
+			$profile = AccountService::get($id, true);
 			if(!$profile) {
-				return [];
+				return $empty;
 			}
 			$profileUrl = "/i/web/profile/{$profile['id']}";
 			$res = [

+ 22 - 8
app/Services/MediaStorageService.php

@@ -19,7 +19,7 @@ class MediaStorageService
     public static function store(Media $media)
     {
         if ((bool) config_cache('pixelfed.cloud_storage') == true) {
-            (new self())->cloudStore($media);
+            (new self)->cloudStore($media);
         }
 
     }
@@ -31,19 +31,19 @@ class MediaStorageService
         }
 
         if ((bool) config_cache('pixelfed.cloud_storage') == true) {
-            return (new self())->cloudMove($media);
+            return (new self)->cloudMove($media);
         }
 
     }
 
     public static function avatar($avatar, $local = false, $skipRecentCheck = false)
     {
-        return (new self())->fetchAvatar($avatar, $local, $skipRecentCheck);
+        return (new self)->fetchAvatar($avatar, $local, $skipRecentCheck);
     }
 
     public static function head($url)
     {
-        $c = new Client();
+        $c = new Client;
         try {
             $r = $c->request('HEAD', $url);
         } catch (RequestException $e) {
@@ -75,17 +75,19 @@ class MediaStorageService
     {
         if ($media->remote_media == true) {
             if (config('media.storage.remote.cloud')) {
-                (new self())->remoteToCloud($media);
+                (new self)->remoteToCloud($media);
             }
         } else {
-            (new self())->localToCloud($media);
+            (new self)->localToCloud($media);
         }
     }
 
     protected function localToCloud($media)
     {
         $path = storage_path('app/'.$media->media_path);
-        $thumb = storage_path('app/'.$media->thumbnail_path);
+        if ($media->thumbnail_path) {
+            $thumb = storage_path('app/'.$media->thumbnail_path);
+        }
 
         $p = explode('/', $media->media_path);
         $name = array_pop($p);
@@ -94,7 +96,7 @@ class MediaStorageService
         $storagePath = implode('/', $p);
 
         $url = ResilientMediaStorageService::store($storagePath, $path, $name);
-        if ($thumb) {
+        if ($media->thumbnail_path) {
             $thumbUrl = ResilientMediaStorageService::store($storagePath, $thumb, $thumbname);
             $media->thumbnail_url = $thumbUrl;
         }
@@ -102,6 +104,18 @@ class MediaStorageService
         $media->optimized_url = $url;
         $media->replicated_at = now();
         $media->save();
+        if ((bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.delete_local_after_cloud')) {
+            $s3Domain = config('filesystems.disks.s3.url') ?? config('filesystems.disks.s3.endpoint');
+            if (str_contains($url, $s3Domain)) {
+                if (file_exists($path)) {
+                    unlink($path);
+                }
+
+                if (file_exists($thumb)) {
+                    unlink($thumb);
+                }
+            }
+        }
         if ($media->status_id) {
             Cache::forget('status:transformer:media:attachments:'.$media->status_id);
             MediaService::del($media->status_id);

+ 4 - 110
app/Services/PushNotificationService.php

@@ -3,121 +3,15 @@
 namespace App\Services;
 
 use App\User;
-use Exception;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Redis;
-use Log;
 
-class PushNotificationService
-{
-    public const ACTIVE_LIST_KEY = 'pf:services:push-notify:active_deliver:';
+class PushNotificationService {
 
     public const NOTIFY_TYPES = ['follow', 'like', 'mention', 'comment'];
 
-    public const DEEP_CHECK_KEY = 'pf:services:push-notify:deep-check:';
-
     public const PUSH_GATEWAY_VERSION = '1.0';
 
-    public const LOTTERY_ODDS = 20;
-
-    public const CACHE_LOCK_SECONDS = 10;
-
-    public static function get($list)
-    {
-        return Redis::smembers(self::ACTIVE_LIST_KEY.$list);
-    }
-
-    public static function set($listId, $memberId)
-    {
-        if (! in_array($listId, self::NOTIFY_TYPES)) {
-            return false;
-        }
-        $user = User::whereProfileId($memberId)->first();
-        if (! $user || $user->status || $user->deleted_at) {
-            return false;
-        }
-
-        return Redis::sadd(self::ACTIVE_LIST_KEY.$listId, $memberId);
-    }
-
-    public static function check($listId, $memberId)
-    {
-        return random_int(1, self::LOTTERY_ODDS) === 1
-            ? self::isMemberDeepCheck($listId, $memberId)
-            : self::isMember($listId, $memberId);
-    }
-
-    public static function isMember($listId, $memberId)
-    {
-        try {
-            return Redis::sismember(self::ACTIVE_LIST_KEY.$listId, $memberId);
-        } catch (Exception $e) {
-            return false;
-        }
-    }
-
-    public static function isMemberDeepCheck($listId, $memberId)
-    {
-        $lock = Cache::lock(self::DEEP_CHECK_KEY.$listId, self::CACHE_LOCK_SECONDS);
-
-        try {
-            $lock->block(5);
-            $actualCount = User::whereNull('status')->where('notify_enabled', true)->where('notify_'.$listId, true)->count();
-            $cachedCount = self::count($listId);
-            if ($actualCount != $cachedCount) {
-                self::warmList($listId);
-                $user = User::where('notify_enabled', true)->where('profile_id', $memberId)->first();
-
-                return $user ? (bool) $user->{"notify_{$listId}"} : false;
-            } else {
-                return self::isMember($listId, $memberId);
-            }
-        } catch (Exception $e) {
-            Log::error('Failed during deep membership check: '.$e->getMessage());
-
-            return false;
-        } finally {
-            optional($lock)->release();
-        }
-    }
-
-    public static function removeMember($listId, $memberId)
-    {
-        return Redis::srem(self::ACTIVE_LIST_KEY.$listId, $memberId);
-    }
-
-    public static function removeMemberFromAll($memberId)
-    {
-        foreach (self::NOTIFY_TYPES as $type) {
-            self::removeMember($type, $memberId);
-        }
-
-        return 1;
-    }
-
-    public static function count($listId)
-    {
-        if (! in_array($listId, self::NOTIFY_TYPES)) {
-            return false;
-        }
-
-        return Redis::scard(self::ACTIVE_LIST_KEY.$listId);
-    }
-
-    public static function warmList($listId)
-    {
-        if (! in_array($listId, self::NOTIFY_TYPES)) {
-            return false;
-        }
-        $key = self::ACTIVE_LIST_KEY.$listId;
-        Redis::del($key);
-        foreach (User::where('notify_'.$listId, true)->cursor() as $acct) {
-            if ($acct->status || $acct->deleted_at || ! $acct->profile_id || ! $acct->notify_enabled) {
-                continue;
-            }
-            Redis::sadd($key, $acct->profile_id);
-        }
-
-        return self::count($listId);
+    public static function check($listId, $memberId) {
+        $user = User::where('notify_enabled', true)->where('profile_id', $memberId)->first();
+        return $user ? (bool) $user->{"notify_{$listId}"} : false;
     }
 }

+ 103 - 99
app/Services/RelationshipService.php

@@ -2,116 +2,120 @@
 
 namespace App\Services;
 
-use Illuminate\Support\Facades\Cache;
 use App\Follower;
 use App\FollowRequest;
-use App\Profile;
 use App\UserFilter;
+use Illuminate\Support\Facades\Cache;
 
 class RelationshipService
 {
-	const CACHE_KEY = 'pf:services:urel:';
-
-	public static function get($aid, $tid)
-	{
-		$actor = AccountService::get($aid, true);
-		$target = AccountService::get($tid, true);
-		if(!$actor || !$target) {
-			return self::defaultRelation($tid);
-		}
-
-		if($actor['id'] === $target['id']) {
-			return self::defaultRelation($tid);
-		}
-
-		return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid) {
-			return [
-				'id' => (string) $tid,
-				'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
-				'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
-				'blocking' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('block')
-					->exists(),
-				'muting' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('mute')
-					->exists(),
-				'muting_notifications' => null,
-				'requested' => FollowRequest::whereFollowerId($aid)
-					->whereFollowingId($tid)
-					->exists(),
-				'domain_blocking' => null,
-				'showing_reblogs' => null,
-				'endorsed' => false
-			];
-		});
-	}
-
-	public static function delete($aid, $tid)
-	{
-		Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
-		return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
-	}
-
-	public static function refresh($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-		self::get($tid, $aid);
-		return self::get($aid, $tid);
-	}
-
-	public static function forget($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-	}
-
-	public static function defaultRelation($tid)
-	{
-		return [
+    const CACHE_KEY = 'pf:services:urel:';
+
+    public static function get($aid, $tid)
+    {
+        $actor = AccountService::get($aid, true);
+        $target = AccountService::get($tid, true);
+        if (! $actor || ! $target) {
+            return self::defaultRelation($tid);
+        }
+
+        if ($actor['id'] === $target['id']) {
+            return self::defaultRelation($tid);
+        }
+
+        return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid) {
+            return [
+                'id' => (string) $tid,
+                'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
+                'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
+                'blocking' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('block')
+                    ->exists(),
+                'muting' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('mute')
+                    ->exists(),
+                'muting_notifications' => false,
+                'requested' => FollowRequest::whereFollowerId($aid)
+                    ->whereFollowingId($tid)
+                    ->exists(),
+                'domain_blocking' => false,
+                'showing_reblogs' => false,
+                'endorsed' => false,
+            ];
+        });
+    }
+
+    public static function delete($aid, $tid)
+    {
+        Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
+
+        return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
+    }
+
+    public static function refresh($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+        self::get($tid, $aid);
+
+        return self::get($aid, $tid);
+    }
+
+    public static function forget($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+    }
+
+    public static function defaultRelation($tid)
+    {
+        return [
             'id' => (string) $tid,
             'following' => false,
             'followed_by' => false,
             'blocking' => false,
             'muting' => false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => false,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => false,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
-	}
-
-	protected static function key($suffix)
-	{
-		return self::CACHE_KEY . $suffix;
-	}
-
-	public static function getWithDate($aid, $tid)
-	{
-		$res = self::get($aid, $tid);
-
-		if(!$res || !$res['following']) {
-			$res['following_since'] = null;
-			return $res;
-		}
-
-		return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid, $res) {
-			$tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
-			if(!$tmp) {
-				$res['following_since'] = null;
-				return $res;
-			}
-			$res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
-			return $res;
-		});
-	}
+    }
+
+    protected static function key($suffix)
+    {
+        return self::CACHE_KEY.$suffix;
+    }
+
+    public static function getWithDate($aid, $tid)
+    {
+        $res = self::get($aid, $tid);
+
+        if (! $res || ! $res['following']) {
+            $res['following_since'] = null;
+
+            return $res;
+        }
+
+        return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid, $res) {
+            $tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
+            if (! $tmp) {
+                $res['following_since'] = null;
+
+                return $res;
+            }
+            $res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
+
+            return $res;
+        });
+    }
 }

+ 55 - 9
app/Services/SearchApiV2Service.php

@@ -7,6 +7,7 @@ use App\Profile;
 use App\Status;
 use App\Transformer\Api\AccountTransformer;
 use App\Util\ActivityPub\Helpers;
+use DB;
 use Illuminate\Support\Str;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
@@ -131,17 +132,49 @@ class SearchApiV2Service
         $q = $this->query->input('q');
         $limit = $this->query->input('limit') ?? 20;
         $offset = $this->query->input('offset') ?? 0;
-        $query = Str::startsWith($q, '#') ? substr($q, 1).'%' : $q;
-        $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
+        $query = Str::startsWith($q, '#') ? substr($q, 1) : $q;
+        $query = $query.'%';
+
+        if (config('database.default') === 'pgsql') {
+            $baseQuery = Hashtag::query()
+                ->where('name', 'ilike', $query)
+                ->where('is_banned', false)
+                ->where(function ($q) {
+                    $q->where('can_search', true)
+                        ->orWhereNull('can_search');
+                })
+                ->orderByDesc(DB::raw('COALESCE(cached_count, 0)'))
+                ->offset($offset)
+                ->limit($limit)
+                ->get();
+
+            return $baseQuery
+                ->map(function ($tag) use ($mastodonMode) {
+                    $res = [
+                        'name' => $tag->name,
+                        'url' => $tag->url(),
+                    ];
+
+                    if (! $mastodonMode) {
+                        $res['history'] = [];
+                        $res['count'] = $tag->cached_count ?? 0;
+                    }
 
-        return Hashtag::where('name', $operator, $query)
-            ->orderByDesc('cached_count')
+                    return $res;
+                })
+                ->values();
+        }
+
+        return Hashtag::where('name', 'like', $query)
+            ->where('is_banned', false)
+            ->where(function ($q) {
+                $q->where('can_search', true)
+                    ->orWhereNull('can_search');
+            })
+            ->orderBy(DB::raw('COALESCE(cached_count, 0)'), 'desc')
             ->offset($offset)
             ->limit($limit)
             ->get()
-            ->filter(function ($tag) {
-                return $tag->can_search != false;
-            })
             ->map(function ($tag) use ($mastodonMode) {
                 $res = [
                     'name' => $tag->name,
@@ -180,6 +213,9 @@ class SearchApiV2Service
         $user = request()->user();
         $mastodonMode = self::$mastodonMode;
         $query = urldecode($this->query->input('q'));
+        $limit = $this->query->input('limit') ?? 20;
+        $offset = $this->query->input('offset') ?? 0;
+
         $banned = InstanceService::getBannedDomains();
         $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
         if ($domainBlocks && count($domainBlocks)) {
@@ -218,7 +254,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {
@@ -237,7 +278,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {

+ 44 - 39
app/Services/SnowflakeService.php

@@ -2,45 +2,50 @@
 
 namespace App\Services;
 
-use Illuminate\Support\Carbon;
 use Cache;
+use Illuminate\Support\Carbon;
 
-class SnowflakeService {
-
-	public static function byDate(Carbon $ts = null)
-	{
-		if($ts instanceOf Carbon) {
-			$ts = now()->parse($ts)->timestamp;
-		} else {
-			return self::next();
-		}
-
-		return ((round($ts * 1000) - 1549756800000) << 22)
-		| (random_int(1,31) << 17)
-		| (random_int(1,31) << 12)
-		| 0;
-	}
-
-	public static function next()
-	{
-		$seq = Cache::get('snowflake:seq');
-
-		if(!$seq) {
-			Cache::put('snowflake:seq', 1);
-			$seq = 1;
-		} else {
-			Cache::increment('snowflake:seq');
-		}
-
-		if($seq >= 4095) {
-			Cache::put('snowflake:seq', 0);
-			$seq = 0;
-		}
-
-		return ((round(microtime(true) * 1000) - 1549756800000) << 22)
-		| (random_int(1,31) << 17)
-		| (random_int(1,31) << 12)
-		| $seq;
-	}
-
+class SnowflakeService
+{
+    public static function byDate(?Carbon $ts = null)
+    {
+        if ($ts instanceof Carbon) {
+            $ts = now()->parse($ts)->timestamp;
+        } else {
+            return self::next();
+        }
+
+        $datacenterId = config('snowflake.datacenter_id') ?? random_int(1, 31);
+        $workerId = config('snowflake.worker_id') ?? random_int(1, 31);
+
+        return ((round($ts * 1000) - 1549756800000) << 22)
+        | ($datacenterId << 17)
+        | ($workerId << 12)
+        | 0;
+    }
+
+    public static function next()
+    {
+        $seq = Cache::get('snowflake:seq');
+
+        if (! $seq) {
+            Cache::put('snowflake:seq', 1);
+            $seq = 1;
+        } else {
+            Cache::increment('snowflake:seq');
+        }
+
+        if ($seq >= 4095) {
+            Cache::put('snowflake:seq', 0);
+            $seq = 0;
+        }
+
+        $datacenterId = config('snowflake.datacenter_id') ?? random_int(1, 31);
+        $workerId = config('snowflake.worker_id') ?? random_int(1, 31);
+
+        return ((round(microtime(true) * 1000) - 1549756800000) << 22)
+        | ($datacenterId << 17)
+        | ($workerId << 12)
+        | $seq;
+    }
 }

+ 87 - 1
app/Services/StatusService.php

@@ -12,6 +12,8 @@ class StatusService
 {
     const CACHE_KEY = 'pf:services:status:v1.1:';
 
+    const MAX_PINNED = 3;
+
     public static function key($id, $publicOnly = true)
     {
         $p = $publicOnly ? 'pub:' : 'all:';
@@ -82,7 +84,6 @@ class StatusService
             $status['shortcode'],
             $status['taggedPeople'],
             $status['thread'],
-            $status['pinned'],
             $status['account']['header_bg'],
             $status['account']['is_admin'],
             $status['account']['last_fetched_at'],
@@ -198,4 +199,89 @@ class StatusService
     {
         return InstanceService::totalLocalStatuses();
     }
+
+    public static function isPinned($id)
+    {
+        return Status::whereId($id)->whereNotNull('pinned_order')->exists();
+    }
+
+    public static function totalPins($pid)
+    {
+        return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count();
+    }
+
+    public static function markPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status) {
+            return [
+                'success' => false,
+                'error' => 'Record not found',
+            ];
+        }
+
+        if ($status->scope != 'public') {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: you can only pin public posts',
+            ];
+        }
+
+        if (self::isPinned($id)) {
+            return [
+                'success' => false,
+                'error' => 'This post is already pinned',
+            ];
+        }
+
+        $totalPins = self::totalPins($status->profile_id);
+
+        if ($totalPins >= self::MAX_PINNED) {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: You have already pinned the max number of posts',
+            ];
+        }
+
+        $status->pinned_order = $totalPins + 1;
+        $status->save();
+
+        self::refresh($id);
+
+        return [
+            'success' => true,
+            'error' => null,
+        ];
+    }
+
+    public static function unmarkPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status || is_null($status->pinned_order)) {
+            return false;
+        }
+
+        $removedOrder = $status->pinned_order;
+        $profileId = $status->profile_id;
+
+        $status->pinned_order = null;
+        $status->save();
+
+        Status::where('profile_id', $profileId)
+            ->whereNotNull('pinned_order')
+            ->where('pinned_order', '>', $removedOrder)
+            ->orderBy('pinned_order', 'asc')
+            ->chunk(10, function ($statuses) {
+                foreach ($statuses as $s) {
+                    $s->pinned_order = $s->pinned_order - 1;
+                    $s->save();
+                }
+            });
+
+        self::refresh($id);
+
+        return true;
+    }
 }

+ 21 - 0
app/Services/UserOidcService.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services;
+
+use League\OAuth2\Client\Provider\GenericProvider;
+
+class UserOidcService extends GenericProvider {
+    public static function build()
+    {
+        return new UserOidcService([
+            'clientId' => config('remote-auth.oidc.clientId'),
+            'clientSecret' => config('remote-auth.oidc.clientSecret'),
+            'redirectUri' => url('auth/oidc/callback'),
+            'urlAuthorize' => config('remote-auth.oidc.authorizeURL'),
+            'urlAccessToken' => config('remote-auth.oidc.tokenURL'),
+            'urlResourceOwnerDetails' => config('remote-auth.oidc.profileURL'),
+            'scopes' => config('remote-auth.oidc.scopes'),
+            'responseResourceOwnerId' => config('remote-auth.oidc.field_id'),
+        ]);
+    }
+}

+ 16 - 0
app/Services/WebfingerService.php

@@ -11,10 +11,26 @@ class WebfingerService
 {
     public static function rawGet($url)
     {
+        if (empty($url)) {
+            return false;
+        }
+
         $n = WebfingerUrl::get($url);
+
         if (! $n) {
             return false;
         }
+        if (empty($n) || ! str_starts_with($n, 'https://')) {
+            return false;
+        }
+        $host = parse_url($n, PHP_URL_HOST);
+        if (! $host) {
+            return false;
+        }
+
+        if (in_array($host, InstanceService::getBannedDomains())) {
+            return false;
+        }
         $webfinger = FetchCacheService::getJson($n);
         if (! $webfinger) {
             return false;

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

@@ -3,6 +3,7 @@
 namespace App\Transformer\ActivityPub;
 
 use App\Services\MediaService;
+use App\Services\StatusService;
 use App\Status;
 use App\Util\Lexer\Autolink;
 use League\Fractal;
@@ -11,7 +12,16 @@ class StatusTransformer extends Fractal\TransformerAbstract
 {
     public function transform(Status $status)
     {
-        $content = $status->caption ? nl2br(Autolink::create()->autolink($status->caption)) : "";
+        $content = $status->caption ? nl2br(Autolink::create()->autolink($status->caption)) : '';
+
+        $inReplyTo = null;
+
+        if ($status->in_reply_to_id) {
+            $reply = StatusService::get($status->in_reply_to_id, true);
+            if ($reply && isset($reply['url'])) {
+                $inReplyTo = $reply['url'];
+            }
+        }
 
         return [
             '@context' => [
@@ -25,30 +35,20 @@ class StatusTransformer extends Fractal\TransformerAbstract
                 ],
             ],
             'id' => $status->url(),
-
-            // TODO: handle other types
             'type' => 'Note',
-
-            // XXX: CW Title
             'summary' => null,
             'content' => $content,
-            'inReplyTo' => null,
-
-            // TODO: fix date format
+            'inReplyTo' => $inReplyTo,
             'published' => $status->created_at->toAtomString(),
             'url' => $status->url(),
             'attributedTo' => $status->profile->permalink(),
             'to' => [
-                // TODO: handle proper scope
                 'https://www.w3.org/ns/activitystreams#Public',
             ],
             'cc' => [
-                // TODO: add cc's
                 $status->profile->permalink('/followers'),
             ],
             'sensitive' => (bool) $status->is_nsfw,
-            'atomUri' => $status->url(),
-            'inReplyToAtomUri' => null,
             'attachment' => MediaService::activitypub($status->id),
             'tag' => [],
             'location' => $status->place_id ? [

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

@@ -75,13 +75,6 @@ class AccountTransformer extends Fractal\TransformerAbstract
             'location' => $profile->location,
         ];
 
-        if ($profile->moved_to_profile_id) {
-            $mt = AccountService::getMastodon($profile->moved_to_profile_id, true);
-            if ($mt) {
-                $res['moved'] = $mt;
-            }
-        }
-
         return $res;
     }
 

+ 17 - 10
app/Transformer/Api/RelationshipTransformer.php

@@ -2,11 +2,10 @@
 
 namespace App\Transformer\Api;
 
+use App\FollowRequest;
+use App\Models\UserDomainBlock;
+use App\Profile;
 use Auth;
-use App\{
-    FollowRequest,
-    Profile
-};
 use League\Fractal;
 
 class RelationshipTransformer extends Fractal\TransformerAbstract
@@ -14,27 +13,35 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
     public function transform(Profile $profile)
     {
         $auth = Auth::check();
-        if(!$auth) {
+        if (! $auth) {
             return [];
         }
         $user = $auth ? Auth::user()->profile : false;
         $requested = false;
-        if($user) {
+        $domainBlocking = false;
+        if ($user) {
             $requested = FollowRequest::whereFollowerId($user->id)
                 ->whereFollowingId($profile->id)
                 ->exists();
+
+            if ($profile->domain) {
+                $domainBlocking = UserDomainBlock::whereProfileId($user->id)
+                    ->whereDomain($profile->domain)
+                    ->exists();
+            }
         }
+
         return [
             'id' => (string) $profile->id,
             'following' => $auth ? $user->follows($profile) : false,
             'followed_by' => $auth ? $user->followedBy($profile) : false,
             'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
             'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => $requested,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => $domainBlocking,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
     }
 }

+ 1 - 0
app/Transformer/Api/StatusStatelessTransformer.php

@@ -69,6 +69,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
             'tags' => StatusHashtagService::statusTags($status->id),
             'poll' => $poll,
             'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+            'pinned' => (bool) $status->pinned_order,
         ];
     }
 }

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