This commit is contained in:
Felipe Mateus 2023-05-04 23:17:52 -03:00
commit cd56fdb841
No known key found for this signature in database
GPG key ID: 94D494618F214123
421 changed files with 33899 additions and 11256 deletions

View file

@ -7,7 +7,7 @@ jobs:
build:
docker:
# Specify the version you desire here
- image: cimg/php:8.1.12
- image: cimg/php:8.2.5
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
@ -29,14 +29,14 @@ jobs:
- restore_cache:
keys:
# "composer.lock" can be used if it is committed to the repo
- v1-dependencies-{{ checksum "composer.json" }}
- v2-dependencies-{{ checksum "composer.json" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- v2-dependencies-
- run: composer install -n --prefer-dist
- save_cache:
key: composer-v1-{{ checksum "composer.lock" }}
key: composer-v2-{{ checksum "composer.lock" }}
paths:
- vendor

7
.ddev/commands/redis/redis-cli Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
#ddev-generated
## Description: Run redis-cli inside the redis container
## Usage: redis-cli [flags] [args]
## Example: "redis-cli KEYS *" or "ddev redis-cli INFO" or "ddev redis-cli --version"
redis-cli -p 6379 -h redis $@

32
.ddev/config.yaml Normal file
View file

@ -0,0 +1,32 @@
type: laravel
docroot: public
php_version: "8.1"
webserver_type: nginx-fpm
database:
type: mariadb
version: "10.4"
disable_settings_management: true
web_environment:
- DB_CONNECTION=mysql
- DB_HOST=ddev-pixelfed-db
- DB_DATABASE=db
- DB_USERNAME=db
- DB_PASSWORD=db
- REDIS_HOST=ddev-pixelfed-redis
- MAIL_DRIVER=smtp
- MAIL_HOST=localhost
- MAIL_PORT=1025
- MAIL_USERNAME=null
- MAIL_PASSWORD=null
- MAIL_ENCRYPTION=null
- APP_KEY=placeholder
- APP_NAME=PixelfedTest
- APP_ENV=local
- APP_KEY=base64:lwX95GbNWX3XsucdMe0XwtOKECta3h/B+p9NbH2jd0E=
- APP_DEBUG=true
- APP_URL=https://pixelfed.ddev.site
- APP_DOMAIN=pixelfed.ddev.site
- ADMIN_DOMAIN=pixelfed.ddev.site
- SESSION_DOMAIN=pixelfed.ddev.site
- "TRUST_PROXIES=*"
- LOG_CHANNEL=stack

View file

@ -0,0 +1,14 @@
#ddev-generated
version: '3.6'
services:
redis:
container_name: ddev-${DDEV_SITENAME}-redis
image: redis:6
# These labels ensure this service is discoverable by ddev.
labels:
com.ddev.site-name: ${DDEV_SITENAME}
com.ddev.approot: $DDEV_APPROOT
volumes:
- ".:/mnt/ddev_config"
- "./redis:/usr/local/etc/redis"
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

8
.ddev/redis/redis.conf Normal file
View file

@ -0,0 +1,8 @@
# Redis configuration.
# #ddev-generated
# Example configuration files for reference:
# http://download.redis.io/redis-stable/redis.conf
# http://download.redis.io/redis-stable/sentinel.conf
maxmemory 2048mb
maxmemory-policy allkeys-lfu

View file

@ -2,6 +2,7 @@ root = true
[*]
indent_size = 4
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true

View file

@ -101,7 +101,6 @@ NODEINFO=true
WEBFINGER=true
## S3
FILESYSTEM_DRIVER=local
FILESYSTEM_CLOUD=s3
PF_ENABLE_CLOUD=false
#AWS_ACCESS_KEY_ID=
@ -127,8 +126,8 @@ LOG_CHANNEL=stderr
## Image
IMAGE_DRIVER=imagick
## Broadcasting
BROADCAST_DRIVER=log # log driver for local development
## Broadcasting: log driver for local development
BROADCAST_DRIVER=log
## Cache
CACHE_DRIVER=redis

View file

@ -68,7 +68,6 @@ MAIL_FROM_NAME="Pixelfed"
## S3 Configuration (Post-Installer)
PF_ENABLE_CLOUD=false
FILESYSTEM_DRIVER=local
FILESYSTEM_CLOUD=s3
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=

19
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,19 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
target-branch: "staging"
schedule:
interval: "weekly"
versioning-strategy: lockfile-only
- package-ecosystem: "npm"
directory: "/"
target-branch: "staging"
schedule:
interval: "weekly"
versioning-strategy: lockfile-only

125
.github/workflows/build-docker.yml vendored Normal file
View file

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

1
.node-version Normal file
View file

@ -0,0 +1 @@
v14.20.1

View file

@ -1,11 +1,67 @@
# Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.4...dev)
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.6...dev)
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.6 (2023-05-03)](https://github.com/pixelfed/pixelfed/compare/v0.11.5...v0.11.6)
### Added
- Add php 8.2 support. Bump laravel version, v9 => v10 ([fb4ac4eb](https://github.com/pixelfed/pixelfed/commit/fb4ac4eb))
- New media:fix-nonlocal-driver command. Fixes s3 media created with invalid FILESYSTEM_DRIVER=s3 configuration ([672cccd4](https://github.com/pixelfed/pixelfed/commit/672cccd4))
- New landing page design ([09c0032b](https://github.com/pixelfed/pixelfed/commit/09c0032b))
- Add cloud ip bans to BouncerService (disabled by default) ([50ab2e20](https://github.com/pixelfed/pixelfed/commit/50ab2e20))
- Redesigned Admin Dashboard Reports/Moderation ([c6cc6327](https://github.com/pixelfed/pixelfed/commit/c6cc6327))
### Fixes
- Fixed `violates check constraint "statuses_visibility_check"` bug affecting postgres instances + various api endpoints ([79b6a17e](https://github.com/pixelfed/pixelfed/commit/79b6a17e))
- Fixed duplicate hashtags on postgres ([64059cb4](https://github.com/pixelfed/pixelfed/commit/64059cb4))
- Fixed custom emoji domain search on postgres. Closes #4333 ([3dac45f3](https://github.com/pixelfed/pixelfed/commit/3dac45f3))
### Updates
- Update ApiV1Controller, fix blocking remote accounts. Closes #4256 ([8e71e0c0](https://github.com/pixelfed/pixelfed/commit/8e71e0c0))
- Update ComposeController, fix postgres location search. Closes #4242 and #4239 ([64a4a006](https://github.com/pixelfed/pixelfed/commit/64a4a006))
- Update app.js, add title attribute to iframe embeds to comply with accessibility requirements ([4d72b9e3](https://github.com/pixelfed/pixelfed/commit/4d72b9e3))
- Update MediaPathService, fix story path ([aebbad96](https://github.com/pixelfed/pixelfed/commit/aebbad96))
- Update Story v1.1 api endpoints ([855e9626](https://github.com/pixelfed/pixelfed/commit/855e9626))
- Update ApiV1Controller, filter mute/blocks on statuses/context and statuses/replies endpoints ([73aa01e8](https://github.com/pixelfed/pixelfed/commit/73aa01e8))
- Update filesystems, store all files as public by default and add default permissions. Fixes #4273, #4275. Closes #3825 ([22da2647](https://github.com/pixelfed/pixelfed/commit/22da2647))
- Update Profile model, fix avatar url path generation. Fixes #4041, Fixes #4031, Fixes #3523 ([28bf8649](https://github.com/pixelfed/pixelfed/commit/28bf8649))
- Update filesystem config, change FILESYSTEM_DRIVER env variable to DANGEROUSLY_SET_FILESYSTEM_DRIVER and remove from default env configs. Changing the default filesystem should be avoided, use FILESYSTEM_CLOUD for s3 support, otherwise you can break things ([573c88d7](https://github.com/pixelfed/pixelfed/commit/573c88d7))
- Update MediaS3GarbageCollector, fix handle ([2eee36cf](https://github.com/pixelfed/pixelfed/commit/2eee36cf))
- Update StatusController, allow users to delete replies to posts ([738925c2](https://github.com/pixelfed/pixelfed/commit/738925c2))
- Update admin autospam/report email templates, remove image previews ([76be49ac](https://github.com/pixelfed/pixelfed/commit/76be49ac))
- Update LandingService, enable landing directory/explore feed by default and move configuration to config/instance.php file ([780f2507](https://github.com/pixelfed/pixelfed/commit/780f2507))
- Update ImageOptimizePipeline, improve support for disabling image optimizations ([e76289e4](https://github.com/pixelfed/pixelfed/commit/e76289e4))
- Update LandingController, fix config variable names ([b716926b](https://github.com/pixelfed/pixelfed/commit/b716926b))
- Update Privacy Settings, add Directory setting ([634c15e4](https://github.com/pixelfed/pixelfed/commit/634c15e4))
- Update site config ([6d59dc8e](https://github.com/pixelfed/pixelfed/commit/6d59dc8e))
- Update db:raw queries to support laravel v10 ([849e5103](https://github.com/pixelfed/pixelfed/commit/849e5103))
- Update RegisterController, store client ip during registration ([d4c967de](https://github.com/pixelfed/pixelfed/commit/d4c967de))
- Update ApiV1Controller, fix account blocks. Closes #4304 ([98739139](https://github.com/pixelfed/pixelfed/commit/98739139))
- Update RegisterController, improve max_users calculation and add kb page to redirect to if conditions are met ([1bbee6d0](https://github.com/pixelfed/pixelfed/commit/1bbee6d0))
- Update SecuritySettings, remove imagick depdency for 2FA qr code generation image ([506f95c6](https://github.com/pixelfed/pixelfed/commit/506f95c6))
- Update 2fa checkpoint view design ([86c472ac](https://github.com/pixelfed/pixelfed/commit/86c472ac))
- Update sudo mode checkpoint view design ([091e0b2c](https://github.com/pixelfed/pixelfed/commit/091e0b2c))
- Update ForgotPasswordController, add captcha support, improve security and a new redesigned view ([f6e7ff64](https://github.com/pixelfed/pixelfed/commit/f6e7ff64))
- Update ResetPasswordController, add captcha support, improve security and a new redesigned view ([0ab5b96a](https://github.com/pixelfed/pixelfed/commit/0ab5b96a))
- Update Inbox, remove handleCreateActivity logic that rejected posts from accounts without followers ([a93a3efd](https://github.com/pixelfed/pixelfed/commit/a93a3efd))
- Update ApiV1Controller and DiscoverController, fix postgres hashtag search ([055aa6b3](https://github.com/pixelfed/pixelfed/commit/055aa6b3))
- Update StatusTagsPipeline, deduplicate hashtags on postgres ([867cbc75](https://github.com/pixelfed/pixelfed/commit/867cbc75))
- Update SearchApiV2Service, fix postgres hashtag search and prepend wildcard operator to improve results ([6e20d0a6](https://github.com/pixelfed/pixelfed/commit/6e20d0a6))
## [v0.11.5 (2023-03-25)](https://github.com/pixelfed/pixelfed/compare/v0.11.4...v0.11.5)
### New Features
- Mobile App Registration ([#3829](https://github.com/pixelfed/pixelfed/pull/3829))
- Portfolios ([#3705](https://github.com/pixelfed/pixelfed/pull/3705))
- Server Directory ([#3762](https://github.com/pixelfed/pixelfed/pull/3762))
- Manually verify email address (php artisan user:verifyemail) ([682f5f0f](https://github.com/pixelfed/pixelfed/commit/682f5f0f))
- Manually generate in-app registration confirmation links (php artisan user:app-magic-link) ([73eb9e36](https://github.com/pixelfed/pixelfed/commit/73eb9e36))
- Optional home feed caching ([3328b367](https://github.com/pixelfed/pixelfed/commit/3328b367))
- Admin Invites ([b73ca9a1](https://github.com/pixelfed/pixelfed/commit/b73ca9a1))
- Hashtag administration ([84872311](https://github.com/pixelfed/pixelfed/commit/84872311))
- Admin report email notifications ([4e1d0ed5](https://github.com/pixelfed/pixelfed/commit/4e1d0ed5))
- Add Licenses help page, fixes #4238 ([3c712a70](https://github.com/pixelfed/pixelfed/commit/3c712a70))
### Updates
- Update ApiV1Controller, include self likes in favourited_by endpoint ([58b331d2](https://github.com/pixelfed/pixelfed/commit/58b331d2))
@ -26,8 +82,117 @@
- Update landing view, add `app.name` and `app.short_description` for better customizability ([bda9d16b](https://github.com/pixelfed/pixelfed/commit/bda9d16b))
- Update Profile, fix avatarUrl paths. Fixes #3559 #3634 ([989e4249](https://github.com/pixelfed/pixelfed/commit/989e4249))
- Update InboxPipeline, bump request timeout from 5s to 60s ([bb120019](https://github.com/pixelfed/pixelfed/commit/bb120019))
- Update web routes, fix missing hom route ([a9f4ddfc](https://github.com/pixelfed/pixelfed/commit/a9f4ddfc))
- ([](https://github.com/pixelfed/pixelfed/commit/))
- Update web routes, fix missing home route ([a9f4ddfc](https://github.com/pixelfed/pixelfed/commit/a9f4ddfc))
- Allow forceHttps to be disabled, fixes #3710 ([a31bdec7](https://github.com/pixelfed/pixelfed/commit/a31bdec7))
- Update MediaStorageService, fix size check bug ([319f0ba5](https://github.com/pixelfed/pixelfed/commit/319f0ba5))
- Update AvatarSync, fix sync skipping recently fetched avatars by setting last_fetched_at to null before refetching ([a83fc798](https://github.com/pixelfed/pixelfed/commit/a83fc798))
- Refactor AvatarStorage to support migrating avatars to cloud storage, fix remote avatar refetching and merge AvatarSync commands and add deprecation notice to avatar:sync command ([223aea47](https://github.com/pixelfed/pixelfed/commit/223aea47))
- Update AvatarStorage, improve overview calculations ([733b9fd0](https://github.com/pixelfed/pixelfed/commit/733b9fd0))
- Update filesystem config, fix DO Spaces root default ([720b6eb3](https://github.com/pixelfed/pixelfed/commit/720b6eb3))
- Update Avatar pipeline, fix cloud storage media_path ([02edd19d](https://github.com/pixelfed/pixelfed/commit/02edd19d))
- Update FederationController, add instance actor profile to webfinger ([6e3c8097](https://github.com/pixelfed/pixelfed/commit/6e3c8097))
- Update MediaService, add summary attribute for better alt text federation ([a12712cc](https://github.com/pixelfed/pixelfed/commit/a12712cc))
- Update AvatarObserver, fix cloud delete bug by checking if cloud storage is enabled ([9f7672f5](https://github.com/pixelfed/pixelfed/commit/9f7672f5))
- Update DeleteAccountPipeline, dispatch on low queue ([6eabe07c](https://github.com/pixelfed/pixelfed/commit/6eabe07c))
- Update DeleteAccountPipeline, handle flysystem v3 changes by checking files exist before attempting to delete ([23e2998f](https://github.com/pixelfed/pixelfed/commit/23e2998f))
- Update FollowerService, use redis sorted sets for follower relations ([356cc277](https://github.com/pixelfed/pixelfed/commit/356cc277))
- Update FollowerService, use redis sorted sets for following relations ([f46b01af](https://github.com/pixelfed/pixelfed/commit/f46b01af))
- Update PublicApiController, refactor follower/following api endpoints to consume FollowerService instead of querying database ([b39f91b4](https://github.com/pixelfed/pixelfed/commit/b39f91b4))
- Update follower/following profile layout, optimized for mobile devices and use FollowerService ([78a5575d](https://github.com/pixelfed/pixelfed/commit/78a5575d))
- Update sidebar menu, when clicking on the active feed/timeline buttons force a reload and scroll to top of feed ([78a5575d](https://github.com/pixelfed/pixelfed/commit/78a5575d))
- Update InboxPipeline, increase timeout from 60s to 300s ([d1b888b5](https://github.com/pixelfed/pixelfed/commit/d1b888b5))
- Update backup config, fixes #3793, #3920, #3931 ([b0c4cc30](https://github.com/pixelfed/pixelfed/commit/b0c4cc30))
- Update FederationController, add two new queues (follow, shared) to prioritize follow request handling ([8ba33864](https://github.com/pixelfed/pixelfed/commit/8ba33864))
- Dispatch follow accept/reject pipeline jobs to follow queue ([aaed2bf6](https://github.com/pixelfed/pixelfed/commit/aaed2bf6))
- Update MediaStorageService, improve support for pleroma .blob avatars ([66226658](https://github.com/pixelfed/pixelfed/commit/66226658))
- Update ApiV1Controller, remove min avatar size limit, fixes #3715 ([2b0db812](https://github.com/pixelfed/pixelfed/commit/2b0db812))
- Update InboxPipeline, add inbox job queue and separate http sig validation from activity handling ([e6c1604d](https://github.com/pixelfed/pixelfed/commit/e6c1604d))
- Update InboxPipeline, dispatch Follow/Accept Follow jobs to follow queue ([f62d2494](https://github.com/pixelfed/pixelfed/commit/f62d2494))
- Add MediaS3GarbageCollector command to clear local media after uploaded to S3 disks after 12 hours ([b8c3f153](https://github.com/pixelfed/pixelfed/commit/b8c3f153))
- Update MediaS3GarbageCollector command, disable logging by default and optimize huge invocations ([a14af93b](https://github.com/pixelfed/pixelfed/commit/a14af93b))
- Update MediaStorageService, clear MediaService and StatusService caches after localToCloud ([de56b0f0](https://github.com/pixelfed/pixelfed/commit/de56b0f0))
- Add CloudMediaMigrate command to migrate older local media to cloud storage ([382d00d9](https://github.com/pixelfed/pixelfed/commit/382d00d9))
- Update MediaS3GarbageCollector command, handle thumbnail deletion ([95bbcc38](https://github.com/pixelfed/pixelfed/commit/95bbcc38))
- Update StatusReplyPipeline, remove expensive reply count re-calculation query ([a2f8aad1](https://github.com/pixelfed/pixelfed/commit/a2f8aad1))
- Update CommentPipeline, remove expensive reply count re-calculation query ([b457a446](https://github.com/pixelfed/pixelfed/commit/b457a446))
- Update FederationController, improve inbox/sharedInbox delete handling ([2180a2de](https://github.com/pixelfed/pixelfed/commit/2180a2de))
- Update HashtagController, improve trending hashtag endpoint ([4873c7dd](https://github.com/pixelfed/pixelfed/commit/4873c7dd))
- Fix CustomEmoji, properly handle shortcode updates and delete old copy in case the extension changes ([bc29073a](https://github.com/pixelfed/pixelfed/commit/bc29073a))
- Update reply pipelines, restore reply_count logic ([0d780ffb](https://github.com/pixelfed/pixelfed/commit/0d780ffb))
- Update StatusTagsPipeline, reject if `type` not set ([91085c45](https://github.com/pixelfed/pixelfed/commit/91085c45))
- Update ReplyPipelines, use more efficent reply count calculation ([d4dfa95c](https://github.com/pixelfed/pixelfed/commit/d4dfa95c))
- Update StatusDelete pipeline, dispatch async ([257c0949](https://github.com/pixelfed/pixelfed/commit/257c0949))
- Update lexer/extractor to handle banned hashtags ([909a8a5a](https://github.com/pixelfed/pixelfed/commit/909a8a5a))
- Update FederationController, fix double lock bug ([9fcccca9](https://github.com/pixelfed/pixelfed/commit/9fcccca9))
- Update AdminInvite component, fix email regex ([2aea77d3](https://github.com/pixelfed/pixelfed/commit/2aea77d3))
- Update database config, use single transaction and skip lock tables for mysql dump ([936f1e7a](https://github.com/pixelfed/pixelfed/commit/936f1e7a))
- Update database config, add sticky flag https://laravel.com/docs/9.x/database#the-sticky-option ([10b65980](https://github.com/pixelfed/pixelfed/commit/10b65980))
- Update profile audience to filter blocked instances ([e0c3dae3](https://github.com/pixelfed/pixelfed/commit/e0c3dae3))
- Update SearchApiV2Service, improve query performance ([4d1f2811](https://github.com/pixelfed/pixelfed/commit/4d1f2811))
- Update InstanceService, improve unlisted/banned network post filtering ([a0da6ec3](https://github.com/pixelfed/pixelfed/commit/a0da6ec3))
- Update ApiV1DotController, fix inAppRegistrationConfirm logic ([6cfbedd9](https://github.com/pixelfed/pixelfed/commit/6cfbedd9))
- Update ApiV1Controller, allow description (alt text) updates after status is published ([869c3ed1](https://github.com/pixelfed/pixelfed/commit/869c3ed1))
- Update AdminApiController, fix postgres support ([84fb59d0](https://github.com/pixelfed/pixelfed/commit/84fb59d0))
- Update StatusReplyPipeline, fix comment counts ([164aa577](https://github.com/pixelfed/pixelfed/commit/164aa577))
- Update ComposeModal, add Alt Text button to caption screen ([4db48188](https://github.com/pixelfed/pixelfed/commit/4db48188))
- Update AccountService, fix actor cache invalidation ([498b46f7](https://github.com/pixelfed/pixelfed/commit/498b46f7))
- Update SharePipeline, fix share handling and notification generation ([83e1e203](https://github.com/pixelfed/pixelfed/commit/83e1e203))
- Update SharePipeline, fix ReblogService and undo handling ([016c6e41](https://github.com/pixelfed/pixelfed/commit/016c6e41))
- Update AP Helpers, fix media validation bug that would reject media with alttext/name longer than 255 chars and store remote alt text if set ([a7f58349](https://github.com/pixelfed/pixelfed/commit/a7f58349))
- Update MentionPipeline, store non-local mentions ([17149230](https://github.com/pixelfed/pixelfed/commit/17149230))
- Update Like model, increase rate limit to 500 likes per day ([ab7676f9](https://github.com/pixelfed/pixelfed/commit/ab7676f9))
- Update ComposeController, fix validation issue ([80e6a5a9](https://github.com/pixelfed/pixelfed/commit/80e6a5a9))
- Update reply view, fix visibility filtering ([d419af4b](https://github.com/pixelfed/pixelfed/commit/d419af4b))
- Update AP helpers, ingest attachments in replies ([c504e643](https://github.com/pixelfed/pixelfed/commit/c504e643))
- Update Media model, use cloud filesystem url if enabled instead of cdn_url to easily update S3 media urls ([e6bc57d7](https://github.com/pixelfed/pixelfed/commit/e6bc57d7))
- Update ap helpers, fix unset media name bug ([083f506b](https://github.com/pixelfed/pixelfed/commit/083f506b))
- Update MediaStorageService, fix improper path ([964c62da](https://github.com/pixelfed/pixelfed/commit/964c62da))
- Update ApiV1Controller, fix account statuses and bookmark pagination ([9f66d6b6](https://github.com/pixelfed/pixelfed/commit/9f66d6b6))
- Update SearchApiV2Service, improve account search results ([f6a588f9](https://github.com/pixelfed/pixelfed/commit/f6a588f9))
- Update profile model, improve avatarUrl fallback ([620ee826](https://github.com/pixelfed/pixelfed/commit/620ee826))
- Update ApiV1Controller, use cursor pagination for favourited_by and reblogged_by endpoints ([e1c7e701](https://github.com/pixelfed/pixelfed/commit/e1c7e701))
- Update ApiV1Controller, fix favourited_by and reblogged_by follows attribute ([1a130f3e](https://github.com/pixelfed/pixelfed/commit/1a130f3e))
- Update notifications component, improve UX with exponential retry and loading state ([937e6d07](https://github.com/pixelfed/pixelfed/commit/937e6d07))
- Update likeModal and shareModal components, use new pagination logic and re-add Follow/Unfollow buttons ([b565ead6](https://github.com/pixelfed/pixelfed/commit/b565ead6))
- Update profileFeed component, fix pagination ([7cf41628](https://github.com/pixelfed/pixelfed/commit/7cf41628))
- Update ApiV1Controller, add BookmarkService logic to bookmark endpoints ([29b1af10](https://github.com/pixelfed/pixelfed/commit/29b1af10))
- Update ApiV1Controller, filter conversations without last_status ([e8a6a8c7](https://github.com/pixelfed/pixelfed/commit/e8a6a8c7))
- Update ApiV1Controller and BookmarkController, fix api differences and allow unbookmarking regardless of relationship ([e343061a](https://github.com/pixelfed/pixelfed/commit/e343061a))
- Update ApiV1Controller, add pixelfed entity support to bookmarks endpoint ([94069db9](https://github.com/pixelfed/pixelfed/commit/94069db9))
- Update PostReactions, reduce bookmark timeout to 2s from 5s ([a8094e6c](https://github.com/pixelfed/pixelfed/commit/a8094e6c))
- Update CollectionController, fixes #3946 ([abd52f4d](https://github.com/pixelfed/pixelfed/commit/abd52f4d))
- Update ComposeController, fix add to collection logic ([9f8957b9](https://github.com/pixelfed/pixelfed/commit/9f8957b9))
- Update v1.1 api, add post moderation endpoint ([9bbd6dcd](https://github.com/pixelfed/pixelfed/commit/9bbd6dcd))
- Update StatusService, on purge remove from NetworkTimelineService cache ([18940cb2](https://github.com/pixelfed/pixelfed/commit/18940cb2))
- Update mute/block logic with admin defined limits and improved filtering to skip deleted accounts ([5b879f01](https://github.com/pixelfed/pixelfed/commit/5b879f01))
- Update FollowPipeline, fix followers_count and following_count counters ([6153b620](https://github.com/pixelfed/pixelfed/commit/6153b620))
- Update ApiV1Controller, fix media update. Fixes #4196 ([f3164650](https://github.com/pixelfed/pixelfed/commit/f3164650))
- Update SearchApiV2Service, fix hashtag search. ([1992b5bc](https://github.com/pixelfed/pixelfed/commit/1992b5bc))
- Update ApiV1Controller, allow optional mastodonMode on v2/search endpoint. ([f4a69631](https://github.com/pixelfed/pixelfed/commit/f4a69631))
- Update ApiV1Controller, add cursor pagination and pagination link headers to account/{id}/followers and account/{id}/following endpoints with legacy support for `page=` simple pagination ([713aa5fd](https://github.com/pixelfed/pixelfed/commit/713aa5fd))
- Update legacy Profile component to use new cursor pagination for following/follower modals ([7a1495e6](https://github.com/pixelfed/pixelfed/commit/7a1495e6))
- Update ApiV1Controller, fix link header pagination in /api/v1/statuses/{id}/favourited_by ([adc82eca](https://github.com/pixelfed/pixelfed/commit/adc82eca))
- Update ApiV1Controller, fix link header pagination in /api/v1/statuses/{id}/reblogged_by ([e346b675](https://github.com/pixelfed/pixelfed/commit/e346b675))
- Update ApiV1Controller, fix following/follower entities, use masto schema by default and update components accordingly ([4716c280](https://github.com/pixelfed/pixelfed/commit/4716c280))
- Update FollowerController, remove deprecated /i/follow endpoint ([4739d614](https://github.com/pixelfed/pixelfed/commit/4739d614))
- Update queue config, set "after_commit" to true ([304ea956](https://github.com/pixelfed/pixelfed/commit/304ea956))
- Update ApiV1Controller, fix home timeline bug ([a8ec8445](https://github.com/pixelfed/pixelfed/commit/a8ec8445))
- Update ApiV1Controller, increase home timeline max limit to 100 to fix compatibility with mastoapi ([5cf9ba78](https://github.com/pixelfed/pixelfed/commit/5cf9ba78))
- Update ApiV1Controller, preserve album order. Fixes #3708 ([deb26971](https://github.com/pixelfed/pixelfed/commit/deb26971))
- Update site config endpoint ([f9be48d6](https://github.com/pixelfed/pixelfed/commit/f9be48d6))
- Update Portfolios, add ActivityPub + RSS support, light mode, style customization and more ([5ad0d883](https://github.com/pixelfed/pixelfed/commit/5ad0d883))
- Update atom feed, improve cache expiry and fix double encoding bug. Fixes #4121 ([467c9d75](https://github.com/pixelfed/pixelfed/commit/467c9d75))
- Update email settings, add dangerzone middleware to prompt for password before you can change your email address. Fixes #4101 ([186ba7f0](https://github.com/pixelfed/pixelfed/commit/186ba7f0))
- Update InboxPipelines, improve handling of missing signature validation headers ([419c0fb0](https://github.com/pixelfed/pixelfed/commit/419c0fb0))
- Update admin instances dashboard ([ecfc0766](https://github.com/pixelfed/pixelfed/commit/ecfc0766))
- Update ap helpers, fix album order bug by setting media order ([871f798c](https://github.com/pixelfed/pixelfed/commit/871f798c))
- Update image pipeline, dispatch jobs to mmo queue and add "replace_id" param to v2/media endpoint to dispatch delayed MediaDeletePipeline job for original media id to improve media gc on supported clients ([5a67e9f9](https://github.com/pixelfed/pixelfed/commit/5a67e9f9))
- Update admin instance management, improve filtering/sorting and add import/export support ([d5d9500d](https://github.com/pixelfed/pixelfed/commit/d5d9500d))
- Update Post component, show state error when status account is null or missing ([e6dc6234](https://github.com/pixelfed/pixelfed/commit/e6dc6234))
- Update private profile view, add rel=me support, hide avatar/bio when not logged in and add robots meta tag to block search engine indexing on private profiles ([ab4bb9a0](https://github.com/pixelfed/pixelfed/commit/ab4bb9a0))
- Update settings, set maxlength on name and bio inputs. Fixes #4248 ([558700fc](https://github.com/pixelfed/pixelfed/commit/558700fc))
- Update api routes, add post method support to /api/v1/accounts/update_credentials to properly handle binary form data (avatars). Fixes #4250 ([1ae19ea5](https://github.com/pixelfed/pixelfed/commit/1ae19ea5))
- Update ApiV1Controller, improve timeline account hydration ([4e79c772](https://github.com/pixelfed/pixelfed/commit/4e79c772))
## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)

View file

@ -11,7 +11,10 @@ class AccountInterstitial extends Model
*
* @var array
*/
protected $dates = ['read_at', 'appeal_requested_at'];
protected $casts = [
'read_at' => 'datetime',
'appeal_requested_at' => 'datetime'
];
public const JSON_MESSAGE = 'Please use web browser to proceed.';

View file

@ -6,7 +6,10 @@ use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
protected $dates = ['processed_at'];
protected $casts = [
'processed_at' => 'datetime'
];
protected $fillable = ['data', 'to_id', 'from_id', 'object_type'];
public function toProfile()

View file

@ -14,13 +14,13 @@ class Avatar extends Model
*
* @var array
*/
protected $dates = [
'deleted_at',
'last_fetched_at',
'last_processed_at'
protected $casts = [
'deleted_at' => 'datetime',
'last_fetched_at' => 'datetime',
'last_processed_at' => 'datetime'
];
protected $fillable = ['profile_id'];
protected $guarded = [];
protected $visible = [
'id',

View file

@ -0,0 +1,179 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\AdminInvite;
use Illuminate\Support\Str;
class AdminInviteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:invite';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create an invite link';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Pixelfed Admin Inviter');
$this->line(' ');
$this->info(' Manage user registration invite links');
$this->line(' ');
$action = $this->choice(
'Select an action',
[
'Create invite',
'View invites',
'Expire invite',
'Cancel'
],
3
);
switch($action) {
case 'Create invite':
return $this->create();
break;
case 'View invites':
return $this->view();
break;
case 'Expire invite':
return $this->expire();
break;
case 'Cancel':
return;
break;
}
}
protected function create()
{
$this->info('Create Invite');
$this->line('=============');
$this->info('Set an optional invite name (only visible to admins)');
$name = $this->ask('Invite Name (optional)', 'Untitled Invite');
$this->info('Set an optional invite description (only visible to admins)');
$description = $this->ask('Invite Description (optional)');
$this->info('Set an optional message to invitees (visible to all)');
$message = $this->ask('Invite Message (optional)', 'You\'ve been invited to join');
$this->info('Set maximum # of invite uses, use 0 for unlimited');
$max_uses = $this->ask('Max uses', 1);
$shouldExpire = $this->choice(
'Set an invite expiry date?',
[
'No - invite never expires',
'Yes - expire after 24 hours',
'Custom - let me pick an expiry date'
],
0
);
switch($shouldExpire) {
case 'No - invite never expires':
$expires = null;
break;
case 'Yes - expire after 24 hours':
$expires = now()->addHours(24);
break;
case 'Custom - let me pick an expiry date':
$this->info('Set custom expiry date in days');
$customExpiry = $this->ask('Custom Expiry', 14);
$expires = now()->addDays($customExpiry);
break;
}
$this->info('Skip email verification for invitees?');
$skipEmailVerification = $this->choice('Skip email verification', ['No', 'Yes'], 0);
$invite = new AdminInvite;
$invite->name = $name;
$invite->description = $description;
$invite->message = $message;
$invite->max_uses = $max_uses;
$invite->skip_email_verification = $skipEmailVerification === 'Yes';
$invite->expires_at = $expires;
$invite->invite_code = Str::uuid() . Str::random(random_int(1,6));
$invite->save();
$this->info('####################');
$this->info('# Invite Generated!');
$this->line(' ');
$this->info($invite->url());
$this->line(' ');
return Command::SUCCESS;
}
protected function view()
{
$this->info('View Invites');
$this->line('=============');
if(AdminInvite::count() == 0) {
$this->line(' ');
$this->error('No invites found!');
return;
}
$this->table(
['Invite Code', 'Uses Left', 'Expires'],
AdminInvite::all(['invite_code', 'max_uses', 'uses', 'expires_at'])->map(function($invite) {
return [
'invite_code' => $invite->invite_code,
'uses_left' => $invite->max_uses ? ($invite->max_uses - $invite->uses) : '∞',
'expires_at' => $invite->expires_at ? $invite->expires_at->diffForHumans() : 'never'
];
})->toArray()
);
}
protected function expire()
{
$token = $this->anticipate('Enter invite code to expire', function($val) {
if(!$val || empty($val)) {
return [];
}
return AdminInvite::where('invite_code', 'like', '%' . $val . '%')->pluck('invite_code')->toArray();
});
if(!$token || empty($token)) {
$this->error('Invalid invite code');
return;
}
$invite = AdminInvite::whereInviteCode($token)->first();
if(!$invite) {
$this->error('Invalid invite code');
return;
}
$invite->max_uses = 1;
$invite->expires_at = now()->subHours(2);
$invite->save();
$this->info('Expired the following invite: ' . $invite->url());
}
}

View file

@ -0,0 +1,293 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Avatar;
use App\Profile;
use App\User;
use Cache;
use Storage;
use App\Services\AccountService;
use App\Util\Lexer\PrettyNumber;
use Illuminate\Support\Str;
use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
class AvatarStorage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatar:storage';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Manage avatar storage';
public $found = 0;
public $notFetched = 0;
public $fixed = 0;
public $missing = 0;
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Pixelfed Avatar Storage Manager');
$this->line(' ');
$segments = [
[
'Local',
Avatar::whereNull('is_remote')->count(),
PrettyNumber::size(Avatar::whereNull('is_remote')->sum('size'))
],
[
'Remote',
Avatar::whereIsRemote(true)->count(),
PrettyNumber::size(Avatar::whereIsRemote(true)->sum('size'))
],
[
'Cached (CDN)',
Avatar::whereNotNull('cdn_url')->count(),
PrettyNumber::size(Avatar::whereNotNull('cdn_url')->sum('size'))
],
[
'Uncached',
Avatar::whereNull('cdn_url')->count(),
PrettyNumber::size(Avatar::whereNull('cdn_url')->sum('size'))
],
[
'------------',
'----------',
'-----'
],
[
'Total',
Avatar::count(),
PrettyNumber::size(Avatar::sum('size'))
],
];
$this->table(
['Segment', 'Count', 'Space Used'],
$segments
);
$this->line(' ');
if(config_cache('pixelfed.cloud_storage')) {
$this->info('✅ - Cloud storage configured');
$this->line(' ');
}
if(config('instance.avatar.local_to_cloud')) {
$this->info('✅ - Store avatars on cloud filesystem');
$this->line(' ');
}
if(config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
$disk = Storage::disk(config_cache('filesystems.cloud'));
$exists = $disk->exists('cache/avatars/default.jpg');
$state = $exists ? '✅' : '❌';
$msg = $state . ' - Cloud default avatar exists';
$this->info($msg);
}
$options = config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud') ?
[
'Cancel',
'Upload default avatar to cloud',
'Move local avatars to cloud',
'Re-fetch remote avatars'
] : [
'Cancel',
'Re-fetch remote avatars'
];
$this->missing = Profile::where('created_at', '<', now()->subDays(1))->doesntHave('avatar')->count();
if($this->missing != 0) {
$options[] = 'Fix missing avatars';
}
$choice = $this->choice(
'Select action:',
$options,
0
);
return $this->handleChoice($choice);
}
protected function handleChoice($id)
{
switch ($id) {
case 'Cancel':
return;
break;
case 'Upload default avatar to cloud':
return $this->uploadDefaultAvatar();
break;
case 'Move local avatars to cloud':
return $this->uploadAvatarsToCloud();
break;
case 'Re-fetch remote avatars':
return $this->refetchRemoteAvatars();
break;
case 'Fix missing avatars':
return $this->fixMissingAvatars();
break;
}
}
protected function uploadDefaultAvatar()
{
if(!$this->confirm('Are you sure you want to upload the default avatar to the cloud storage disk?')) {
return;
}
$disk = Storage::disk(config_cache('filesystems.cloud'));
$disk->put('cache/avatars/default.jpg', Storage::get('public/avatars/default.jpg'));
Avatar::whereMediaPath('public/avatars/default.jpg')->update(['cdn_url' => $disk->url('cache/avatars/default.jpg')]);
$this->info('Successfully uploaded default avatar to cloud storage!');
$this->info($disk->url('cache/avatars/default.jpg'));
}
protected function uploadAvatarsToCloud()
{
if(!config_cache('pixelfed.cloud_storage') || !config('instance.avatar.local_to_cloud')) {
$this->error('Enable cloud storage and avatar cloud storage to perform this action');
return;
}
$confirm = $this->confirm('Are you sure you want to move local avatars to cloud storage?');
if(!$confirm) {
$this->error('Aborted action');
return;
}
$disk = Storage::disk(config_cache('filesystems.cloud'));
if($disk->missing('cache/avatars/default.jpg')) {
$disk->put('cache/avatars/default.jpg', Storage::get('public/avatars/default.jpg'));
}
Avatar::whereNull('is_remote')->chunk(5, function($avatars) use($disk) {
foreach($avatars as $avatar) {
if($avatar->media_path === 'public/avatars/default.jpg') {
$avatar->cdn_url = $disk->url('cache/avatars/default.jpg');
$avatar->save();
} else {
if(!$avatar->media_path || !Str::of($avatar->media_path)->startsWith('public/avatars/')) {
continue;
}
$ext = pathinfo($avatar->media_path, PATHINFO_EXTENSION);
$newPath = 'cache/avatars/' . $avatar->profile_id . '/avatar_' . strtolower(Str::random(6)) . '.' . $ext;
$existing = Storage::disk('local')->get($avatar->media_path);
if(!$existing) {
continue;
}
$newMediaPath = $disk->put($newPath, $existing);
$avatar->media_path = $newPath;
$avatar->cdn_url = $disk->url($newPath);
$avatar->save();
}
Cache::forget('avatar:' . $avatar->profile_id);
Cache::forget(AccountService::CACHE_KEY . $avatar->profile_id);
}
});
}
protected function refetchRemoteAvatars()
{
if(!$this->confirm('Are you sure you want to refetch all remote avatars? This could take a while.')) {
return;
}
if(config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) {
$this->error('You have cloud storage disabled and local avatar storage disabled, we cannot refetch avatars.');
return;
}
$count = Profile::has('avatar')
->with('avatar')
->whereNull('user_id')
->count();
$this->info('Found ' . $count . ' remote avatars to re-fetch');
$this->line(' ');
$bar = $this->output->createProgressBar($count);
Profile::has('avatar')
->with('avatar')
->whereNull('user_id')
->chunk(50, function($profiles) use($bar) {
foreach($profiles as $profile) {
$avatar = $profile->avatar;
$avatar->last_fetched_at = null;
$avatar->save();
RemoteAvatarFetch::dispatch($profile)->onQueue('low');
$bar->advance();
}
});
$this->line(' ');
$this->line(' ');
$this->info('Finished dispatching avatar refetch jobs!');
$this->line(' ');
$this->info('This may take a few minutes to complete, you may need to run "php artisan cache:clear" after the jobs are processed.');
$this->line(' ');
}
protected function incr($name)
{
switch($name) {
case 'found':
$this->found = $this->found + 1;
break;
case 'notFetched':
$this->notFetched = $this->notFetched + 1;
break;
case 'fixed':
$this->fixed++;
break;
}
}
protected function fixMissingAvatars()
{
if(!$this->confirm('Are you sure you want to fix missing avatars?')) {
return;
}
$this->info('Found ' . $this->missing . ' accounts with missing profiles');
Profile::where('created_at', '<', now()->subDays(1))
->doesntHave('avatar')
->chunk(50, function($profiles) {
foreach($profiles as $profile) {
Avatar::updateOrCreate([
'profile_id' => $profile->id
], [
'media_path' => 'public/avatars/default.jpg',
'is_remote' => $profile->domain == null && $profile->private_key == null
]);
$this->incr('fixed');
}
});
$this->line(' ');
$this->line(' ');
$this->info('Fixed ' . $this->fixed . ' accounts with a blank avatar');
}
}

View file

@ -48,6 +48,18 @@ class AvatarSync extends Command
public function handle()
{
$this->info('Welcome to the avatar sync manager');
$this->line(' ');
$this->line(' ');
$this->error('This command is deprecated and will be removed in a future version');
$this->error('You should use the following command instead: ');
$this->line(' ');
$this->info('php artisan avatar:storage');
$this->line(' ');
$confirm = $this->confirm('Are you sure you want to use this deprecated command even though it is no longer supported?');
if(!$confirm) {
return;
}
$actions = [
'Analyze',
@ -123,7 +135,7 @@ class AvatarSync extends Command
$bar = $this->output->createProgressBar($count);
$bar->start();
Profile::chunk(5000, function($profiles) use ($bar) {
Profile::chunk(50, function($profiles) use ($bar) {
foreach($profiles as $profile) {
if($profile->domain == null) {
$bar->advance();
@ -146,41 +158,11 @@ class AvatarSync extends Command
protected function fetch()
{
$this->info('Fetching ....');
Avatar::whereIsRemote(true)
->whereNull('cdn_url')
// ->with('profile')
->chunk(10, function($avatars) {
foreach($avatars as $avatar) {
if(!$avatar || !$avatar->profile) {
continue;
}
$url = $avatar->profile->remote_url;
if(!$url || !Helpers::validateUrl($url)) {
continue;
}
try {
$res = Helpers::fetchFromUrl($url);
if(
!is_array($res) ||
!isset($res['@context']) ||
!isset($res['icon']) ||
!isset($res['icon']['type']) ||
!isset($res['icon']['url']) ||
!Str::endsWith($res['icon']['url'], ['.png', '.jpg', '.jpeg'])
) {
continue;
}
} catch (\GuzzleHttp\Exception\RequestException $e) {
continue;
} catch(\Illuminate\Http\Client\ConnectionException $e) {
continue;
}
$avatar->remote_url = $res['icon']['url'];
$avatar->save();
RemoteAvatarFetch::dispatch($avatar->profile);
}
});
$this->error('This action has been deprecated, please run the following command instead:');
$this->line(' ');
$this->info('php artisan avatar:storage');
$this->line(' ');
return;
}
protected function fix()
@ -208,12 +190,10 @@ class AvatarSync extends Command
protected function sync()
{
Avatar::whereIsRemote(true)
->with('profile')
->chunk(10, function($avatars) {
foreach($avatars as $avatar) {
RemoteAvatarFetch::dispatch($avatar->profile);
}
});
}
$this->error('This action has been deprecated, please run the following command instead:');
$this->line(' ');
$this->info('php artisan avatar:storage');
$this->line(' ');
return;
}
}

View file

@ -40,22 +40,20 @@ class CatchUnoptimizedMedia extends Command
*/
public function handle()
{
DB::transaction(function() {
Media::whereNull('processed_at')
->where('skip_optimize', '!=', true)
->whereNull('remote_url')
->whereNotNull('status_id')
->whereNotNull('media_path')
->where('created_at', '>', now()->subHours(1))
->whereIn('mime', [
'image/jpeg',
'image/png',
])
->chunk(50, function($medias) {
foreach ($medias as $media) {
ImageOptimize::dispatch($media);
}
});
});
Media::whereNull('processed_at')
->where('created_at', '>', now()->subHours(1))
->where('skip_optimize', '!=', true)
->whereNull('remote_url')
->whereNotNull('status_id')
->whereNotNull('media_path')
->whereIn('mime', [
'image/jpeg',
'image/png',
])
->chunk(50, function($medias) {
foreach ($medias as $media) {
ImageOptimize::dispatch($media);
}
});
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use App\Services\MediaStorageService;
use App\Util\Lexer\PrettyNumber;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CloudMediaMigrate extends Command
{
public $totalSize = 0;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:migrate2cloud {--limit=200} {--huge}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Move older media to cloud storage';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$enabled = config('pixelfed.cloud_storage');
if(!$enabled) {
$this->error('Cloud storage not enabled. Exiting...');
return;
}
$limit = $this->option('limit');
$hugeMode = $this->option('huge');
if($limit > 500 && !$hugeMode) {
$this->error('Max limit exceeded, use a limit lower than 500 or run again with the --huge flag');
return;
}
$bar = $this->output->createProgressBar($limit);
$bar->start();
Media::whereNot('version', '4')
->where('created_at', '<', now()->subDays(2))
->whereRemoteMedia(false)
->whereNotNull(['status_id', 'profile_id'])
->whereNull(['cdn_url', 'replicated_at'])
->orderByDesc('size')
->take($limit)
->get()
->each(function($media) use($bar) {
if(Storage::disk('local')->exists($media->media_path)) {
$this->totalSize = $this->totalSize + $media->size;
try {
MediaStorageService::store($media);
} catch (FileNotFoundException $e) {
$this->error('Error migrating media ' . $media->id . ' to cloud storage: ' . $e->getMessage());
return;
} catch (NotFoundHttpException $e) {
$this->error('Error migrating media ' . $media->id . ' to cloud storage: ' . $e->getMessage());
return;
} catch (\Exception $e) {
$this->error('Error migrating media ' . $media->id . ' to cloud storage: ' . $e->getMessage());
return;
}
}
$bar->advance();
});
$bar->finish();
$this->line(' ');
$this->info('Finished!');
if($this->totalSize) {
$this->info('Uploaded ' . PrettyNumber::size($this->totalSize) . ' of media to cloud storage!');
$this->line(' ');
$this->info('These files are still stored locally, and will be automatically removed.');
}
return Command::SUCCESS;
}
}

View file

@ -5,12 +5,36 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{
Avatar,
Bookmark,
Collection,
DirectMessage,
FollowRequest,
Follower,
HashtagFollow,
Like,
Media,
MediaTag,
Mention,
Profile,
Report,
ReportComment,
ReportLog,
StatusArchived,
StatusHashtag,
StatusView,
Status,
User
Story,
StoryView,
User,
UserFilter
};
use App\Models\{
Conversation,
Portfolio,
UserPronoun
};
use DB, Cache;
class FixDuplicateProfiles extends Command
{
@ -45,31 +69,186 @@ class FixDuplicateProfiles extends Command
*/
public function handle()
{
$profiles = Profile::selectRaw('count(user_id) as count,user_id')->whereNotNull('user_id')->groupBy('user_id')->orderBy('user_id', 'desc')->get()->where('count', '>', 1);
$count = $profiles->count();
if($count == 0) {
$this->info("No duplicate profiles found!");
return;
}
$this->info("Found {$count} accounts with duplicate profiles...");
$bar = $this->output->createProgressBar($count);
$bar->start();
$duplicates = DB::table('profiles')
->whereNull('domain')
->select('username', DB::raw('COUNT(*) as "count"'))
->groupBy('username')
->havingRaw('COUNT(*) > 1')
->pluck('username');
foreach ($profiles as $profile) {
$dup = Profile::whereUserId($profile->user_id)->get();
if(
$dup->first()->username === $dup->last()->username &&
$dup->last()->statuses()->count() == 0 &&
$dup->last()->followers()->count() == 0 &&
$dup->last()->likes()->count() == 0 &&
$dup->last()->media()->count() == 0
) {
$dup->last()->avatar->forceDelete();
$dup->last()->forceDelete();
foreach($duplicates as $dupe) {
$ids = Profile::whereNull('domain')->whereUsername($dupe)->pluck('id');
if(!$ids || $ids->count() != 2) {
continue;
}
$bar->advance();
$id = $ids->first();
$oid = $ids->last();
$user = User::whereUsername($dupe)->first();
if($user) {
$user->profile_id = $id;
$user->save();
} else {
continue;
}
$this->checkAvatar($id, $oid);
$this->checkBookmarks($id, $oid);
$this->checkCollections($id, $oid);
$this->checkConversations($id, $oid);
$this->checkDirectMessages($id, $oid);
$this->checkFollowRequest($id, $oid);
$this->checkFollowers($id, $oid);
$this->checkHashtagFollow($id, $oid);
$this->checkLikes($id, $oid);
$this->checkMedia($id, $oid);
$this->checkMediaTag($id, $oid);
$this->checkMention($id, $oid);
$this->checkPortfolio($id, $oid);
$this->checkReport($id, $oid);
$this->checkStatusArchived($id, $oid);
$this->checkStatusHashtag($id, $oid);
$this->checkStatusView($id, $oid);
$this->checkStatus($id, $oid);
$this->checkStory($id, $oid);
$this->checkStoryView($id, $oid);
$this->checkUserFilter($id, $oid);
$this->checkUserPronoun($id, $oid);
Profile::find($oid)->forceDelete();
}
$bar->finish();
Cache::clear();
}
protected function checkAvatar($id, $oid)
{
Avatar::whereProfileId($oid)->forceDelete();
}
protected function checkBookmarks($id, $oid)
{
Bookmark::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkCollections($id, $oid)
{
Collection::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkConversations($id, $oid)
{
Conversation::whereToId($oid)->update(['to_id' => $id]);
Conversation::whereFromId($oid)->update(['from_id' => $id]);
}
protected function checkDirectMessages($id, $oid)
{
DirectMessage::whereToId($oid)->update(['to_id' => $id]);
DirectMessage::whereFromId($oid)->update(['from_id' => $id]);
}
protected function checkFollowRequest($id, $oid)
{
FollowRequest::whereFollowerId($oid)->update(['follower_id' => $id]);
FollowRequest::whereFollowingId($oid)->update(['following_id' => $id]);
}
protected function checkFollowers($id, $oid)
{
$f = Follower::whereProfileId($oid)->pluck('following_id');
foreach($f as $fo) {
Follower::updateOrCreate([
'profile_id' => $id,
'following_id' => $fo
]);
}
$f = Follower::whereFollowingId($oid)->pluck('profile_id');
foreach($f as $fo) {
Follower::updateOrCreate([
'profile_id' => $fo,
'following_id' => $id
]);
}
}
protected function checkHashtagFollow($id, $oid)
{
HashtagFollow::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkLikes($id, $oid)
{
Like::whereStatusProfileId($oid)->update(['status_profile_id' => $id]);
Like::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkMedia($id, $oid)
{
Media::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkMediaTag($id, $oid)
{
MediaTag::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkMention($id, $oid)
{
Mention::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkPortfolio($id, $oid)
{
Portfolio::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkReport($id, $oid)
{
ReportComment::whereProfileId($oid)->update(['profile_id' => $id]);
ReportLog::whereProfileId($oid)->update(['profile_id' => $id]);
Report::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStatusArchived($id, $oid)
{
StatusArchived::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStatusHashtag($id, $oid)
{
StatusHashtag::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStatusView($id, $oid)
{
StatusView::whereStatusProfileId($oid)->update(['profile_id' => $id]);
StatusView::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStatus($id, $oid)
{
Status::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStory($id, $oid)
{
Story::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkStoryView($id, $oid)
{
StoryView::whereProfileId($oid)->update(['profile_id' => $id]);
}
protected function checkUserFilter($id, $oid)
{
UserFilter::whereUserId($oid)->update(['user_id' => $id]);
UserFilter::whereFilterableType('App\Profile')->whereFilterableId($oid)->update(['filterable_id' => $id]);
}
protected function checkUserPronoun($id, $oid)
{
UserPronoun::whereProfileId($oid)->update(['profile_id' => $id]);
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use App\Media;
use League\Flysystem\MountManager;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\MediaPipeline\MediaFixLocalFilesystemCleanupPipeline;
class FixMediaDriver extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:fix-nonlocal-driver';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix filesystem when FILESYSTEM_DRIVER not set to local';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if(config('filesystems.default') !== 'local') {
$this->error('Invalid default filesystem, set FILESYSTEM_DRIVER=local to proceed');
return Command::SUCCESS;
}
if(config_cache('pixelfed.cloud_storage') == false) {
$this->error('Cloud storage not enabled, exiting...');
return Command::SUCCESS;
}
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Media Filesystem Fix');
$this->info(' =====================');
$this->info(' Fix media that was created when FILESYSTEM_DRIVER=local');
$this->info(' was not properly set. This command will fix media urls');
$this->info(' and optionally optimize/generate thumbnails when applicable,');
$this->info(' clean up temporary local media files and clear the app cache');
$this->info(' to fix media paths/urls.');
$this->info(' ');
$this->error(' Remember, FILESYSTEM_DRIVER=local must remain set or you will break things!');
if(!$this->confirm('Are you sure you want to perform this command?')) {
$this->info('Exiting...');
return Command::SUCCESS;
}
$optimize = $this->choice(
'Do you want to optimize media and generate thumbnails? This will store s3 locally and re-upload optimized versions.',
['no', 'yes'],
1
);
$cloud = Storage::disk(config('filesystems.cloud'));
$mountManager = new MountManager([
's3' => $cloud->getDriver(),
'local' => Storage::disk('local')->getDriver(),
]);
$this->info('Fixing media, this may take a while...');
$this->line(' ');
$bar = $this->output->createProgressBar(Media::whereNotNull('status_id')->whereNull('cdn_url')->count());
$bar->start();
foreach(Media::whereNotNull('status_id')->whereNull('cdn_url')->lazyById(20) as $media) {
if($cloud->exists($media->media_path)) {
if($optimize === 'yes') {
$mountManager->copy(
's3://' . $media->media_path,
'local://' . $media->media_path
);
sleep(1);
if(empty($media->original_sha256)) {
$hash = \hash_file('sha256', Storage::disk('local')->path($media->media_path));
$media->original_sha256 = $hash;
$media->save();
sleep(1);
}
if(
$media->mime &&
in_array($media->mime, [
'image/jpeg',
'image/png',
'image/webp'
])
) {
ImageOptimize::dispatch($media);
sleep(3);
}
} else {
$media->cdn_url = $cloud->url($media->media_path);
$media->save();
}
}
$bar->advance();
}
$bar->finish();
$this->line(' ');
$this->line(' ');
$this->callSilently('cache:clear');
$this->info('Successfully fixed media paths and cleared cached!');
if($optimize === 'yes') {
MediaFixLocalFilesystemCleanupPipeline::dispatch()->delay(now()->addMinutes(15))->onQueue('default');
$this->line(' ');
$this->info('A cleanup job has been dispatched to delete media stored locally, it may take a few minutes to process!');
}
$this->line(' ');
return Command::SUCCESS;
}
}

View file

@ -4,7 +4,6 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{Media, Status};
use Carbon\Carbon;
use App\Services\MediaStorageService;
class MediaGarbageCollector extends Command
@ -40,11 +39,10 @@ class MediaGarbageCollector extends Command
*/
public function handle()
{
$limit = 20000;
$limit = 500;
$gc = Media::whereNull('status_id')
->where('created_at', '<', Carbon::now()->subHours(12)->toDateTimeString())
->orderBy('created_at','asc')
->where('created_at', '<', now()->subHours(2)->toDateTimeString())
->take($limit)
->get();

View file

@ -0,0 +1,195 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use App\Status;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use App\Services\MediaService;
use App\Services\StatusService;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class MediaS3GarbageCollector extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:s3gc {--limit=200} {--huge} {--log-errors}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete (local) media uploads that exist on S3';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$enabled = config('pixelfed.cloud_storage');
if(!$enabled) {
$this->error('Cloud storage not enabled. Exiting...');
return;
}
$deleteEnabled = config('media.delete_local_after_cloud');
if(!$deleteEnabled) {
$this->error('Delete local storage after cloud upload is not enabled');
return;
}
$limit = $this->option('limit');
$hugeMode = $this->option('huge');
$log = $this->option('log-errors');
if($limit > 2000 && !$hugeMode) {
$this->error('Limit exceeded, please use a limit under 2000 or run again with the --huge flag');
return;
}
$minId = Media::orderByDesc('id')->where('created_at', '<', now()->subHours(12))->first();
if(!$minId) {
return;
} else {
$minId = $minId->id;
}
return $hugeMode ?
$this->hugeMode($minId, $limit, $log) :
$this->regularMode($minId, $limit, $log);
}
protected function regularMode($minId, $limit, $log)
{
$gc = Media::whereRemoteMedia(false)
->whereNotNull(['status_id', 'cdn_url', 'replicated_at'])
->whereNot('version', '4')
->where('id', '<', $minId)
->inRandomOrder()
->take($limit)
->get();
$totalSize = 0;
$bar = $this->output->createProgressBar($gc->count());
$bar->start();
$cloudDisk = Storage::disk(config('filesystems.cloud'));
$localDisk = Storage::disk('local');
foreach($gc as $media) {
try {
if(
$cloudDisk->exists($media->media_path)
) {
if( $localDisk->exists($media->media_path)) {
$localDisk->delete($media->media_path);
$media->version = 4;
$media->save();
$totalSize = $totalSize + $media->size;
MediaService::del($media->status_id);
StatusService::del($media->status_id, false);
if($localDisk->exists($media->thumbnail_path)) {
$localDisk->delete($media->thumbnail_path);
}
} else {
$media->version = 4;
$media->save();
}
} else {
if($log) {
Log::channel('media')->info('[GC] Local media not properly persisted to cloud storage', ['media_id' => $media->id]);
}
}
$bar->advance();
} catch (FileNotFoundException $e) {
$bar->advance();
continue;
} catch (NotFoundHttpException $e) {
$bar->advance();
continue;
} catch (\Exception $e) {
$bar->advance();
continue;
}
}
$bar->finish();
$this->line(' ');
$this->info('Finished!');
if($totalSize) {
$this->info('Cleared ' . $totalSize . ' bytes of media from local disk!');
}
return 0;
}
protected function hugeMode($minId, $limit, $log)
{
$cloudDisk = Storage::disk(config('filesystems.cloud'));
$localDisk = Storage::disk('local');
$bar = $this->output->createProgressBar($limit);
$bar->start();
Media::whereRemoteMedia(false)
->whereNotNull(['status_id', 'cdn_url', 'replicated_at'])
->whereNot('version', '4')
->where('id', '<', $minId)
->chunk(50, function($medias) use($cloudDisk, $localDisk, $bar, $log) {
foreach($medias as $media) {
try {
if($cloudDisk->exists($media->media_path)) {
if( $localDisk->exists($media->media_path)) {
$localDisk->delete($media->media_path);
$media->version = 4;
$media->save();
MediaService::del($media->status_id);
StatusService::del($media->status_id, false);
if($localDisk->exists($media->thumbnail_path)) {
$localDisk->delete($media->thumbnail_path);
}
} else {
$media->version = 4;
$media->save();
}
} else {
if($log) {
Log::channel('media')->info('[GC] Local media not properly persisted to cloud storage', ['media_id' => $media->id]);
}
}
$bar->advance();
} catch (FileNotFoundException $e) {
$bar->advance();
continue;
} catch (NotFoundHttpException $e) {
$bar->advance();
continue;
} catch (\Exception $e) {
$bar->advance();
continue;
}
}
});
$bar->finish();
$this->line(' ');
$this->info('Finished!');
}
}

View file

@ -3,7 +3,6 @@
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Story;
use App\StoryView;
@ -51,7 +50,7 @@ class StoryGC extends Command
protected function archiveExpiredStories()
{
$stories = Story::whereActive(true)
->where('created_at', '<', now()->subHours(24))
->where('expires_at', '<', now())
->get();
foreach($stories as $story) {
@ -79,6 +78,7 @@ class StoryGC extends Command
}
StoryRotateMedia::dispatch($story)->onQueue('story');
StoryService::removeRotateQueue($id);
return;
});
}
}

View file

@ -39,7 +39,11 @@ class UserAdmin extends Command
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(ctype_digit($id) == true) {
$user = User::find($id);
} else {
$user = User::whereUsername($id)->first();
}
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;

View file

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\EmailVerification;
use App\User;
class UserRegistrationMagicLink extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:app-magic-link {--username=} {--email=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get the app magic link for users who register in-app but have not recieved the confirmation email';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$username = $this->option('username');
$email = $this->option('email');
if(!$username && !$email) {
$this->error('Please provide the username or email as arguments');
$this->line(' ');
$this->info('Example: ');
$this->info('php artisan user:app-magic-link --username=dansup');
$this->info('php artisan user:app-magic-link --email=dansup@pixelfed.com');
return;
}
$user = User::when($username, function($q, $username) {
return $q->whereUsername($username);
})
->when($email, function($q, $email) {
return $q->whereEmail($email);
})
->first();
if(!$user) {
$this->error('We cannot find any matching accounts');
return;
}
if($user->email_verified_at) {
$this->error('User already verified email address');
return;
}
if(!$user->register_source || $user->register_source !== 'app' || !$user->app_register_token) {
$this->error('User did not register via app');
return;
}
$verify = EmailVerification::whereUserId($user->id)->first();
if(!$verify) {
$this->error('Cannot find user verification codes');
return;
}
$appUrl = 'pixelfed://confirm-account/'. $user->app_register_token . '?rt=' . $verify->random_token;
$this->line(' ');
$this->info('Magic link found! Copy the following link and send to user');
$this->line(' ');
$this->line(' ');
$this->info($appUrl);
$this->line(' ');
$this->line(' ');
return Command::SUCCESS;
}
}

View file

@ -39,7 +39,11 @@ class UserShow extends Command
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(ctype_digit($id) == true) {
$user = User::find($id);
} else {
$user = User::whereUsername($id)->first();
}
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;

View file

@ -39,7 +39,11 @@ class UserSuspend extends Command
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(ctype_digit($id) == true) {
$user = User::find($id);
} else {
$user = User::whereUsername($id)->first();
}
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;

View file

@ -39,7 +39,11 @@ class UserUnsuspend extends Command
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(ctype_digit($id) == true) {
$user = User::find($id);
} else {
$user = User::whereUsername($id)->first();
}
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;

View file

@ -46,7 +46,7 @@ class VideoThumbnail extends Command
->take($limit)
->get();
foreach($videos as $video) {
Pipeline::dispatchNow($video);
Pipeline::dispatch($video);
}
}
}

View file

@ -25,13 +25,17 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('media:optimize')->hourly();
$schedule->command('media:gc')->hourly();
$schedule->command('media:optimize')->hourlyAt(40);
$schedule->command('media:gc')->hourlyAt(5);
$schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('story:gc')->everyFiveMinutes();
$schedule->command('gc:failedjobs')->dailyAt(3);
$schedule->command('gc:passwordreset')->dailyAt('09:41');
$schedule->command('gc:sessions')->twiceDaily(13, 23);
if(config('pixelfed.cloud_storage') && config('media.delete_local_after_cloud')) {
$schedule->command('media:s3gc')->hourlyAt(15);
}
}
/**

View file

@ -17,16 +17,21 @@ use App\{
EmailVerification,
Follower,
FollowRequest,
Media,
Notification,
Profile,
User,
UserFilter
UserDevice,
UserFilter,
UserSetting
};
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\Mastodon\v1\AccountTransformer;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\NotificationService;
use App\Services\UserFilterService;
use App\Services\RelationshipService;
use App\Jobs\FollowPipeline\FollowAcceptPipeline;
@ -39,7 +44,8 @@ class AccountController extends Controller
'user.block',
];
const FILTER_LIMIT = 'You cannot block or mute more than 100 accounts';
const FILTER_LIMIT_MUTE_TEXT = 'You cannot mute more than ';
const FILTER_LIMIT_BLOCK_TEXT = 'You cannot block more than ';
public function __construct()
{
@ -145,16 +151,17 @@ class AccountController extends Controller
public function mute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'type' => 'required|string|in:user',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$count = UserFilterService::muteCount($user->id);
abort_if($count >= 100, 422, self::FILTER_LIMIT);
$pid = $request->user()->profile_id;
$count = UserFilterService::muteCount($pid);
$maxLimit = intval(config('instance.user_filters.max_user_mutes'));
abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
if($count == 0) {
$filterCount = UserFilter::whereUserId($user->id)->count();
abort_if($filterCount >= 100, 422, self::FILTER_LIMIT);
$filterCount = UserFilter::whereUserId($pid)->count();
abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_MUTE_TEXT . $maxLimit . ' accounts');
}
$type = $request->input('type');
$item = $request->input('item');
@ -167,7 +174,7 @@ class AccountController extends Controller
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
if ($profile->id == $pid) {
return abort(403);
}
$class = get_class($profile);
@ -177,29 +184,30 @@ class AccountController extends Controller
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'user_id' => $pid,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'mute',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
UserFilterService::mute($pid, $filterable['id']);
$res = RelationshipService::refresh($pid, $profile->id);
return redirect()->back();
if($request->wantsJson()) {
return response()->json($res);
} else {
return redirect()->back();
}
}
public function unmute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'type' => 'required|string|in:user',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$pid = $request->user()->profile_id;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.mute';
@ -211,7 +219,7 @@ class AccountController extends Controller
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
if ($profile->id == $pid) {
return abort(403);
}
$class = get_class($profile);
@ -224,24 +232,21 @@ class AccountController extends Controller
break;
}
$filter = UserFilter::whereUserId($user->id)
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('mute')
->first();
if($filter) {
UserFilterService::unmute($pid, $filterable['id']);
$filter->delete();
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
$res = RelationshipService::refresh($pid, $profile->id);
if($request->wantsJson()) {
return response()->json([200]);
return response()->json($res);
} else {
return redirect()->back();
}
@ -250,16 +255,16 @@ class AccountController extends Controller
public function block(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'type' => 'required|string|in:user',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$count = UserFilterService::blockCount($user->id);
abort_if($count >= 100, 422, self::FILTER_LIMIT);
$pid = $request->user()->profile_id;
$count = UserFilterService::blockCount($pid);
$maxLimit = intval(config('instance.user_filters.max_user_blocks'));
abort_if($count >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
if($count == 0) {
$filterCount = UserFilter::whereUserId($user->id)->count();
abort_if($filterCount >= 100, 422, self::FILTER_LIMIT);
$filterCount = UserFilter::whereUserId($pid)->whereFilterType('block')->count();
abort_if($filterCount >= $maxLimit, 422, self::FILTER_LIMIT_BLOCK_TEXT . $maxLimit . ' accounts');
}
$type = $request->input('type');
$item = $request->input('item');
@ -271,41 +276,74 @@ class AccountController extends Controller
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id || ($profile->user && $profile->user->is_admin == true)) {
if ($profile->id == $pid || ($profile->user && $profile->user->is_admin == true)) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
Follower::whereProfileId($profile->id)->whereFollowingId($user->id)->delete();
Notification::whereProfileId($user->id)->whereActorId($profile->id)->delete();
$followed = Follower::whereProfileId($profile->id)->whereFollowingId($pid)->first();
if($followed) {
$followed->delete();
$profile->following_count = Follower::whereProfileId($profile->id)->count();
$profile->save();
$selfProfile = $request->user()->profile;
$selfProfile->followers_count = Follower::whereFollowingId($pid)->count();
$selfProfile->save();
FollowerService::remove($profile->id, $pid);
AccountService::del($pid);
AccountService::del($profile->id);
}
$following = Follower::whereProfileId($pid)->whereFollowingId($profile->id)->first();
if($following) {
$following->delete();
$profile->followers_count = Follower::whereFollowingId($profile->id)->count();
$profile->save();
$selfProfile = $request->user()->profile;
$selfProfile->following_count = Follower::whereProfileId($pid)->count();
$selfProfile->save();
FollowerService::remove($pid, $profile->pid);
AccountService::del($pid);
AccountService::del($profile->id);
}
Notification::whereProfileId($pid)
->whereActorId($profile->id)
->get()
->map(function($n) use($pid) {
NotificationService::del($pid, $n['id']);
$n->forceDelete();
});
break;
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'user_id' => $pid,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'block',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
UserFilterService::block($pid, $filterable['id']);
$res = RelationshipService::refresh($pid, $profile->id);
return redirect()->back();
if($request->wantsJson()) {
return response()->json($res);
} else {
return redirect()->back();
}
}
public function unblock(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'type' => 'required|string|in:user',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$pid = $request->user()->profile_id;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.block';
@ -316,7 +354,7 @@ class AccountController extends Controller
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
if ($profile->id == $pid) {
return abort(403);
}
$class = get_class($profile);
@ -330,7 +368,7 @@ class AccountController extends Controller
}
$filter = UserFilter::whereUserId($user->id)
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('block')
@ -338,15 +376,16 @@ class AccountController extends Controller
if($filter) {
$filter->delete();
UserFilterService::unblock($pid, $filterable['id']);
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
$res = RelationshipService::refresh($pid, $profile->id);
return redirect()->back();
if($request->wantsJson()) {
return response()->json($res);
} else {
return redirect()->back();
}
}
public function followRequests(Request $request)
@ -409,7 +448,7 @@ class AccountController extends Controller
AccountService::del($profile->id);
if($follower->domain != null && $follower->private_key === null) {
FollowAcceptPipeline::dispatch($followRequest);
FollowAcceptPipeline::dispatch($followRequest)->onQueue('follow');
} else {
FollowPipeline::dispatch($follow);
$followRequest->delete();
@ -418,7 +457,7 @@ class AccountController extends Controller
case 'reject':
if($follower->domain != null && $follower->private_key === null) {
FollowRejectPipeline::dispatch($followRequest);
FollowRejectPipeline::dispatch($followRequest)->onQueue('follow');
} else {
$followRequest->delete();
}
@ -466,6 +505,12 @@ class AccountController extends Controller
if($trustDevice == true) {
$request->session()->put('sudoTrustDevice', 1);
}
//Fix wrong scheme when using reverse proxy
if(!str_contains($next, 'https') && config('instance.force_https_urls', true)) {
$next = Str::of($next)->replace('http', 'https')->toString();
}
return redirect($next);
} else {
return redirect()
@ -513,25 +558,25 @@ class AccountController extends Controller
}
}
protected function twoFactorBackupCheck($request, $code, User $user)
{
$backupCodes = $user->{'2fa_backup_codes'};
if($backupCodes) {
$codes = json_decode($backupCodes, true);
foreach ($codes as $c) {
if(hash_equals($c, $code)) {
$codes = array_flatten(array_diff($codes, [$code]));
$user->{'2fa_backup_codes'} = json_encode($codes);
$user->save();
$request->session()->push('2fa.session.active', true);
return true;
}
}
protected function twoFactorBackupCheck($request, $code, User $user)
{
$backupCodes = $user->{'2fa_backup_codes'};
if($backupCodes) {
$codes = json_decode($backupCodes, true);
foreach ($codes as $c) {
if(hash_equals($c, $code)) {
$codes = array_flatten(array_diff($codes, [$code]));
$user->{'2fa_backup_codes'} = json_encode($codes);
$user->save();
$request->session()->push('2fa.session.active', true);
return true;
}
}
return false;
} else {
return false;
}
}
} else {
return false;
}
}
public function accountRestored(Request $request)
{

View file

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Admin;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use App\Hashtag;
use App\StatusHashtag;
use App\Http\Resources\AdminHashtag;
use App\Services\TrendingHashtagService;
trait AdminHashtagsController
{
public function hashtagsHome(Request $request)
{
return view('admin.hashtags.home');
}
public function hashtagsApi(Request $request)
{
$this->validate($request, [
'action' => 'sometimes|in:banned,nsfw',
'sort' => 'sometimes|in:id,name,cached_count,can_search,can_trend,is_banned,is_nsfw',
'dir' => 'sometimes|in:asc,desc'
]);
$action = $request->input('action');
$query = $request->input('q');
$sort = $request->input('sort');
$order = $request->input('dir');
$hashtags = Hashtag::when($query, function($q, $query) {
return $q->where('name', 'like', $query . '%');
})
->when($sort, function($q, $sort) use($order) {
return $q->orderBy($sort, $order);
}, function($q) {
return $q->orderByDesc('id');
})
->when($action, function($q, $action) {
if($action === 'banned') {
return $q->whereIsBanned(true);
} else if ($action === 'nsfw') {
return $q->whereIsNsfw(true);
}
})
->cursorPaginate(10)
->withQueryString();
return AdminHashtag::collection($hashtags);
}
public function hashtagsStats(Request $request)
{
$stats = [
'total_unique' => Hashtag::count(),
'total_posts' => StatusHashtag::count(),
'added_14_days' => Hashtag::where('created_at', '>', now()->subDays(14))->count(),
'total_banned' => Hashtag::whereIsBanned(true)->count(),
'total_nsfw' => Hashtag::whereIsNsfw(true)->count()
];
return response()->json($stats);
}
public function hashtagsGet(Request $request)
{
return new AdminHashtag(Hashtag::findOrFail($request->input('id')));
}
public function hashtagsUpdate(Request $request)
{
$this->validate($request, [
'id' => 'required',
'name' => 'required',
'slug' => 'required',
'can_search' => 'required:boolean',
'can_trend' => 'required:boolean',
'is_nsfw' => 'required:boolean',
'is_banned' => 'required:boolean'
]);
$hashtag = Hashtag::whereSlug($request->input('slug'))->findOrFail($request->input('id'));
$canTrendPrev = $hashtag->can_trend == null ? true : $hashtag->can_trend;
$hashtag->is_banned = $request->input('is_banned');
$hashtag->is_nsfw = $request->input('is_nsfw');
$hashtag->can_search = $hashtag->is_banned ? false : $request->input('can_search');
$hashtag->can_trend = $hashtag->is_banned ? false : $request->input('can_trend');
$hashtag->save();
TrendingHashtagService::refresh();
return new AdminHashtag($hashtag);
}
public function hashtagsClearTrendingCache(Request $request)
{
TrendingHashtagService::refresh();
return [];
}
}

View file

@ -8,66 +8,13 @@ use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use App\Services\InstanceService;
use App\Http\Resources\AdminInstance;
trait AdminInstanceController
{
public function instances(Request $request)
{
$this->validate($request, [
'filter' => [
'nullable',
'string',
'min:1',
'max:20',
Rule::in([
'cw',
'unlisted',
'banned',
// 'popular',
'new',
'all'
])
],
]);
if($request->has('q') && $request->filled('q')) {
$instances = Instance::where('domain', 'like', '%' . $request->input('q') . '%')->simplePaginate(10);
} else if($request->has('filter') && $request->filled('filter')) {
switch ($request->filter) {
case 'cw':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereAutoCw(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'unlisted':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereUnlisted(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'banned':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereBanned(true)->orderByDesc('id')->simplePaginate(10);
break;
case 'new':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->latest()->simplePaginate(10);
break;
// case 'popular':
// $popular = Profile::selectRaw('*, count(domain) as count')
// ->whereNotNull('domain')
// ->groupBy('domain')
// ->orderByDesc('count')
// ->take(10)
// ->get()
// ->pluck('domain')
// ->toArray();
// $instances = Instance::whereIn('domain', $popular)->simplePaginate(10);
// break;
default:
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
break;
}
} else {
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
}
return view('admin.instances.home', compact('instances'));
return view('admin.instances.home');
}
public function instanceScan(Request $request)
@ -133,4 +80,223 @@ trait AdminInstanceController
return response()->json([]);
}
public function getInstancesStatsApi(Request $request)
{
return InstanceService::stats();
}
public function getInstancesQueryApi(Request $request)
{
$this->validate($request, [
'q' => 'required'
]);
$q = $request->input('q');
return AdminInstance::collection(
Instance::where('domain', 'like', '%' . $q . '%')
->orderByDesc('user_count')
->cursorPaginate(10)
->withQueryString()
);
}
public function getInstancesApi(Request $request)
{
$this->validate($request, [
'filter' => [
'nullable',
'string',
'min:1',
'max:20',
Rule::in([
'cw',
'unlisted',
'banned',
'popular_users',
'popular_statuses',
'new',
'all'
])
],
'sort' => [
'sometimes',
'string',
Rule::in([
'id',
'domain',
'software',
'user_count',
'status_count',
'banned',
'auto_cw',
'unlisted'
])
],
'dir' => 'sometimes|in:desc,asc'
]);
$filter = $request->input('filter');
$query = $request->input('q');
$sortCol = $request->input('sort');
$sortDir = $request->input('dir');
return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) {
return $q->where('domain', 'like', '%' . $query . '%');
})
->when($filter, function($q, $f) use($filter) {
if($filter == 'cw') { return $q->whereAutoCw(true); }
if($filter == 'unlisted') { return $q->whereUnlisted(true); }
if($filter == 'banned') { return $q->whereBanned(true); }
if($filter == 'new') { return $q->orderByDesc('id'); }
if($filter == 'popular_users') { return $q->orderByDesc('user_count'); }
if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); }
return $q->orderByDesc('id');
})
->when($sortCol, function($q, $s) use($sortCol, $sortDir, $filter) {
if(!in_array($filter, ['popular_users', 'popular_statuses'])) {
return $q->whereNotNull($sortCol)->orderBy($sortCol, $sortDir);
}
}, function($q) use($filter) {
if(!$filter || !in_array($filter, ['popular_users', 'popular_statuses'])) {
return $q->orderByDesc('id');
}
})
->cursorPaginate(10)
->withQueryString());
}
public function postInstanceUpdateApi(Request $request)
{
$this->validate($request, [
'id' => 'required',
'banned' => 'boolean',
'auto_cw' => 'boolean',
'unlisted' => 'boolean',
'notes' => 'nullable|string|max:500',
]);
$id = $request->input('id');
$instance = Instance::findOrFail($id);
$instance->update($request->only([
'banned',
'auto_cw',
'unlisted',
'notes'
]));
InstanceService::refresh();
return new AdminInstance($instance);
}
public function postInstanceCreateNewApi(Request $request)
{
$this->validate($request, [
'domain' => 'required|string',
'banned' => 'boolean',
'auto_cw' => 'boolean',
'unlisted' => 'boolean',
'notes' => 'nullable|string|max:500'
]);
$domain = $request->input('domain');
abort_if(!strpos($domain, '.'), 400, 'Invalid domain');
abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain');
$instance = new Instance;
$instance->domain = $request->input('domain');
$instance->banned = $request->input('banned');
$instance->auto_cw = $request->input('auto_cw');
$instance->unlisted = $request->input('unlisted');
$instance->manually_added = true;
$instance->notes = $request->input('notes');
$instance->save();
InstanceService::refresh();
return new AdminInstance($instance);
}
public function postInstanceRefreshStatsApi(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$instance = Instance::findOrFail($request->input('id'));
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->status_count = Profile::whereDomain($instance->domain)->leftJoin('statuses', 'profiles.id', '=', 'statuses.profile_id')->count();
$instance->save();
return new AdminInstance($instance);
}
public function postInstanceDeleteApi(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$instance = Instance::findOrFail($request->input('id'));
$instance->delete();
InstanceService::refresh();
return 200;
}
public function downloadBackup(Request $request)
{
return response()->streamDownload(function () {
$json = [
'version' => 1,
'auto_cw' => Instance::whereAutoCw(true)->pluck('domain')->toArray(),
'unlisted' => Instance::whereUnlisted(true)->pluck('domain')->toArray(),
'banned' => Instance::whereBanned(true)->pluck('domain')->toArray(),
'created_at' => now()->format('c'),
];
$chk = hash('sha256', json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
$json['_sha256'] = $chk;
echo json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}, 'pixelfed-instances-mod.json');
}
public function importBackup(Request $request)
{
$this->validate($request, [
'banned' => 'sometimes|array',
'auto_cw' => 'sometimes|array',
'unlisted' => 'sometimes|array',
]);
$banned = $request->input('banned');
$auto_cw = $request->input('auto_cw');
$unlisted = $request->input('unlisted');
foreach($banned as $i) {
Instance::updateOrCreate(
['domain' => $i],
['banned' => true]
);
}
foreach($auto_cw as $i) {
Instance::updateOrCreate(
['domain' => $i],
['auto_cw' => true]
);
}
foreach($unlisted as $i) {
Instance::updateOrCreate(
['domain' => $i],
['unlisted' => true]
);
}
InstanceService::refresh();
return [200];
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use App\Services\AccountService;
use App\Services\StatusService;
@ -24,6 +25,13 @@ use Illuminate\Validation\Rule;
use App\Services\StoryService;
use App\Services\ModLogService;
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
use App\Jobs\StatusPipeline\StatusDelete;
use App\Http\Resources\AdminReport;
use App\Http\Resources\AdminSpamReport;
use App\Services\PublicTimelineService;
use App\Services\NetworkTimelineService;
trait AdminReportController
{
@ -74,6 +82,9 @@ trait AdminReportController
public function showReport(Request $request, $id)
{
$report = Report::with('status')->findOrFail($id);
if($request->has('ref') && $request->input('ref') == 'email') {
return redirect('/i/admin/reports?tab=report&id=' . $report->id);
}
return view('admin.reports.show', compact('report'));
}
@ -200,6 +211,9 @@ trait AdminReportController
{
$appeal = AccountInterstitial::whereType('post.autospam')
->findOrFail($id);
if($request->has('ref') && $request->input('ref') == 'email') {
return redirect('/i/admin/reports?tab=autospam&id=' . $appeal->id);
}
$meta = json_decode($appeal->meta);
return view('admin.reports.show_spam', compact('appeal', 'meta'));
}
@ -601,4 +615,588 @@ trait AdminReportController
Redis::del('email:manual-ignored');
return [200];
}
public function reportsStats(Request $request)
{
$stats = [
'total' => Report::count(),
'open' => Report::whereNull('admin_seen')->count(),
'closed' => Report::whereNotNull('admin_seen')->count(),
'autospam' => AccountInterstitial::whereType('post.autospam')->count(),
'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(),
'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(),
'email_verification_requests' => Redis::scard('email:manual')
];
return $stats;
}
public function reportsApiAll(Request $request)
{
$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
$reports = AdminReport::collection(
Report::orderBy('id','desc')
->when($filter, function($q, $filter) {
return $filter == 'open' ?
$q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen');
})
->groupBy(['object_id', 'object_type'])
->cursorPaginate(6)
->withQueryString()
);
return $reports;
}
public function reportsApiGet(Request $request, $id)
{
$report = Report::findOrFail($id);
return new AdminReport($report);
}
public function reportsApiHandle(Request $request)
{
$this->validate($request, [
'object_id' => 'required',
'object_type' => 'required',
'id' => 'required',
'action' => 'required|in:ignore,nsfw,unlist,private,delete',
'action_type' => 'required|in:post,profile'
]);
$report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id'));
if($request->input('action_type') === 'profile') {
return $this->reportsHandleProfileAction($report, $request->input('action'));
} else if($request->input('action_type') === 'post') {
return $this->reportsHandleStatusAction($report, $request->input('action'));
}
return $report;
}
protected function reportsHandleProfileAction($report, $action)
{
switch($action) {
case 'ignore':
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'nsfw':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->cw = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'nsfw' => true,
'admin_seen' => now()
]);
return [200];
break;
case 'unlist':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->unlisted = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'unlisted',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'private':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->unlisted = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
$status->scope = 'private';
$status->visibility = 'private';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'private',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'delete':
if(config('pixelfed.account_deletion') == false) {
abort(404);
}
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.');
$ts = now()->addMonth();
if($profile->user_id) {
$user = $profile->user;
abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
$user->status = 'delete';
$user->delete_after = $ts;
$user->save();
}
$profile->status = 'delete';
$profile->delete_after = $ts;
$profile->save();
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
if($profile->user_id) {
DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
$user->email = $user->id;
$user->password = '';
$user->status = 'delete';
$user->save();
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteAccountPipeline::dispatch($user)->onQueue('high');
} else {
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
}
return [200];
break;
}
}
protected function reportsHandleStatusAction($report, $action)
{
switch($action) {
case 'ignore':
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'nsfw':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'nsfw' => true,
'admin_seen' => now()
]);
return [200];
break;
case 'private':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
$status->scope = 'private';
$status->visibility = 'private';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'private',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'unlist':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
if($status->scope === 'public') {
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'unlist',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'delete':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = $status->profile;
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.');
StatusService::del($status->id);
if($profile->user_id != null && $profile->domain == null) {
PublicTimelineService::del($status->id);
StatusDelete::dispatch($status)->onQueue('high');
} else {
NetworkTimelineService::del($status->id);
DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high');
}
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
}
}
public function reportsApiSpamAll(Request $request)
{
$tab = $request->input('tab', 'home');
$appeals = AdminSpamReport::collection(
AccountInterstitial::orderBy('id', 'desc')
->whereType('post.autospam')
->whereNull('appeal_handled_at')
->cursorPaginate(6)
->withQueryString()
);
return $appeals;
}
public function reportsApiSpamHandle(Request $request)
{
$this->validate($request, [
'id' => 'required',
'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile',
]);
$action = $request->input('action');
abort_if(
$action === 'delete-profile' &&
!config('pixelfed.account_deletion'),
404,
"Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config."
);
$report = AccountInterstitial::with('user')
->whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($request->input('id'));
$this->reportsHandleSpamAction($report, $action);
Cache::forget('admin-dash:reports:spam-count');
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id);
PublicTimelineService::warmCache(true, 400);
return [$action, $report];
}
public function reportsHandleSpamAction($appeal, $action)
{
$meta = json_decode($appeal->meta);
if($action == 'mark-read') {
$appeal->is_spam = true;
$appeal->appeal_handled_at = now();
$appeal->save();
}
if($action == 'mark-not-spam') {
$status = $appeal->status;
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
$appeal->is_spam = false;
$appeal->appeal_handled_at = now();
$appeal->save();
StatusService::del($status->id);
}
if($action == 'mark-all-read') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->update([
'appeal_handled_at' => now(),
'is_spam' => true
]);
}
if($action == 'mark-all-not-spam') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereUserId($appeal->user_id)
->get()
->each(function($report) use($meta) {
$report->is_spam = false;
$report->appeal_handled_at = now();
$report->save();
$status = Status::find($report->item_id);
if($status) {
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
StatusService::del($status->id);
}
});
}
if($action == 'delete-profile') {
$user = User::findOrFail($appeal->user_id);
$profile = $user->profile;
if($user->is_admin == true) {
$mid = request()->user()->id;
abort_if($user->id < $mid, 403, 'You cannot delete an admin account.');
}
$ts = now()->addMonth();
$user->status = 'delete';
$profile->status = 'delete';
$user->delete_after = $ts;
$profile->delete_after = $ts;
$user->save();
$profile->save();
$appeal->appeal_handled_at = now();
$appeal->save();
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user(request()->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
Cache::forget('profiles:private');
DeleteAccountPipeline::dispatch($user);
}
}
public function reportsApiSpamGet(Request $request, $id)
{
$report = AccountInterstitial::findOrFail($id);
return new AdminSpamReport($report);
}
}

View file

@ -22,7 +22,7 @@ trait AdminSettingsController
$cloud_ready = !empty(config('filesystems.disks.' . $cloud_disk . '.key')) && !empty(config('filesystems.disks.' . $cloud_disk . '.secret'));
$types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
$rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : null;
$jpeg = in_array('image/jpg', $types) ? true : in_array('image/jpeg', $types);
$jpeg = in_array('image/jpg', $types) || in_array('image/jpeg', $types);
$png = in_array('image/png', $types);
$gif = in_array('image/gif', $types);
$mp4 = in_array('video/mp4', $types);
@ -140,7 +140,9 @@ trait AdminSettingsController
'show_custom_css' => 'uikit.show_custom.css',
'show_custom_js' => 'uikit.show_custom.js',
'cloud_storage' => 'pixelfed.cloud_storage',
'account_autofollow' => 'account.autofollow'
'account_autofollow' => 'account.autofollow',
'show_directory' => 'landing.show_directory',
'show_explore_feed' => 'landing.show_explore_feed',
];
foreach ($bools as $key => $value) {
@ -241,16 +243,20 @@ trait AdminSettingsController
];
switch (config('database.default')) {
case 'pgsql':
$exp = DB::raw('select version();');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'Postgres',
'version' => explode(' ', DB::select(DB::raw('select version();'))[0]->version)[1]
'version' => explode(' ', DB::select($expQuery)[0]->version)[1]
];
break;
case 'mysql':
$exp = DB::raw('select version()');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'MySQL',
'version' => DB::select( DB::raw("select version()") )[0]->{'version()'}
'version' => DB::select($expQuery)[0]->{'version()'}
];
break;

View file

@ -12,6 +12,7 @@ use App\{
Profile,
Report,
Status,
StatusHashtag,
Story,
User
};
@ -22,6 +23,7 @@ use Illuminate\Support\Facades\Redis;
use App\Http\Controllers\Admin\{
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,
AdminInstanceController,
AdminReportController,
// AdminGroupsController,
@ -40,12 +42,13 @@ use App\Models\CustomEmoji;
class AdminController extends Controller
{
use AdminReportController,
use AdminReportController,
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,
// AdminGroupsController,
AdminMediaController,
AdminSettingsController,
AdminMediaController,
AdminSettingsController,
AdminInstanceController,
// AdminStorageController,
AdminUserController;
@ -201,12 +204,6 @@ class AdminController extends Controller
return view('admin.apps.home', compact('apps'));
}
public function hashtagsHome(Request $request)
{
$hashtags = Hashtag::orderByDesc('id')->paginate(10);
return view('admin.hashtags.home', compact('hashtags'));
}
public function messagesHome(Request $request)
{
$messages = Contact::orderByDesc('id')->paginate(10);
@ -267,6 +264,10 @@ class AdminController extends Controller
]);
$changed = false;
$changedFields = [];
$slug = str_slug($request->input('title'));
if(Newsroom::whereSlug($slug)->exists()) {
$slug = $slug . '-' . str_random(4);
}
$news = Newsroom::findOrFail($id);
$fields = [
'title' => 'string',
@ -284,7 +285,7 @@ class AdminController extends Controller
case 'string':
if($request->{$field} != $news->{$field}) {
if($field == 'title') {
$news->slug = str_slug($request->{$field});
$news->slug = $slug;
}
$news->{$field} = $request->{$field};
$changed = true;
@ -330,6 +331,10 @@ class AdminController extends Controller
]);
$changed = false;
$changedFields = [];
$slug = str_slug($request->input('title'));
if(Newsroom::whereSlug($slug)->exists()) {
$slug = $slug . '-' . str_random(4);
}
$news = new Newsroom();
$fields = [
'title' => 'string',
@ -347,7 +352,7 @@ class AdminController extends Controller
case 'string':
if($request->{$field} != $news->{$field}) {
if($field == 'title') {
$news->slug = str_slug($request->{$field});
$news->slug = $slug;
}
$news->{$field} = $request->{$field};
$changed = true;
@ -459,7 +464,9 @@ class AdminController extends Controller
->where('shortcode', 'like', '%' . $request->input('q') . '%')
->orWhere('domain', 'like', '%' . $request->input('q') . '%');
if(!$request->has('dups')) {
$q = $q->groupBy('shortcode');
if(!$pg) {
$q = $q->groupBy('shortcode');
}
}
return $q;
}
@ -518,7 +525,7 @@ class AdminController extends Controller
->whereShortcode($request->input('shortcode'));
})
],
'emoji' => 'required|file|mimetypes:jpg,png|max:' . (config('federation.custom_emoji.max_size') / 1000)
'emoji' => 'required|file|mimes:jpg,png|max:' . (config('federation.custom_emoji.max_size') / 1000)
]);
$emoji = new CustomEmoji;
@ -527,7 +534,7 @@ class AdminController extends Controller
$emoji->save();
$fileName = $emoji->id . '.' . $request->emoji->extension();
$request->emoji->storeAs('public/emoji', $fileName);
$request->emoji->storePubliclyAs('public/emoji', $fileName);
$emoji->media_path = 'emoji/' . $fileName;
$emoji->save();
Cache::forget('pf:custom_emoji');

View file

@ -0,0 +1,243 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\AdminInvite;
use App\Profile;
use App\User;
use Purify;
use App\Util\Lexer\RestrictedNames;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Auth\Events\Registered;
use App\Services\EmailService;
use App\Http\Controllers\Auth\RegisterController;
class AdminInviteController extends Controller
{
public function __construct()
{
abort_if(!config('instance.admin_invites.enabled'), 404);
}
public function index(Request $request, $code)
{
if($request->user()) {
return redirect('/');
}
return view('invite.admin_invite', compact('code'));
}
public function apiVerifyCheck(Request $request)
{
$this->validate($request, [
'token' => 'required',
]);
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
abort_if(!$invite, 404);
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
$res = [
'message' => $invite->message,
'max_uses' => $invite->max_uses,
'sev' => $invite->skip_email_verification
];
return response()->json($res);
}
public function apiUsernameCheck(Request $request)
{
$this->validate($request, [
'token' => 'required',
'username' => 'required'
]);
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
abort_if(!$invite, 404);
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
$usernameRules = [
'required',
'min:2',
'max:15',
'unique:users',
function ($attribute, $value, $fail) {
$dash = substr_count($value, '-');
$underscore = substr_count($value, '_');
$period = substr_count($value, '.');
if(ends_with($value, ['.php', '.js', '.css'])) {
return $fail('Username is invalid.');
}
if(($dash + $underscore + $period) > 1) {
return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
}
if (!ctype_alnum($value[0])) {
return $fail('Username is invalid. Must start with a letter or number.');
}
if (!ctype_alnum($value[strlen($value) - 1])) {
return $fail('Username is invalid. Must end with a letter or number.');
}
$val = str_replace(['_', '.', '-'], '', $value);
if(!ctype_alnum($val)) {
return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
}
$restricted = RestrictedNames::get();
if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
return $fail('Username cannot be used.');
}
},
];
$rules = ['username' => $usernameRules];
$validator = Validator::make($request->all(), $rules);
if($validator->fails()) {
return response()->json($validator->errors(), 400);
}
return response()->json([]);
}
public function apiEmailCheck(Request $request)
{
$this->validate($request, [
'token' => 'required',
'email' => 'required'
]);
$invite = AdminInvite::whereInviteCode($request->input('token'))->first();
abort_if(!$invite, 404);
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite has expired.');
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
$emailRules = [
'required',
'string',
'email',
'max:255',
'unique:users',
function ($attribute, $value, $fail) {
$banned = EmailService::isBanned($value);
if($banned) {
return $fail('Email is invalid.');
}
},
];
$rules = ['email' => $emailRules];
$validator = Validator::make($request->all(), $rules);
if($validator->fails()) {
return response()->json($validator->errors(), 400);
}
return response()->json([]);
}
public function apiRegister(Request $request)
{
$this->validate($request, [
'token' => 'required',
'username' => [
'required',
'min:2',
'max:15',
'unique:users',
function ($attribute, $value, $fail) {
$dash = substr_count($value, '-');
$underscore = substr_count($value, '_');
$period = substr_count($value, '.');
if(ends_with($value, ['.php', '.js', '.css'])) {
return $fail('Username is invalid.');
}
if(($dash + $underscore + $period) > 1) {
return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
}
if (!ctype_alnum($value[0])) {
return $fail('Username is invalid. Must start with a letter or number.');
}
if (!ctype_alnum($value[strlen($value) - 1])) {
return $fail('Username is invalid. Must end with a letter or number.');
}
$val = str_replace(['_', '.', '-'], '', $value);
if(!ctype_alnum($val)) {
return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
}
$restricted = RestrictedNames::get();
if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
return $fail('Username cannot be used.');
}
},
],
'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
'email' => [
'required',
'string',
'email',
'max:255',
'unique:users',
function ($attribute, $value, $fail) {
$banned = EmailService::isBanned($value);
if($banned) {
return $fail('Email is invalid.');
}
},
],
'password' => 'required',
'password_confirm' => 'required'
]);
$invite = AdminInvite::whereInviteCode($request->input('token'))->firstOrFail();
abort_if($invite->expires_at && $invite->expires_at->lt(now()), 400, 'Invite expired');
abort_if($invite->max_uses && $invite->uses >= $invite->max_uses, 400, 'Maximum invites reached.');
$invite->uses = $invite->uses + 1;
event(new Registered($user = User::create([
'name' => Purify::clean($request->input('name')) ?? $request->input('username'),
'username' => $request->input('username'),
'email' => $request->input('email'),
'password' => Hash::make($request->input('password')),
])));
sleep(5);
$invite->used_by = array_merge($invite->used_by ?? [], [[
'user_id' => $user->id,
'username' => $user->username
]]);
$invite->save();
if($invite->skip_email_verification) {
$user->email_verified_at = now();
$user->save();
}
if(Auth::attempt([
'email' => $request->input('email'),
'password' => $request->input('password')
])) {
$request->session()->regenerate();
return redirect()->intended('/');
} else {
return response()->json([], 400);
}
}
}

View file

@ -5,114 +5,581 @@ namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Jobs\StatusPipeline\StatusDelete;
use Auth, Cache;
use Auth, Cache, DB;
use Carbon\Carbon;
use App\{
AccountInterstitial,
Instance,
Like,
Media,
Profile,
Status
Report,
Status,
User
};
use App\Services\AccountService;
use App\Services\AdminStatsService;
use App\Services\ConfigCacheService;
use App\Services\InstanceService;
use App\Services\ModLogService;
use App\Services\StatusService;
use App\Services\NetworkTimelineService;
use App\Services\NotificationService;
use App\Http\Resources\AdminInstance;
use App\Http\Resources\AdminUser;
class AdminApiController extends Controller
{
public function __construct()
public function supported(Request $request)
{
$this->middleware(['auth', 'admin']);
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
return response()->json(['supported' => true]);
}
public function activity(Request $request)
public function getStats(Request $request)
{
$activity = [];
$limit = request()->input('limit', 20);
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$activity['captions'] = Status::select(
'id',
'caption',
'rendered',
'uri',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'created_at'
)->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->orderByDesc('created_at')
->paginate($limit);
$activity['comments'] = Status::select(
'id',
'caption',
'rendered',
'uri',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'created_at'
)->whereNotNull('in_reply_to_id')
->whereNull('reblog_of_id')
->orderByDesc('created_at')
->paginate($limit);
return response()->json($activity, 200, [], JSON_PRETTY_PRINT);
$res = AdminStatsService::summary();
$res['autospam_count'] = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->count();
return $res;
}
public function moderateStatus(Request $request)
public function autospam(Request $request)
{
abort(400, 'Unpublished API');
return;
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$appeals = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->latest()
->simplePaginate(6)
->map(function($report) {
$r = [
'id' => $report->id,
'type' => $report->type,
'item_id' => $report->item_id,
'item_type' => $report->item_type,
'created_at' => $report->created_at
];
if($report->item_type === 'App\\Status') {
$status = StatusService::get($report->item_id, false);
if(!$status) {
return;
}
$r['status'] = $status;
if($status['in_reply_to_id']) {
$r['parent'] = StatusService::get($status['in_reply_to_id'], false);
}
}
return $r;
});
return $appeals;
}
public function autospamHandle(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'type' => 'required|string|in:status,profile',
'id' => 'required|integer|min:1',
'action' => 'required|string|in:cw,unlink,unlist,suspend,delete'
'action' => 'required|in:dismiss,approve,dismiss-all,approve-all',
'id' => 'required'
]);
$type = $request->input('type');
$id = $request->input('id');
$action = $request->input('action');
$id = $request->input('id');
$appeal = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($id);
$now = now();
$res = ['status' => 'success'];
$meta = json_decode($appeal->meta);
if ($type == 'status') {
$status = Status::findOrFail($id);
switch ($action) {
case 'cw':
$status->is_nsfw = true;
$status->save();
break;
case 'unlink':
$status->rendered = $status->caption;
$status->save();
break;
case 'unlist':
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
break;
default:
break;
}
} else if ($type == 'profile') {
$profile = Profile::findOrFail($id);
switch ($action) {
if($action == 'dismiss') {
$appeal->is_spam = true;
$appeal->appeal_handled_at = $now;
$appeal->save();
case 'delete':
StatusDelete::dispatch($status);
break;
default:
break;
}
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'dismiss-all') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->update(['appeal_handled_at' => $now, 'is_spam' => true]);
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'approve') {
$status = $appeal->status;
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
$appeal->is_spam = false;
$appeal->appeal_handled_at = now();
$appeal->save();
StatusService::del($status->id);
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'approve-all') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->get()
->each(function($report) use($meta) {
$report->is_spam = false;
$report->appeal_handled_at = now();
$report->save();
$status = Status::find($report->item_id);
if($status) {
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
StatusService::del($status->id, true);
}
});
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
return $res;
}
}
public function modReports(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$reports = Report::whereNull('admin_seen')
->orderBy('created_at','desc')
->paginate(6)
->map(function($report) {
$r = [
'id' => $report->id,
'type' => $report->type,
'message' => $report->message,
'object_id' => $report->object_id,
'object_type' => $report->object_type,
'created_at' => $report->created_at
];
if($report->profile_id) {
$r['reported_by_account'] = AccountService::get($report->profile_id, true);
}
if($report->object_type === 'App\\Status') {
$status = StatusService::get($report->object_id, false);
if(!$status) {
return;
}
$r['status'] = $status;
if($status['in_reply_to_id']) {
$r['parent'] = StatusService::get($status['in_reply_to_id'], false);
}
}
if($report->object_type === 'App\\Profile') {
$r['account'] = AccountService::get($report->object_id, false);
}
return $r;
})
->filter()
->values();
return $reports;
}
public function modReportHandle(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'action' => 'required|string',
'id' => 'required'
]);
$action = $request->input('action');
$id = $request->input('id');
$actions = [
'ignore',
'cw',
'unlist'
];
if (!in_array($action, $actions)) {
return abort(403);
}
$report = Report::findOrFail($id);
$item = $report->reported();
$report->admin_seen = now();
switch ($action) {
case 'ignore':
$report->not_interested = true;
break;
case 'cw':
Cache::forget('status:thumb:'.$item->id);
$item->is_nsfw = true;
$item->save();
$report->nsfw = true;
StatusService::del($item->id, true);
break;
case 'unlist':
$item->visibility = 'unlisted';
$item->save();
StatusService::del($item->id, true);
break;
default:
$report->admin_seen = null;
break;
}
$report->save();
Cache::forget('admin-dash:reports:list-cache');
Cache::forget('admin:dashboard:home:data:v0:15min');
return ['success' => true];
}
public function getConfiguration(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
abort_unless(config('instance.enable_cc'), 400);
return collect([
[
'name' => 'ActivityPub Federation',
'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.',
'key' => 'federation.activitypub.enabled'
],
[
'name' => 'Open Registration',
'description' => 'Allow new account registrations.',
'key' => 'pixelfed.open_registration'
],
[
'name' => 'Stories',
'description' => 'Enable the ephemeral Stories feature.',
'key' => 'instance.stories.enabled'
],
[
'name' => 'Require Email Verification',
'description' => 'Require new accounts to verify their email address.',
'key' => 'pixelfed.enforce_email_verification'
],
[
'name' => 'AutoSpam Detection',
'description' => 'Detect and remove spam from public timelines.',
'key' => 'pixelfed.bouncer.enabled'
],
])
->map(function($s) {
$s['state'] = (bool) config_cache($s['key']);
return $s;
});
}
public function updateConfiguration(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
abort_unless(config('instance.enable_cc'), 400);
$this->validate($request, [
'key' => 'required',
'value' => 'required'
]);
$allowedKeys = [
'federation.activitypub.enabled',
'pixelfed.open_registration',
'instance.stories.enabled',
'pixelfed.enforce_email_verification',
'pixelfed.bouncer.enabled',
];
$key = $request->input('key');
$value = (bool) filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
abort_if(!in_array($key, $allowedKeys), 400, 'Invalid cache key.');
ConfigCacheService::put($key, $value);
return collect([
[
'name' => 'ActivityPub Federation',
'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.',
'key' => 'federation.activitypub.enabled'
],
[
'name' => 'Open Registration',
'description' => 'Allow new account registrations.',
'key' => 'pixelfed.open_registration'
],
[
'name' => 'Stories',
'description' => 'Enable the ephemeral Stories feature.',
'key' => 'instance.stories.enabled'
],
[
'name' => 'Require Email Verification',
'description' => 'Require new accounts to verify their email address.',
'key' => 'pixelfed.enforce_email_verification'
],
[
'name' => 'AutoSpam Detection',
'description' => 'Detect and remove spam from public timelines.',
'key' => 'pixelfed.bouncer.enabled'
],
])
->map(function($s) {
$s['state'] = (bool) config_cache($s['key']);
return $s;
});
}
public function getUsers(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$q = $request->input('q');
$sort = $request->input('sort', 'desc') === 'asc' ? 'asc' : 'desc';
$res = User::whereNull('status')
->when($q, function($query, $q) {
return $query->where('username', 'like', '%' . $q . '%');
})
->orderBy('id', $sort)
->cursorPaginate(10);
return AdminUser::collection($res);
}
public function getUser(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$id = $request->input('user_id');
$user = User::findOrFail($id);
$profile = $user->profile;
$account = AccountService::get($user->profile_id, true);
return (new AdminUser($user))->additional(['meta' => [
'account' => $account,
'moderation' => [
'unlisted' => (bool) $profile->unlisted,
'cw' => (bool) $profile->cw,
'no_autolink' => (bool) $profile->no_autolink
]
]]);
}
public function userAdminAction(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'id' => 'required',
'action' => 'required|in:unlisted,cw,no_autolink,refresh_stats,verify_email',
'value' => 'sometimes'
]);
$id = $request->input('id');
$user = User::findOrFail($id);
$profile = Profile::findOrFail($user->profile_id);
$action = $request->input('action');
abort_if($user->is_admin == true && $action !== 'refresh_stats', 400, 'Cannot moderate admin accounts');
if($action === 'refresh_stats') {
$profile->following_count = DB::table('followers')->whereProfileId($user->profile_id)->count();
$profile->followers_count = DB::table('followers')->whereFollowingId($user->profile_id)->count();
$statusCount = Status::whereProfileId($user->profile_id)
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereIn('scope', ['public', 'unlisted', 'private'])
->count();
$profile->status_count = $statusCount;
$profile->save();
} else if($action === 'verify_email') {
$user->email_verified_at = now();
$user->save();
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => 'Manually verified email address',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
} else {
$profile->{$action} = filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
$profile->save();
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => $action,
'message' => 'Success!'
])
->accessLevel('admin')
->save();
}
AccountService::del($user->profile_id);
$account = AccountService::get($user->profile_id, true);
return (new AdminUser($user))->additional(['meta' => [
'account' => $account,
'moderation' => [
'unlisted' => (bool) $profile->unlisted,
'cw' => (bool) $profile->cw,
'no_autolink' => (bool) $profile->no_autolink
]
]]);
}
public function instances(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'q' => 'sometimes',
'sort' => 'sometimes|in:asc,desc',
'sort_by' => 'sometimes|in:id,status_count,user_count,domain',
'filter' => 'sometimes|in:all,unlisted,auto_cw,banned',
]);
$q = $request->input('q');
$sort = $request->input('sort', 'desc') === 'asc' ? 'asc' : 'desc';
$sortBy = $request->input('sort_by', 'id');
$filter = $request->input('filter');
$res = Instance::when($q, function($query, $q) {
return $query->where('domain', 'like', '%' . $q . '%');
})
->when($filter, function($query, $filter) {
if($filter === 'all') {
return $query;
} else {
return $query->where($filter, true);
}
})
->when($sortBy, function($query, $sortBy) use($sort) {
return $query->orderBy($sortBy, $sort);
}, function($query) {
return $query->orderBy('id', 'desc');
})
->cursorPaginate(10)
->withQueryString();
return AdminInstance::collection($res);
}
public function getInstance(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$id = $request->input('id');
$res = Instance::findOrFail($id);
return new AdminInstance($res);
}
public function moderateInstance(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'id' => 'required',
'key' => 'required|in:unlisted,auto_cw,banned',
'value' => 'required'
]);
$id = $request->input('id');
$key = $request->input('key');
$value = (bool) filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
$res = Instance::findOrFail($id);
$res->{$key} = $value;
$res->save();
InstanceService::refresh();
NetworkTimelineService::warmCache(true);
return new AdminInstance($res);
}
public function refreshInstanceStats(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'id' => 'required',
]);
$id = $request->input('id');
$instance = Instance::findOrFail($id);
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->status_count = Profile::whereDomain($instance->domain)->leftJoin('statuses', 'profiles.id', '=', 'statuses.profile_id')->count();
$instance->save();
return new AdminInstance($instance);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -96,89 +96,6 @@ class BaseApiController extends Controller
return response()->json($res);
}
public function accounts(Request $request, $id)
{
abort_if(!$request->user(), 403);
$profile = Profile::findOrFail($id);
$resource = new Fractal\Resource\Item($profile, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function accountFollowers(Request $request, $id)
{
abort_if(!$request->user(), 403);
$profile = Profile::findOrFail($id);
$followers = $profile->followers;
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function accountFollowing(Request $request, $id)
{
abort_if(!$request->user(), 403);
$profile = Profile::findOrFail($id);
$following = $profile->following;
$resource = new Fractal\Resource\Collection($following, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function accountStatuses(Request $request, $id)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'only_media' => 'nullable',
'pinned' => 'nullable',
'exclude_replies' => 'nullable',
'max_id' => 'nullable|integer|min:1',
'since_id' => 'nullable|integer|min:1',
'min_id' => 'nullable|integer|min:1',
'limit' => 'nullable|integer|min:1|max:24'
]);
$limit = $request->limit ?? 20;
$max_id = $request->max_id ?? false;
$min_id = $request->min_id ?? false;
$since_id = $request->since_id ?? false;
$only_media = $request->only_media ?? false;
$user = Auth::user();
$account = Profile::whereNull('status')->findOrFail($id);
$statuses = $account->statuses()->getQuery();
if($only_media == true) {
$statuses = $statuses
->whereIn('scope', ['public','unlisted'])
->whereHas('media')
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id');
}
if($id == $account->id && !$max_id && !$min_id && !$since_id) {
$statuses = $statuses->orderBy('id', 'desc')
->paginate($limit);
} else if($since_id) {
$statuses = $statuses->where('id', '>', $since_id)
->orderBy('id', 'DESC')
->paginate($limit);
} else if($min_id) {
$statuses = $statuses->where('id', '>', $min_id)
->orderBy('id', 'ASC')
->paginate($limit);
} else if($max_id) {
$statuses = $statuses->where('id', '<', $max_id)
->orderBy('id', 'DESC')
->paginate($limit);
} else {
$statuses = $statuses->whereScope('public')->orderBy('id', 'desc')->paginate($limit);
}
$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function avatarUpdate(Request $request)
{
abort_if(!$request->user(), 403);
@ -195,7 +112,7 @@ class BaseApiController extends Controller
$name = $path['name'];
$public = $path['storage'];
$currentAvatar = storage_path('app/'.$profile->avatar->media_path);
$loc = $request->file('upload')->storeAs($public, $name);
$loc = $request->file('upload')->storePubliclyAs($public, $name);
$avatar = Avatar::whereProfileId($profile->id)->firstOrFail();
$opath = $avatar->media_path;
@ -215,21 +132,6 @@ class BaseApiController extends Controller
]);
}
public function showTempMedia(Request $request, $profileId, $mediaId, $timestamp)
{
abort(400, 'Endpoint deprecated');
}
public function uploadMedia(Request $request)
{
abort(400, 'Endpoint deprecated');
}
public function deleteMedia(Request $request)
{
abort(400, 'Endpoint deprecated');
}
public function verifyCredentials(Request $request)
{
$user = $request->user();
@ -242,21 +144,6 @@ class BaseApiController extends Controller
return response()->json($res);
}
public function drafts(Request $request)
{
$user = $request->user();
abort_if(!$request->user(), 403);
$medias = Media::whereUserId($user->id)
->whereNull('status_id')
->latest()
->take(13)
->get();
$resource = new Fractal\Resource\Collection($medias, new MediaDraftTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function accountLikes(Request $request)
{
abort_if(!$request->user(), 403);

View file

@ -14,16 +14,9 @@ use Auth, Cache;
use Illuminate\Support\Facades\Redis;
use App\Util\Site\Config;
use Illuminate\Http\Request;
use App\Services\SuggestionService;
class ApiController extends BaseApiController
{
// todo: deprecate and remove
public function hydrateLikes(Request $request)
{
return response()->json([]);
}
public function siteConfiguration(Request $request)
{
return response()->json(Config::get());
@ -31,79 +24,6 @@ class ApiController extends BaseApiController
public function userRecommendations(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(!config('exp.rec'), 400);
$id = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$id, now()->addHours(12), function() use ($id) {
return Follower::whereProfileId($id)->pluck('following_id')->toArray();
});
array_push($following, $id);
$ids = SuggestionService::get();
$filters = UserFilter::whereUserId($id)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
$following = array_merge($following, $filters);
$key = config('cache.prefix').':api:local:exp:rec:'.$id;
$ttl = (int) Redis::ttl($key);
if($request->filled('refresh') == true && (290 > $ttl) == true) {
Cache::forget('api:local:exp:rec:'.$id);
}
$res = Cache::remember('api:local:exp:rec:'.$id, now()->addMinutes(5), function() use($id, $following, $ids) {
return Profile::select(
'id',
'username'
)
->whereNotIn('id', $following)
->whereIn('id', $ids)
->whereIsPrivate(0)
->whereNull('status')
->whereNull('domain')
->inRandomOrder()
->take(3)
->get()
->map(function($item, $key) {
return [
'id' => $item->id,
'avatar' => $item->avatarUrl(),
'username' => $item->username,
'message' => 'Recommended for You'
];
});
});
return response()->json($res->all());
return response()->json([]);
}
public function composeLocationSearch(Request $request)
{
abort_if(!Auth::check(), 403);
$this->validate($request, [
'q' => 'required|string|max:100'
]);
$q = filter_var($request->input('q'), FILTER_SANITIZE_STRING);
$hash = hash('sha256', $q);
$key = 'search:location:id:' . $hash;
$places = Cache::remember($key, now()->addMinutes(15), function() use($q) {
$q = '%' . $q . '%';
return Place::where('name', 'like', $q)
->take(80)
->get()
->map(function($r) {
return [
'id' => $r->id,
'name' => $r->name,
'country' => $r->country,
'url' => $r->url()
];
});
});
return $places;
}
}

View file

@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use App\Services\BouncerService;
use Illuminate\Http\Request;
class ForgotPasswordController extends Controller
{
@ -29,4 +31,74 @@ class ForgotPasswordController extends Controller
{
$this->middleware('guest');
}
/**
* Display the form to request a password reset link.
*
* @return \Illuminate\View\View
*/
public function showLinkRequestForm()
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp(request()->ip()), 404);
}
usleep(random_int(100000, 300000));
return view('auth.passwords.email');
}
/**
* Validate the email for the given request.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
public function validateEmail(Request $request)
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
usleep(random_int(100000, 3000000));
if(config('captcha.enabled')) {
$rules = [
'email' => 'required|email',
'h-captcha-response' => 'required|captcha'
];
} else {
$rules = [
'email' => 'required|email'
];
}
$request->validate($rules, [
'h-captcha-response' => 'Failed to validate the captcha.',
]);
}
/**
* Get the response for a failed password reset link.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
public function sendResetLinkFailedResponse(Request $request, $response)
{
if ($request->wantsJson()) {
throw ValidationException::withMessages([
'email' => [trans($response)],
]);
}
return back()
->withInput($request->only('email'))
->withErrors([
'email' => trans($response),
]);
}
}

View file

@ -6,6 +6,7 @@ use App\AccountLog;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use App\Services\BouncerService;
class LoginController extends Controller
{
@ -42,6 +43,15 @@ class LoginController extends Controller
$this->middleware('guest')->except('logout');
}
public function showLoginForm()
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp(request()->ip()), 404);
}
return view('auth.login');
}
/**
* Validate the user login request.
*
@ -51,6 +61,10 @@ class LoginController extends Controller
*/
public function validateLogin($request)
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$rules = [
$this->username() => 'required|email',
'password' => 'required|string|min:6',

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\User;
use Purify;
use App\Util\Lexer\RestrictedNames;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use App\Services\EmailService;
use App\Services\BouncerService;
class RegisterController extends Controller
{
@ -157,10 +159,11 @@ class RegisterController extends Controller
}
return User::create([
'name' => $data['name'],
'name' => Purify::clean($data['name']),
'username' => $data['username'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'app_register_ip' => request()->ip()
]);
}
@ -172,9 +175,16 @@ class RegisterController extends Controller
public function showRegistrationForm()
{
if(config_cache('pixelfed.open_registration')) {
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp(request()->ip()), 404);
}
$limit = config('pixelfed.max_users');
if($limit) {
abort_if($limit <= User::count(), 404);
$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
if($limit <= $count) {
return redirect(route('help.instance-max-users-limit'));
}
abort_if($limit <= $count, 404);
return view('auth.register');
} else {
return view('auth.register');
@ -194,11 +204,15 @@ class RegisterController extends Controller
{
abort_if(config_cache('pixelfed.open_registration') == false, 400);
$count = User::count();
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
$limit = config('pixelfed.max_users');
if(false == config_cache('pixelfed.open_registration') || $limit && $limit <= $count) {
return abort(403);
return redirect(route('help.instance-max-users-limit'));
}
$this->validator($request->all())->validate();

View file

@ -4,6 +4,10 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Facades\Password;
use Illuminate\Http\Request;
use App\Services\BouncerService;
use Illuminate\Validation\Rules;
class ResetPasswordController extends Controller
{
@ -36,4 +40,123 @@ class ResetPasswordController extends Controller
{
$this->middleware('guest');
}
/**
* Get the password reset validation rules.
*
* @return array
*/
protected function rules()
{
usleep(random_int(100000, 3000000));
if(config('captcha.enabled')) {
return [
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
'h-captcha-response' => ['required' ,'filled', 'captcha']
];
}
return [
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
];
}
/**
* Get the password reset validation error messages.
*
* @return array
*/
protected function validationErrorMessages()
{
return [
'password.max' => 'Passwords should not exceed 72 characters.',
'h-captcha-response.required' => 'Failed to validate the captcha.',
'h-captcha-response.filled' => 'Failed to validate the captcha.',
'h-captcha-response.captcha' => 'Failed to validate the captcha.',
];
}
/**
* Display the password reset view for the given token.
*
* If no token is present, display the link request form.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function showResetForm(Request $request)
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
usleep(random_int(100000, 300000));
$token = $request->route()->parameter('token');
return view('auth.passwords.reset')->with(
['token' => $token, 'email' => $request->email]
);
}
public function reset(Request $request)
{
if(config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$request->validate($this->rules(), $this->validationErrorMessages());
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$response = $this->broker()->reset(
$this->credentials($request), function ($user, $password) {
$this->resetPassword($user, $password);
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $response == Password::PASSWORD_RESET
? $this->sendResetResponse($request, $response)
: $this->sendResetFailedResponse($request, $response);
}
/**
* Get the password reset credentials from the request.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
protected function credentials(Request $request)
{
return $request->only(
'email', 'password', 'password_confirmation', 'token'
);
}
/**
* Get the response for a failed password reset.
*
* @param \Illuminate\Http\Request $request
* @param string $response
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
protected function sendResetFailedResponse(Request $request, $response)
{
if ($request->wantsJson()) {
throw ValidationException::withMessages(['email' => [trans($response)]]);
}
return redirect()->back()
->withInput($request->only('email'))
->withErrors(['email' => [trans($response)]]);
}
}

View file

@ -30,7 +30,7 @@ class AvatarController extends Controller
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
$loc = $request->file('avatar')->storeAs($public, $name);
$loc = $request->file('avatar')->storePubliclyAs($public, $name);
$avatar = Avatar::firstOrNew(['profile_id' => $profile->id]);
$currentAvatar = $avatar->recentlyCreated ? null : storage_path('app/'.$profile->avatar->media_path);

View file

@ -7,40 +7,61 @@ use App\Status;
use Auth;
use Illuminate\Http\Request;
use App\Services\BookmarkService;
use App\Services\FollowerService;
class BookmarkController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer|min:1',
]);
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer|min:1',
]);
$profile = Auth::user()->profile;
$status = Status::findOrFail($request->input('item'));
$profile = Auth::user()->profile;
$status = Status::findOrFail($request->input('item'));
$bookmark = Bookmark::firstOrCreate(
['status_id' => $status->id], ['profile_id' => $profile->id]
);
abort_if($status->in_reply_to_id || $status->reblog_of_id, 404);
abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404);
if (!$bookmark->wasRecentlyCreated) {
BookmarkService::del($profile->id, $status->id);
$bookmark->delete();
} else {
BookmarkService::add($profile->id, $status->id);
}
if($status->scope == 'private') {
if($profile->id !== $status->profile_id && !FollowerService::follows($profile->id, $status->profile_id)) {
if($exists = Bookmark::whereStatusId($status->id)->whereProfileId($profile->id)->first()) {
BookmarkService::del($profile->id, $status->id);
$exists->delete();
if ($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
} else {
$response = redirect()->back();
}
if ($request->ajax()) {
return ['code' => 200, 'msg' => 'Bookmark removed!'];
} else {
return redirect()->back();
}
}
abort(404, 'Error: You cannot bookmark private posts from accounts you do not follow.');
}
}
return $response;
}
$bookmark = Bookmark::firstOrCreate(
['status_id' => $status->id], ['profile_id' => $profile->id]
);
if (!$bookmark->wasRecentlyCreated) {
BookmarkService::del($profile->id, $status->id);
$bookmark->delete();
} else {
BookmarkService::add($profile->id, $status->id);
}
if ($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
} else {
$response = redirect()->back();
}
return $response;
}
}

View file

@ -135,7 +135,6 @@ class CollectionController extends Controller
$collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
$count = $collection->items()->count();
CollectionService::deleteCollection($collection->id);
if($count) {
CollectionItem::whereCollectionId($collection->id)

View file

@ -41,6 +41,7 @@ use App\Jobs\VideoPipeline\{
VideoThumbnail
};
use App\Services\AccountService;
use App\Services\CollectionService;
use App\Services\NotificationService;
use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
@ -75,13 +76,16 @@ class ComposeController extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'file.*' => function() {
return [
'required',
'mimetypes:' . config_cache('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
'file.*' => [
'required_without:file',
'mimetypes:' . config_cache('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'),
],
'file' => [
'required_without:file.*',
'mimetypes:' . config_cache('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'),
],
'filter_name' => 'nullable|string|max:24',
'filter_class' => 'nullable|alpha_dash|max:24'
]);
@ -119,7 +123,7 @@ class ComposeController extends Controller
abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format');
$storagePath = MediaPathService::get($user, 2);
$path = $photo->store($storagePath);
$path = $photo->storePublicly($storagePath);
$hash = \hash_file('sha256', $photo);
$mime = $photo->getMimeType();
@ -145,11 +149,11 @@ class ComposeController extends Controller
case 'image/jpeg':
case 'image/png':
case 'image/webp':
ImageOptimize::dispatch($media);
ImageOptimize::dispatch($media)->onQueue('mmo');
break;
case 'video/mp4':
VideoThumbnail::dispatch($media);
VideoThumbnail::dispatch($media)->onQueue('mmo');
$preview_url = '/storage/no-preview.png';
$url = '/storage/no-preview.png';
break;
@ -205,11 +209,11 @@ class ComposeController extends Controller
$name = last($fragments);
array_pop($fragments);
$dir = implode('/', $fragments);
$path = $photo->storeAs($dir, $name);
$path = $photo->storePubliclyAs($dir, $name);
$res = [
'url' => $media->url() . '?v=' . time()
];
ImageOptimize::dispatch($media);
ImageOptimize::dispatch($media)->onQueue('mmo');
Cache::forget($limitKey);
return $res;
}
@ -317,14 +321,30 @@ class ComposeController extends Controller
]);
$pid = $request->user()->profile_id;
abort_if(!$pid, 400);
$q = filter_var($request->input('q'), FILTER_SANITIZE_STRING);
$hash = hash('sha256', $q);
$key = 'pf:search:location:v1:id:' . $hash;
$popular = Cache::remember('pf:search:location:v1:popular', 86400, function() {
if(config('database.default') != 'mysql') {
return [];
}
$q = e($request->input('q'));
$popular = Cache::remember('pf:search:location:v1:popular', 1209600, function() {
$minId = SnowflakeService::byDate(now()->subDays(290));
if(config('database.default') == 'pgsql') {
return Status::selectRaw('id, place_id, count(place_id) as pc')
->whereNotNull('place_id')
->where('id', '>', $minId)
->orderByDesc('pc')
->groupBy(['place_id', 'id'])
->limit(400)
->get()
->filter(function($post) {
return $post;
})
->map(function($place) {
return [
'id' => $place->place_id,
'count' => $place->pc
];
})
->unique('id')
->values();
}
return Status::selectRaw('id, place_id, count(place_id) as pc')
->whereNotNull('place_id')
->where('id', '>', $minId)
@ -342,30 +362,30 @@ class ComposeController extends Controller
];
});
});
$places = Cache::remember($key, 900, function() use($q, $popular) {
$q = '%' . $q . '%';
return DB::table('places')
->where('name', 'like', $q)
->limit((strlen($q) > 5 ? 360 : 180))
->get()
->sortByDesc(function($place, $key) use($popular) {
return $popular->filter(function($p) use($place) {
return $p['id'] == $place->id;
})->map(function($p) use($place) {
return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1;
})->values();
})
->map(function($r) {
return [
'id' => $r->id,
'name' => $r->name,
'country' => $r->country,
'url' => url('/discover/places/' . $r->id . '/' . $r->slug)
];
})
->values()
->all();
});
$q = '%' . $q . '%';
$wildcard = config('database.default') === 'pgsql' ? 'ilike' : 'like';
$places = DB::table('places')
->where('name', $wildcard, $q)
->limit((strlen($q) > 5 ? 360 : 30))
->get()
->sortByDesc(function($place, $key) use($popular) {
return $popular->filter(function($p) use($place) {
return $p['id'] == $place->id;
})->map(function($p) use($place) {
return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1;
})->values();
})
->map(function($r) {
return [
'id' => $r->id,
'name' => $r->name,
'country' => $r->country,
'url' => url('/discover/places/' . $r->id . '/' . $r->slug)
];
})
->values()
->all();
return $places;
}
@ -508,12 +528,7 @@ class ComposeController extends Controller
$m->license = $license;
$m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null;
$m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
// if($optimize_media == false) {
// $m->skip_optimize = true;
// ImageThumbnail::dispatch($m);
// } else {
// ImageOptimize::dispatch($m);
// }
if($cw == true || $profile->cw == true) {
$m->is_nsfw = $cw;
$status->is_nsfw = $cw;
@ -546,6 +561,7 @@ class ComposeController extends Controller
$status->caption = strip_tags($request->caption);
$status->rendered = Autolink::create()->autolink($status->caption);
$status->scope = 'draft';
$status->visibility = 'draft';
$status->profile_id = $profile->id;
$status->save();
@ -582,13 +598,24 @@ class ComposeController extends Controller
$collections = Collection::whereProfileId($profile->id)
->find($request->input('collections'))
->each(function($collection) use($status) {
$count = $collection->items()->count();
CollectionItem::firstOrCreate([
'collection_id' => $collection->id,
'object_type' => 'App\Status',
'object_id' => $status->id
], [
'order' => $collection->items()->count()
'order' => $count
]);
CollectionService::addItem(
$collection->id,
$status->id,
$count
);
$collection->updated_at = now();
$collection->save();
CollectionService::setCollection($collection->id, $collection);
});
}

View file

@ -6,6 +6,7 @@ use Illuminate\Http\Request;
use Auth;
use App\Contact;
use App\Jobs\ContactPipeline\ContactPipeline;
use App\Rules\MaxMultiLine;
class ContactController extends Controller
{
@ -21,7 +22,7 @@ class ContactController extends Controller
abort_if(!Auth::check(), 403);
$this->validate($request, [
'message' => 'required|string|min:5|max:500',
'message' => ['required', 'string', 'min:5', new MaxMultiLine('500')],
'request_response' => 'string|max:3'
]);
@ -45,7 +46,7 @@ class ContactController extends Controller
$contact->response = '';
$contact->save();
ContactPipeline::dispatchNow($contact);
ContactPipeline::dispatch($contact);
return redirect()->back()->with('status', 'Success - Your message has been sent to admins.');
}

View file

@ -602,7 +602,7 @@ class DirectMessageController extends Controller
}
$storagePath = MediaPathService::get($user, 2) . Str::random(8);
$path = $photo->store($storagePath);
$path = $photo->storePublicly($storagePath);
$hash = \hash_file('sha256', $photo);
abort_if(MediaBlocklistService::exists($hash) == true, 451);

View file

@ -24,6 +24,7 @@ use App\Services\ReblogService;
use App\Services\StatusHashtagService;
use App\Services\SnowflakeService;
use App\Services\StatusService;
use App\Services\TrendingHashtagService;
use App\Services\UserFilterService;
class DiscoverController extends Controller
@ -40,6 +41,7 @@ class DiscoverController extends Controller
$tag = Hashtag::whereName($hashtag)
->orWhere('slug', $hashtag)
->where('is_banned', '!=', true)
->firstOrFail();
$tagCount = StatusHashtagService::count($tag->id);
return view('discover.tags.show', compact('tag', 'tagCount'));
@ -52,14 +54,22 @@ class DiscoverController extends Controller
$this->validate($request, [
'hashtag' => 'required|string|min:1|max:124',
'page' => 'nullable|integer|min:1|max:' . ($user ? 29 : 10)
'page' => 'nullable|integer|min:1|max:' . ($user ? 29 : 3)
]);
$page = $request->input('page') ?? '1';
$end = $page > 1 ? $page * 9 : 0;
$tag = $request->input('hashtag');
$hashtag = Hashtag::whereName($tag)->firstOrFail();
if(config('database.default') === 'pgsql') {
$hashtag = Hashtag::where('name', 'ilike', $tag)->firstOrFail();
} else {
$hashtag = Hashtag::whereName($tag)->firstOrFail();
}
if($hashtag->is_banned == true) {
return [];
}
if($user) {
$res['follows'] = HashtagService::isFollowing($user->profile_id, $hashtag->id);
}
@ -181,23 +191,7 @@ class DiscoverController extends Controller
{
abort_if(!$request->user(), 403);
$res = Cache::remember('api:discover:v1.1:trending:hashtags', 3600, function() {
return StatusHashtag::select('hashtag_id', \DB::raw('count(*) as total'))
->groupBy('hashtag_id')
->orderBy('total','desc')
->where('created_at', '>', now()->subDays(90))
->take(9)
->get()
->map(function($h) {
$hashtag = $h->hashtag;
return [
'id' => $hashtag->id,
'total' => $h->total,
'name' => '#'.$hashtag->name,
'url' => $hashtag->url()
];
});
});
$res = TrendingHashtagService::getTrending();
return $res;
}

View file

@ -29,6 +29,7 @@ use App\Util\ActivityPub\{
Outbox
};
use Zttp\Zttp;
use App\Services\InstanceService;
class FederationController extends Controller
{
@ -56,12 +57,35 @@ class FederationController extends Controller
}
$resource = $request->input('resource');
$domain = config('pixelfed.domain.app');
if(config('federation.activitypub.sharedInbox') &&
$resource == 'acct:' . $domain . '@' . $domain) {
$res = [
'subject' => 'acct:' . $domain . '@' . $domain,
'aliases' => [
'https://' . $domain . '/i/actor'
],
'links' => [
[
'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html',
'href' => 'https://' . $domain . '/site/kb/instance-actor'
],
[
'rel' => 'self',
'type' => 'application/activity+json',
'href' => 'https://' . $domain . '/i/actor'
]
]
];
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
}
$hash = hash('sha256', $resource);
$key = 'federation:webfinger:sha256:' . $hash;
if($cached = Cache::get($key)) {
return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
}
$domain = config('pixelfed.domain.app');
if(strpos($resource, $domain) == false) {
return response('', 400);
}
@ -94,20 +118,18 @@ class FederationController extends Controller
public function userOutbox(Request $request, $username)
{
abort_if(!config_cache('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.outbox'), 404);
// $profile = Profile::whereNull('domain')
// ->whereNull('status')
// ->whereIsPrivate(false)
// ->whereUsername($username)
// ->firstOrFail();
if(!$request->wantsJson()) {
return redirect('/' . $username);
}
// $key = 'ap:outbox:latest_10:pid:' . $profile->id;
// $ttl = now()->addMinutes(15);
// $res = Cache::remember($key, $ttl, function() use($profile) {
// return Outbox::get($profile);
// });
$res = [];
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => 'https://' . config('pixelfed.domain.app') . '/users/' . $username . '/outbox',
'type' => 'OrderedCollection',
'totalItems' => 0,
'orderedItems' => []
];
return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
}
@ -119,38 +141,43 @@ class FederationController extends Controller
$headers = $request->headers->all();
$payload = $request->getContent();
if(!$payload || empty($payload)) {
return;
}
$obj = json_decode($payload, true, 8);
if(!isset($obj['id'])) {
return;
}
$domain = parse_url($obj['id'], PHP_URL_HOST);
if(in_array($domain, InstanceService::getBannedDomains())) {
return;
}
if(isset($obj['type']) && $obj['type'] === 'Delete') {
if(!isset($obj['id'])) {
return;
}
usleep(5000);
$lockKey = 'pf:ap:del-lock:' . hash('sha256', $obj['id']);
if( isset($obj['actor']) &&
isset($obj['object']) &&
isset($obj['id']) &&
is_string($obj['id']) &&
is_string($obj['actor']) &&
is_string($obj['object']) &&
$obj['actor'] == $obj['object']
) {
if(Cache::get($lockKey) !== null) {
if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
if($obj['object']['type'] === 'Person') {
if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
return;
}
}
if($obj['object']['type'] === 'Tombstone') {
if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return;
}
}
if($obj['object']['type'] === 'Story') {
dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
return;
}
}
Cache::put($lockKey, 1, 3600);
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return;
} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('follow');
} else {
if(!isset($obj['id'])) {
return;
}
usleep(5000);
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $obj['id']);
if(Cache::get($lockKey) !== null) {
return;
}
Cache::put($lockKey, 1, 3600);
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
}
return;
@ -163,29 +190,47 @@ class FederationController extends Controller
$headers = $request->headers->all();
$payload = $request->getContent();
if(!$payload || empty($payload)) {
return;
}
$obj = json_decode($payload, true, 8);
if(!isset($obj['id'])) {
return;
}
$domain = parse_url($obj['id'], PHP_URL_HOST);
if(in_array($domain, InstanceService::getBannedDomains())) {
return;
}
if(isset($obj['type']) && $obj['type'] === 'Delete') {
if(!isset($obj['id'])) {
return;
}
$lockKey = 'pf:ap:del-lock:' . hash('sha256', $obj['id']);
if( isset($obj['actor']) &&
isset($obj['object']) &&
isset($obj['id']) &&
is_string($obj['id']) &&
is_string($obj['actor']) &&
is_string($obj['object']) &&
$obj['actor'] == $obj['object']
) {
if(Cache::get($lockKey) !== null) {
if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
if($obj['object']['type'] === 'Person') {
if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
return;
}
}
if($obj['object']['type'] === 'Tombstone') {
if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return;
}
}
if($obj['object']['type'] === 'Story') {
dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
return;
}
}
Cache::put($lockKey, 1, 3600);
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return;
} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
dispatch(new InboxWorker($headers, $payload))->onQueue('follow');
} else {
dispatch(new InboxWorker($headers, $payload))->onQueue('high');
dispatch(new InboxWorker($headers, $payload))->onQueue('shared');
}
return;
}
@ -194,15 +239,6 @@ class FederationController extends Controller
{
abort_if(!config_cache('federation.activitypub.enabled'), 404);
$profile = Profile::whereNull('remote_url')
->whereUsername($username)
->whereIsPrivate(false)
->firstOrFail();
if($profile->status != null) {
abort(404);
}
$obj = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $request->getUri(),
@ -217,15 +253,6 @@ class FederationController extends Controller
{
abort_if(!config_cache('federation.activitypub.enabled'), 404);
$profile = Profile::whereNull('remote_url')
->whereUsername($username)
->whereIsPrivate(false)
->firstOrFail();
if($profile->status != null) {
abort(404);
}
$obj = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $request->getUri(),

View file

@ -23,109 +23,7 @@ class FollowerController extends Controller
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|string',
'force' => 'nullable|boolean',
]);
$force = (bool) $request->input('force', true);
$item = (int) $request->input('item');
$url = $this->handleFollowRequest($item, $force);
if($request->wantsJson() == true) {
return response()->json(200);
} else {
return redirect($url);
}
}
protected function handleFollowRequest($item, $force)
{
$user = Auth::user()->profile;
$target = Profile::where('id', '!=', $user->id)->whereNull('status')->findOrFail($item);
$private = (bool) $target->is_private;
$remote = (bool) $target->domain;
$blocked = UserFilter::whereUserId($target->id)
->whereFilterType('block')
->whereFilterableId($user->id)
->whereFilterableType('App\Profile')
->exists();
if($blocked == true) {
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($private == true && $isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follow = FollowRequest::firstOrCreate([
'follower_id' => $user->id,
'following_id' => $target->id
]);
if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target);
}
FollowerService::add($user->id, $target->id);
} elseif ($private == false && $isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follower = new Follower();
$follower->profile_id = $user->id;
$follower->following_id = $target->id;
$follower->save();
if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target);
}
FollowerService::add($user->id, $target->id);
FollowPipeline::dispatch($follower);
} else {
if($force == true) {
$request = FollowRequest::whereFollowerId($user->id)->whereFollowingId($target->id)->exists();
$follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($remote == true && $request && !$follower) {
$this->sendFollow($user, $target);
}
if($remote == true && $follower) {
$this->sendUndoFollow($user, $target);
}
Follower::whereProfileId($user->id)
->whereFollowingId($target->id)
->delete();
FollowerService::remove($user->id, $target->id);
}
}
Cache::forget('profile:following:'.$target->id);
Cache::forget('profile:followers:'.$target->id);
Cache::forget('profile:following:'.$user->id);
Cache::forget('profile:followers:'.$user->id);
Cache::forget('api:local:exp:rec:'.$user->id);
Cache::forget('user:account:id:'.$target->user_id);
Cache::forget('user:account:id:'.$user->user_id);
Cache::forget('px:profile:followers-v1.3:'.$user->id);
Cache::forget('px:profile:followers-v1.3:'.$target->id);
Cache::forget('px:profile:following-v1.3:'.$user->id);
Cache::forget('px:profile:following-v1.3:'.$target->id);
Cache::forget('profile:follower_count:'.$target->id);
Cache::forget('profile:follower_count:'.$user->id);
Cache::forget('profile:following_count:'.$target->id);
Cache::forget('profile:following_count:'.$user->id);
return $target->url();
abort(422, 'Deprecated API Endpoint, use /api/v1/accounts/{id}/follow or /api/v1/accounts/{id}/unfollow instead.');
}
public function sendFollow($user, $target)

View file

@ -93,7 +93,7 @@ trait Instagram
continue;
}
$storagePath = "import/{$job->uuid}";
$path = $v->store($storagePath);
$path = $v->storePublicly($storagePath);
DB::transaction(function() use ($profile, $job, $path, $original) {
$data = new ImportData;
$data->profile_id = $profile->id;
@ -141,7 +141,7 @@ trait Instagram
return abort(500);
}
$storagePath = "import/{$job->uuid}";
$path = $media->store($storagePath);
$path = $media->storePublicly($storagePath);
$job->media_json = $path;
$job->stage = 3;
$job->save();

View file

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

View file

@ -17,6 +17,10 @@ class PlaceController extends Controller
public function show(Request $request, $id, $slug)
{
$this->validate($request, [
'page' => 'sometimes|max:10'
]);
$place = Place::whereSlug($slug)->findOrFail($id);
$posts = Status::wherePlaceId($place->id)
->whereNull('uri')

View file

@ -13,6 +13,10 @@ use App\Services\StatusService;
class PortfolioController extends Controller
{
const RSS_FEED_KEY = 'pf:portfolio:rss-feed:';
const CACHED_FEED_KEY = 'pf:portfolio:cached-feed:';
const RECENT_FEED_KEY = 'pf:portfolio:recent-feed:';
public function index(Request $request)
{
return view('portfolio.index');
@ -60,11 +64,11 @@ class PortfolioController extends Controller
$user = AccountService::get($post['account']['id']);
$portfolio = Portfolio::whereProfileId($user['id'])->first();
if($user['locked'] || $portfolio->active != true) {
if(!$portfolio || $user['locked'] || $portfolio->active != true) {
return view('portfolio.404');
}
if(!$post || $post['visibility'] != 'public' || $post['pf_type'] != 'photo' || $user['id'] != $post['account']['id']) {
if(!$post || $post['visibility'] != 'public' || !in_array($post['pf_type'], ['photo', 'photo:album']) || $user['id'] != $post['account']['id']) {
return view('portfolio.404');
}
@ -117,7 +121,7 @@ class PortfolioController extends Controller
$this->validate($request, [
'profile_source' => 'required|in:recent,custom',
'layout' => 'required|in:grid,masonry',
'layout_container' => 'required|in:fixed,fluid'
'layout_container' => 'required|in:fixed,fluid',
]);
$portfolio = Portfolio::whereUserId($request->user()->id)->first();
@ -140,6 +144,7 @@ class PortfolioController extends Controller
$portfolio->show_bio = $request->input('show_bio') === 'on';
$portfolio->profile_layout = $request->input('layout');
$portfolio->profile_container = $request->input('layout_container');
$portfolio->metadata = $metadata;
$portfolio->save();
return redirect('/' . $request->user()->username);
@ -167,20 +172,28 @@ class PortfolioController extends Controller
}
protected function getCustomFeed($portfolio) {
if(!$portfolio->metadata['posts']) {
if(!isset($portfolio->metadata['posts']) || !$portfolio->metadata['posts']) {
return response()->json([], 400);
}
return collect($portfolio->metadata['posts'])->map(function($p) {
return StatusService::get($p);
})
->filter(function($p) {
return $p && isset($p['account']);
})->values();
$feed = Cache::remember(self::CACHED_FEED_KEY . $portfolio->profile_id, 86400, function() use($portfolio) {
return collect($portfolio->metadata['posts'])->map(function($p) {
return StatusService::get($p);
})
->filter(function($p) {
return $p && isset($p['account']);
});
});
if($portfolio->metadata && isset($portfolio->metadata['feed_order']) && $portfolio->metadata['feed_order'] === 'recent') {
return $feed->reverse()->values();
} else {
return $feed->values();
}
}
protected function getRecentFeed($id) {
$media = Cache::remember('portfolio:recent-feed:' . $id, 3600, function() use($id) {
$media = Cache::remember(self::RECENT_FEED_KEY . $id, 3600, function() use($id) {
return DB::table('media')
->whereProfileId($id)
->whereNotNull('status_id')
@ -215,6 +228,14 @@ class PortfolioController extends Controller
}
return $res->map(function($p) {
$metadata = $p->metadata;
$bgColor = $metadata && isset($metadata['background_color']) ? $metadata['background_color'] : '#000000';
$textColor = $metadata && isset($metadata['text_color']) ? $metadata['text_color'] : '#d4d4d8';
$rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$colorScheme = $metadata && isset($metadata['color_scheme']) ? $metadata['color_scheme'] : 'dark';
$feedOrder = $metadata && isset($metadata['feed_order']) ? $metadata['feed_order'] : 'oldest';
return [
'url' => $p->url(),
'pid' => (string) $p->profile_id,
@ -228,6 +249,13 @@ class PortfolioController extends Controller
'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source,
'color_scheme' => $colorScheme,
'background_color' => $bgColor,
'text_color' => $textColor,
'show_profile_button' => true,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
'feed_order' => $feedOrder,
'metadata' => $p->metadata
];
})->first();
@ -248,8 +276,13 @@ class PortfolioController extends Controller
if(!$p) {
return [];
}
$metadata = $p->metadata;
return [
$rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$profileButton = $metadata && isset($metadata['show_profile_button']) ? $metadata['show_profile_button'] : false;
$res = [
'url' => $p->url(),
'show_captions' => (bool) $p->show_captions,
'show_license' => (bool) $p->show_license,
@ -259,8 +292,27 @@ class PortfolioController extends Controller
'show_avatar' => (bool) $p->show_avatar,
'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source
'profile_source' => $p->profile_source,
'show_profile_button' => $profileButton,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
];
if($rssEnabled) {
$res['rss_feed_url'] = $p->permalink('.rss');
}
if($p->metadata) {
if(isset($p->metadata['background_color'])) {
$res['background_color'] = $p->metadata['background_color'];
}
if(isset($p->metadata['text_color'])) {
$res['text_color'] = $p->metadata['text_color'];
}
}
return $res;
}
public function storeSettings(Request $request)
@ -268,11 +320,99 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'profile_layout' => 'sometimes|in:grid,masonry,album'
'active' => 'sometimes|boolean',
'show_captions' => 'sometimes|boolean',
'show_license' => 'sometimes|boolean',
'show_location' => 'sometimes|boolean',
'show_timestamp' => 'sometimes|boolean',
'show_link' => 'sometimes|boolean',
'show_avatar' => 'sometimes|boolean',
'show_bio' => 'sometimes|boolean',
'profile_layout' => 'sometimes|in:grid,masonry,album',
'profile_source' => 'sometimes|in:recent,custom',
'color_scheme' => 'sometimes|in:light,dark,custom',
'show_profile_button' => 'sometimes|boolean',
'rss_enabled' => 'sometimes|boolean',
'show_rss_button' => 'sometimes|boolean',
'feed_order' => 'sometimes|in:oldest,recent',
'background_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
'text_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
]);
$res = Portfolio::whereUserId($request->user()->id)
->update($request->only([
$res = Portfolio::whereUserId($request->user()->id)->firstOrFail();
$pid = $request->user()->profile_id;
$metadata = $res->metadata;
$clearFeedCache = false;
if($request->has('color_scheme')) {
$metadata['color_scheme'] = $request->input('color_scheme');
}
if($request->has('background_color')) {
$metadata['background_color'] = $request->input('background_color');
$bgc = $request->background_color;
if($bgc && $bgc !== '#000000') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('text_color')) {
$metadata['text_color'] = $request->input('text_color');
$txc = $request->text_color;
if($txc && $txc !== '#d4d4d8') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('show_profile_button')) {
$metadata['show_profile_button'] = $request->input('show_profile_button');
}
if($request->has('rss_enabled')) {
$metadata['rss_enabled'] = $request->input('rss_enabled');
}
if($request->has('show_rss_button')) {
$metadata['show_rss_button'] = $metadata['rss_enabled'] ? $request->input('show_rss_button') : false;
}
if($request->has('feed_order')) {
$metadata['feed_order'] = $request->input('feed_order');
}
if(isset($metadata['background_color']) || isset($metadata['text_color'])) {
$bgc = isset($metadata['background_color']) ? $metadata['background_color'] : null;
$txc = isset($metadata['text_color']) ? $metadata['text_color'] : null;
if((!$bgc || $bgc == '#000000') && (!$txc || $txc === '#d4d4d8') && $request->color_scheme != 'light') {
$metadata['color_scheme'] = 'dark';
}
}
if($request->has('color_scheme') && $request->color_scheme === 'light') {
$metadata['background_color'] = '#ffffff';
$metadata['text_color'] = '#000000';
$metadata['color_scheme'] = 'light';
}
if($request->metadata !== $metadata) {
$res->metadata = $metadata;
$res->save();
}
if($request->profile_layout != $res->profile_layout) {
$clearFeedCache = true;
}
$res->update($request->only([
'active',
'show_captions',
'show_license',
@ -285,7 +425,11 @@ class PortfolioController extends Controller
'profile_source'
]));
Cache::forget('portfolio:recent-feed:' . $request->user()->profile_id);
Cache::forget(self::RECENT_FEED_KEY . $pid);
if($clearFeedCache) {
Cache::forget(self::RSS_FEED_KEY . $pid);
}
return 200;
}
@ -295,7 +439,7 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'ids' => 'required|array|max:24'
'ids' => 'required|array|max:100'
]);
$pid = $request->user()->profile_id;
@ -308,11 +452,117 @@ class PortfolioController extends Controller
->findOrFail($ids);
$p = Portfolio::whereProfileId($pid)->firstOrFail();
$p->metadata = ['posts' => $ids];
$metadata = $p->metadata;
$metadata['posts'] = $ids;
$p->metadata = $metadata;
$p->save();
Cache::forget('portfolio:recent-feed:' . $pid);
Cache::forget(self::RECENT_FEED_KEY . $pid);
Cache::forget(self::RSS_FEED_KEY . $pid);
Cache::forget(self::CACHED_FEED_KEY . $pid);
return $request->ids;
}
public function getRssFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
abort_if(!$metadata || !isset($metadata['rss_enabled']), 404);
abort_unless($metadata['rss_enabled'], 404);
$account = AccountService::get($user->profile_id);
$portfolioUrl = $portfolio->url();
$portfolioLayout = $portfolio->profile_layout;
if(!isset($metadata['posts']) || !count($metadata['posts'])) {
$feed = [];
} else {
$feed = Cache::remember(
self::RSS_FEED_KEY . $user->profile_id,
43200,
function() use($portfolio, $portfolioUrl, $portfolioLayout) {
return collect($portfolio->metadata['posts'])->map(function($post) {
return StatusService::get($post);
})
->filter()
->values()
->map(function($post, $idx) use($portfolioLayout, $portfolioUrl) {
$ts = now()->parse($post['created_at']);
$url = $portfolioLayout == 'album' ? $portfolioUrl . '?slide=' . ($idx + 1) : $portfolioUrl . '/' . $post['id'];
return [
'title' => 'Post by ' . $post['account']['username'] . ' on ' . $ts->format('D, d M Y'),
'description' => $post['content_text'],
'pubDate' => date('D, d M Y H:i:s ', strtotime($post['created_at'])) . 'GMT',
'url' => $url
];
})
->reverse()
->take(10)
->toArray();
}
);
}
$now = date('D, d M Y H:i:s ') . 'GMT';
return response()
->view('portfolio.rss_feed', compact('account', 'now', 'feed', 'portfolioUrl'), 200)
->header('Content-Type', 'text/xml');
return response($feed)->withHeaders(['Content-Type' => 'text/xml']);
}
public function getApFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
$baseUrl = config('app.url');
$page = $request->input('page');
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $portfolio->permalink('.json'),
'type' => 'OrderedCollection',
'totalItems' => isset($metadata['posts']) ? count($metadata['posts']) : 0,
];
if($request->has('page')) {
$start = $page == 1 ? 0 : ($page * 10 - 10);
$res['id'] = $portfolio->permalink('.json?page=' . $page);
$res['type'] = 'OrderedCollectionPage';
$res['next'] = $portfolio->permalink('.json?page=' . $page + 1);
$res['partOf'] = $portfolio->permalink('.json');
$res['orderedItems'] = collect($metadata['posts'])->slice($start)->take(10)->map(function($p) {
return StatusService::get($p);
})
->filter()
->map(function($p) {
return $p['url'];
})
->values();
if(!$res['orderedItems'] || $res['orderedItems']->count() != 10) {
unset($res['next']);
}
} else {
$res['first'] = $portfolio->permalink('.json?page=1');
}
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT)
->header('Content-Type', 'application/activity+json');
}
}

View file

@ -207,7 +207,7 @@ class ProfileController extends Controller
abort_if(!$profile || $profile['locked'] || !$profile['local'], 404);
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 86400, function() use($pid, $profile) {
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 43200, function() use($pid, $profile) {
$items = DB::table('statuses')
->whereProfileId($pid)
->whereVisibility('public')

View file

@ -62,36 +62,6 @@ class PublicApiController extends Controller
}
}
protected function getLikes($status)
{
if(false == Auth::check()) {
return [];
} else {
$profile = Auth::user()->profile;
if($profile->status) {
return [];
}
$likes = $status->likedBy()->orderBy('created_at','desc')->paginate(10);
$collection = new Fractal\Resource\Collection($likes, new AccountTransformer());
return $this->fractal->createData($collection)->toArray();
}
}
protected function getShares($status)
{
if(false == Auth::check()) {
return [];
} else {
$profile = Auth::user()->profile;
if($profile->status) {
return [];
}
$shares = $status->sharedBy()->orderBy('created_at','desc')->paginate(10);
$collection = new Fractal\Resource\Collection($shares, new AccountTransformer());
return $this->fractal->createData($collection)->toArray();
}
}
public function getStatus(Request $request, $id)
{
abort_if(!$request->user(), 403);
@ -216,41 +186,6 @@ class PublicApiController extends Controller
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function statusLikes(Request $request, $username, $id)
{
abort_if(!$request->user(), 404);
$status = Status::findOrFail($id);
$this->scopeCheck($status->profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$likes = $this->getLikes($status);
return response()->json([
'data' => $likes
]);
}
public function statusShares(Request $request, $username, $id)
{
abort_if(!$request->user(), 404);
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($id);
$this->scopeCheck($profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$shares = $this->getShares($status);
return response()->json([
'data' => $shares
]);
}
protected function scopeCheck(Profile $profile, Status $status)
{
if($profile->is_private == true && Auth::check() == false) {
@ -307,6 +242,7 @@ class PublicApiController extends Controller
$user = $request->user();
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
if(config('exp.cached_public_timeline') == false) {
if($min || $max) {
$dir = $min ? '>' : '<';
@ -322,6 +258,9 @@ class PublicApiController extends Controller
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereLocal(true)
->when($hideNsfw, function($q, $hideNsfw) {
return $q->where('is_nsfw', false);
})
->whereScope('public')
->orderBy('id', 'desc')
->limit($limit)
@ -365,6 +304,9 @@ class PublicApiController extends Controller
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereLocal(true)
->when($hideNsfw, function($q, $hideNsfw) {
return $q->where('is_nsfw', false);
})
->whereScope('public')
->orderBy('id', 'desc')
->limit($limit)
@ -447,48 +389,25 @@ class PublicApiController extends Controller
$user = $request->user();
$key = 'user:last_active_at:id:'.$user->id;
$ttl = now()->addMinutes(20);
Cache::remember($key, $ttl, function() use($user) {
if(Cache::get($key) == null) {
$user->last_active_at = now();
$user->save();
return;
});
Cache::put($key, true, 43200);
}
$pid = $user->profile_id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Cache::remember('profile:following:'.$pid, 1209600, function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray();
});
if($recentFeed == true) {
$key = 'profile:home-timeline-cursor:'.$user->id;
$ttl = now()->addMinutes(30);
$min = Cache::remember($key, $ttl, function() use($pid) {
$res = StatusView::whereProfileId($pid)->orderByDesc('status_id')->first();
return $res ? $res->status_id : null;
});
}
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
$types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
// $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album', 'text'];
$textOnlyReplies = false;
if(config('exp.top')) {
$textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
$textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
if($textOnlyPosts) {
array_push($types, 'text');
}
}
if(config('exp.polls') == true) {
array_push($types, 'poll');
}
if($min || $max) {
$dir = $min ? '>' : '<';
$id = $min ?? $max;
@ -513,7 +432,7 @@ class PublicApiController extends Controller
'updated_at'
)
->whereIn('type', $types)
->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
->when(!$textOnlyReplies, function($q, $textOnlyReplies) {
return $q->whereNull('in_reply_to_id');
})
->where('id', $dir, $id)
@ -523,10 +442,14 @@ class PublicApiController extends Controller
->limit($limit)
->get()
->map(function($s) use ($user) {
try {
$status = StatusService::get($s->id, false);
if(!$status) {
return false;
}
} catch(\Exception $e) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
@ -568,10 +491,14 @@ class PublicApiController extends Controller
->limit($limit)
->get()
->map(function($s) use ($user) {
$status = StatusService::get($s->id, false);
if(!$status) {
return false;
}
try {
$status = StatusService::get($s->id, false);
if(!$status) {
return false;
}
} catch(\Exception $e) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
@ -608,6 +535,7 @@ class PublicApiController extends Controller
$amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
if(config('instance.timeline.network.cached') == false) {
if($min || $max) {
@ -620,7 +548,10 @@ class PublicApiController extends Controller
'scope',
'created_at',
)
->where('id', $dir, $id)
->where('id', $dir, $id)
->when($hideNsfw, function($q, $hideNsfw) {
return $q->where('is_nsfw', false);
})
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereNotIn('profile_id', $filtered)
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
@ -648,6 +579,9 @@ class PublicApiController extends Controller
)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereNotIn('profile_id', $filtered)
->when($hideNsfw, function($q, $hideNsfw) {
return $q->where('is_nsfw', false);
})
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereNotNull('uri')
->whereScope('public')
@ -730,86 +664,6 @@ class PublicApiController extends Controller
return response()->json($res);
}
public function accountFollowers(Request $request, $id)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($id);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
if($pid != $account['id']) {
if($account['locked']) {
if(!FollowerService::follows($pid, $account['id'])) {
return [];
}
}
if(AccountService::hiddenFollowers($id)) {
return [];
}
if($request->has('page') && $request->page >= 5) {
return [];
}
}
$res = DB::table('followers')
->select('id', 'profile_id', 'following_id')
->whereFollowingId($account['id'])
->orderByDesc('id')
->simplePaginate(10)
->map(function($follower) {
return AccountService::get($follower->profile_id);
})
->filter(function($account) {
return $account && isset($account['id']);
})
->values()
->toArray();
return response()->json($res);
}
public function accountFollowing(Request $request, $id)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($id);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
if($pid != $account['id']) {
if($account['locked']) {
if(!FollowerService::follows($pid, $account['id'])) {
return [];
}
}
if(AccountService::hiddenFollowing($id)) {
return [];
}
if($request->has('page') && $request->page >= 5) {
return [];
}
}
$res = DB::table('followers')
->select('id', 'profile_id', 'following_id')
->whereProfileId($account['id'])
->orderByDesc('id')
->simplePaginate(10)
->map(function($follower) {
return AccountService::get($follower->following_id);
})
->filter(function($account) {
return $account && isset($account['id']);
})
->values()
->toArray();
return response()->json($res);
}
public function accountStatuses(Request $request, $id)
{
$this->validate($request, [

View file

@ -8,6 +8,7 @@ use App\Status;
use App\User;
use Auth;
use Illuminate\Http\Request;
use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
class ReportController extends Controller
{
@ -165,6 +166,10 @@ class ReportController extends Controller
$report->message = e($request->input('msg'));
$report->save();
if(config('instance.reports.email.enabled')) {
ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
}
if($request->wantsJson()) {
return response()->json(200);
} else {

View file

@ -17,11 +17,11 @@ use Mail;
use Purify;
use App\Mail\PasswordChange;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\PronounService;
trait HomeSettings
{
public function home()
{
$id = Auth::user()->profile->id;
@ -40,7 +40,7 @@ trait HomeSettings
public function homeUpdate(Request $request)
{
$this->validate($request, [
'name' => 'required|string|max:'.config('pixelfed.max_name_length'),
'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
'website' => 'nullable|url',
'language' => 'nullable|string|min:2|max:5',
@ -99,10 +99,10 @@ trait HomeSettings
}
if ($changes === true) {
Cache::forget('user:account:id:'.$user->id);
$user->save();
$profile->save();
Cache::forget('user:account:id:'.$user->id);
AccountService::del($profile->id);
return redirect('/settings/home')->with('status', 'Profile successfully updated!');
}

View file

@ -38,6 +38,10 @@ trait PrivacySettings
'show_profile_follower_count',
'show_profile_following_count',
];
$profile->is_suggestable = $request->input('is_suggestable') == 'on';
$profile->save();
foreach ($fields as $field) {
$form = $request->input($field);
if ($field == 'is_private') {

View file

@ -16,7 +16,7 @@ use Carbon\Carbon;
use Illuminate\Http\Request;
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
@ -56,13 +56,15 @@ trait SecuritySettings
$key,
500
);
$writer = new Writer(
new ImageRenderer(
new RendererStyle(400),
new ImagickImageBackEnd()
new SvgImageBackEnd()
)
);
$qrcode = base64_encode($writer->writeString($qrcode));
$qrcode = $writer->writeString($qrcode);
$user->{'2fa_secret'} = $key;
$user->{'2fa_backup_codes'} = json_encode($backups);
$user->save();
@ -162,4 +164,4 @@ trait SecuritySettings
'msg' => 'Successfully removed 2fa device'
], 200);
}
}
}

View file

@ -23,6 +23,7 @@ use App\Http\Controllers\Settings\{
};
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
use App\Services\AccountService;
class SettingsController extends Controller
{
@ -136,6 +137,8 @@ class SettingsController extends Controller
abort_if($user->is_admin, 403);
$profile = $user->profile;
$ts = Carbon::now()->addMonth();
$user->email = $user->id;
$user->password = '';
$user->status = 'delete';
$profile->status = 'delete';
$user->delete_after = $ts;
@ -143,8 +146,9 @@ class SettingsController extends Controller
$user->save();
$profile->save();
Cache::forget('profiles:private');
AccountService::del($profile->id);
Auth::logout();
DeleteAccountPipeline::dispatch($user)->onQueue('high');
DeleteAccountPipeline::dispatch($user)->onQueue('low');
return redirect('/');
}

View file

@ -219,13 +219,23 @@ class StatusController extends Controller
$u->save();
}
Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
Cache::forget('profile:status_count:' . $status->profile_id);
Cache::forget('profile:embed:' . $status->profile_id);
StatusService::del($status->id, true);
if ($status->profile_id == $user->profile->id || $user->is_admin == true) {
if($status->in_reply_to_id) {
$parent = Status::find($status->in_reply_to_id);
if($parent && ($parent->profile_id == $user->profile_id) || ($status->profile_id == $user->profile_id) || $user->is_admin) {
Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
Cache::forget('profile:status_count:' . $status->profile_id);
Cache::forget('profile:embed:' . $status->profile_id);
StatusService::del($status->id, true);
Cache::forget('profile:status_count:'.$status->profile_id);
StatusDelete::dispatch($status);
}
} else if ($status->profile_id == $user->profile_id || $user->is_admin == true) {
Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
Cache::forget('profile:status_count:' . $status->profile_id);
Cache::forget('profile:embed:' . $status->profile_id);
StatusService::del($status->id, true);
Cache::forget('profile:status_count:'.$status->profile_id);
StatusDelete::dispatchNow($status);
StatusDelete::dispatch($status);
}
if($request->wantsJson()) {

View file

@ -0,0 +1,360 @@
<?php
namespace App\Http\Controllers\Stories;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use App\Models\Conversation;
use App\DirectMessage;
use App\Notification;
use App\Story;
use App\Status;
use App\StoryView;
use App\Jobs\StoryPipeline\StoryDelete;
use App\Jobs\StoryPipeline\StoryFanout;
use App\Jobs\StoryPipeline\StoryReplyDeliver;
use App\Jobs\StoryPipeline\StoryViewDeliver;
use App\Services\AccountService;
use App\Services\MediaPathService;
use App\Services\StoryService;
class StoryApiV1Controller extends Controller
{
public function carousel(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$pid = $request->user()->profile_id;
if(config('database.default') == 'pgsql') {
$s = Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->get();
} else {
$s = Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->orderBy('id')
->get();
}
$nodes = $s->map(function($s) use($pid) {
$profile = AccountService::get($s->profile_id, true);
if(!$profile || !isset($profile['id'])) {
return false;
}
return [
'id' => (string) $s->id,
'pid' => (string) $s->profile_id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration ?? 3,
'seen' => StoryService::hasSeen($pid, $s->id),
'created_at' => $s->created_at->format('c')
];
})
->filter()
->groupBy('pid')
->map(function($item) use($pid) {
$profile = AccountService::get($item[0]['pid'], true);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}");
return [
'id' => 'pfs:' . $profile['id'],
'user' => [
'id' => (string) $profile['id'],
'username' => $profile['username'],
'username_acct' => $profile['acct'],
'avatar' => $profile['avatar'],
'local' => $profile['local'],
'is_author' => $profile['id'] == $pid
],
'nodes' => $item,
'url' => $url,
'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
];
})
->sortBy('seen')
->values();
$res = [
'self' => [],
'nodes' => $nodes,
];
if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
$selfStories = Story::whereProfileId($pid)
->whereActive(true)
->get()
->map(function($s) use($pid) {
return [
'id' => (string) $s->id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration,
'seen' => true,
'created_at' => $s->created_at->format('c')
];
})
->sortBy('id')
->values();
$selfProfile = AccountService::get($pid, true);
$res['self'] = [
'user' => [
'id' => (string) $selfProfile['id'],
'username' => $selfProfile['acct'],
'avatar' => $selfProfile['avatar'],
'local' => $selfProfile['local'],
'is_author' => true
],
'nodes' => $selfStories,
];
}
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function add(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'file' => function() {
return [
'required',
'mimetypes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
'duration' => 'sometimes|integer|min:0|max:30'
]);
$user = $request->user();
$count = Story::whereProfileId($user->profile_id)
->whereActive(true)
->where('expires_at', '>', now())
->count();
if($count >= Story::MAX_PER_DAY) {
abort(418, 'You have reached your limit for new Stories today.');
}
$photo = $request->file('file');
$path = $this->storeMedia($photo, $user);
$story = new Story();
$story->duration = $request->input('duration', 3);
$story->profile_id = $user->profile_id;
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->bearcap_token = str_random(64);
$story->expires_at = now()->addMinutes(1440);
$story->save();
$url = $story->path;
$res = [
'code' => 200,
'msg' => 'Successfully added',
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
return $res;
}
public function publish(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required',
'duration' => 'required|integer|min:0|max:30',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
$id = $request->input('media_id');
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = true;
$story->duration = $request->input('duration', 10);
$story->can_reply = $request->input('can_reply');
$story->can_react = $request->input('can_react');
$story->save();
StoryService::delLatest($story->profile_id);
StoryFanout::dispatch($story)->onQueue('story');
StoryService::addRotateQueue($story->id);
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function delete(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = false;
$story->save();
StoryDelete::dispatch($story)->onQueue('story');
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function viewed(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'id' => 'required|min:1',
]);
$id = $request->input('id');
$authed = $request->user()->profile;
$story = Story::with('profile')
->findOrFail($id);
$exp = $story->expires_at;
$profile = $story->profile;
if($story->profile_id == $authed->id) {
return [];
}
$publicOnly = (bool) $profile->followedBy($authed);
abort_if(!$publicOnly, 403);
$v = StoryView::firstOrCreate([
'story_id' => $id,
'profile_id' => $authed->id
]);
if($v->wasRecentlyCreated) {
Story::findOrFail($story->id)->increment('view_count');
if($story->local == false) {
StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
}
}
Cache::forget('stories:recent:by_id:' . $authed->id);
StoryService::addSeen($authed->id, $story->id);
return ['code' => 200];
}
public function comment(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'caption' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('caption');
$story = Story::findOrFail($request->input('sid'));
abort_if(!$story->can_reply, 422);
$status = new Status;
$status->type = 'story:reply';
$status->profile_id = $pid;
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:comment';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
Conversation::updateOrInsert(
[
'to_id' => $story->profile_id,
'from_id' => $pid
],
[
'type' => 'story:comment',
'status_id' => $status->id,
'dm_id' => $dm->id,
'is_hidden' => false
]
);
if($story->local) {
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$request->user()->username} commented on story";
$n->rendered = "{$request->user()->username} commented on story";
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
}
return [
'code' => 200,
'msg' => 'Sent!'
];
}
protected function storeMedia($photo, $user)
{
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
return $path;
}
}

View file

@ -111,7 +111,7 @@ class StoryComposeController extends Controller
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
$path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
$fpath = storage_path('app/' . $path);
$img = Intervention::make($fpath);

View file

@ -19,6 +19,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
];
/**
@ -42,7 +43,6 @@ class Kernel extends HttpKernel
'api' => [
'bindings',
\Fruitcake\Cors\HandleCors::class,
],
];

View file

@ -17,7 +17,7 @@ class EmailVerificationCheck
public function handle($request, Closure $next)
{
if ($request->user() &&
config_cache('pixelfed.enforce_email_verification') &&
config('pixelfed.enforce_email_verification') &&
is_null($request->user()->email_verified_at) &&
!$request->is(
'i/auth/*',

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class AdminHashtag extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'can_trend' => $this->can_trend === null ? true : (bool) $this->can_trend,
'can_search' => $this->can_search === null ? true : (bool) $this->can_search,
'is_nsfw' => (bool) $this->is_nsfw,
'is_banned' => (bool) $this->is_banned,
'cached_count' => $this->cached_count ?? 0,
'created_at' => $this->created_at
];
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class AdminInstance extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'domain' => $this->domain,
'software' => $this->software,
'unlisted' => (bool) $this->unlisted,
'auto_cw' => (bool) $this->auto_cw,
'banned' => (bool) $this->banned,
'user_count' => $this->user_count,
'status_count' => $this->status_count,
'last_crawled_at' => $this->last_crawled_at,
'notes' => $this->notes,
'base_domain' => $this->base_domain,
'ban_subdomains' => $this->ban_subdomains,
'actors_last_synced_at' => $this->actors_last_synced_at,
'created_at' => $this->created_at,
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminReport extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$res = [
'id' => $this->id,
'reporter' => AccountService::get($this->profile_id, true),
'type' => $this->type,
'object_id' => (string) $this->object_id,
'object_type' => $this->object_type,
'reported' => AccountService::get($this->reported_profile_id, true),
'status' => null,
'reporter_message' => $this->message,
'admin_seen_at' => $this->admin_seen,
'created_at' => $this->created_at,
];
if($this->object_id && $this->object_type === 'App\Status') {
$res['status'] = StatusService::get($this->object_id, false);
}
return $res;
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminSpamReport extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$res = [
'id' => $this->id,
'type' => $this->type,
'status' => null,
'read_at' => $this->read_at,
'created_at' => $this->created_at,
];
if($this->item_id && $this->item_type === 'App\Status') {
$res['status'] = StatusService::get($this->item_id, false);
}
return $res;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
class AdminUser extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$account = AccountService::get($this->profile_id, true);
$res = [
'id' => $this->id,
'profile_id' => $this->profile_id,
'name' => $this->name,
'username' => $this->username,
'is_admin' => (bool) $this->is_admin,
'email_verified_at' => $this->email_verified_at,
'two_factor_enabled' => (bool) $this->{'2fa_enabled'},
'register_source' => $this->register_source,
'last_active_at' => $this->last_active_at,
'created_at' => $this->created_at,
];
if($account) {
$res['avatar'] = $account['avatar'];
$res['bio'] = $account['note_text'];
$res['statuses_count'] = $account['statuses_count'];
$res['following_count'] = $account['following_count'];
$res['followers_count'] = $account['followers_count'];
$res['is_private'] = $account['locked'];
}
return $res;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Cache;
use App\Services\AccountService;
class DirectoryProfile extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$account = AccountService::get($this->id, true);
if(!$account) {
return [];
}
$url = url($this->username);
return [
'id' => $this->id,
'name' => $this->name,
'username' => $this->username,
'url' => $url,
'avatar' => $account['avatar'],
'following_count' => $account['following_count'],
'followers_count' => $account['followers_count'],
'statuses_count' => $account['statuses_count'],
'bio' => $account['note_text']
];
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Cache;
use App\Services\AccountService;
use App\Services\HashidService;
use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService;
use App\Services\StatusMentionService;
use App\Services\PollService;
use App\Models\CustomEmoji;
class StatusStateless extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$status = $this;
$taggedPeople = MediaTagService::get($status->id);
$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
return [
'_v' => 1,
'id' => (string) $status->id,
//'gid' => $status->group_id ? (string) $status->group_id : null,
'shortcode' => HashidService::encode($status->id),
'uri' => $status->url(),
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null,
'in_reply_to_account_id' => $status->in_reply_to_profile_id ? (string) $status->in_reply_to_profile_id : null,
'reblog' => null,
'content' => $status->rendered ?? $status->caption,
'content_text' => $status->caption,
'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)),
'emojis' => CustomEmoji::scan($status->caption),
'reblogs_count' => $status->reblogs_count ?? 0,
'favourites_count' => $status->likes_count ?? 0,
'reblogged' => null,
'favourited' => null,
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->scope ?? $status->visibility,
'application' => [
'name' => 'web',
'website' => null
],
'language' => null,
'mentions' => StatusMentionService::get($status->id),
'pf_type' => $status->type ?? $status->setType(),
'reply_count' => (int) $status->reply_count,
'comments_disabled' => (bool) $status->comments_disabled,
'thread' => false,
'replies' => [],
'parent' => [],
'place' => $status->place,
'local' => (bool) $status->local,
'taggedPeople' => $taggedPeople,
'liked_by' => LikeService::likedBy($status),
'media_attachments' => MediaService::get($status->id),
'account' => AccountService::get($status->profile_id, true),
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll
];
}
}

View file

@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Instance extends Model
{
protected $fillable = ['domain'];
protected $fillable = ['domain', 'banned', 'auto_cw', 'unlisted', 'notes'];
public function profiles()
{

View file

@ -13,6 +13,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Image as Intervention;
use Storage;
class AvatarOptimize implements ShouldQueue
{
@ -63,6 +64,13 @@ class AvatarOptimize implements ShouldQueue
$avatar->save();
Cache::forget('avatar:' . $avatar->profile_id);
$this->deleteOldAvatar($avatar->media_path, $this->current);
if(config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
$this->uploadToCloud($avatar);
} else {
$avatar->cdn_url = null;
$avatar->save();
}
} catch (Exception $e) {
}
}
@ -79,4 +87,18 @@ class AvatarOptimize implements ShouldQueue
@unlink($current);
}
}
protected function uploadToCloud($avatar)
{
$base = 'cache/avatars/' . $avatar->profile_id;
$disk = Storage::disk(config('filesystems.cloud'));
$disk->deleteDirectory($base);
$path = $base . '/' . 'avatar_' . strtolower(Str::random(random_int(3,6))) . $avatar->change_count . '.' . pathinfo($avatar->media_path, PATHINFO_EXTENSION);
$url = $disk->put($path, Storage::get($avatar->media_path));
$avatar->media_path = $path;
$avatar->cdn_url = $disk->url($path);
$avatar->save();
Storage::delete($avatar->media_path);
Cache::forget('avatar:' . $avatar->profile_id);
}
}

View file

@ -38,6 +38,8 @@ class RemoteAvatarFetch implements ShouldQueue
* @var int
*/
public $tries = 1;
public $timeout = 300;
public $maxExceptions = 1;
/**
* Create a new job instance.

View file

@ -60,13 +60,21 @@ class CommentPipeline implements ShouldQueue
$actor = $comment->profile;
if(config('database.default') === 'mysql') {
DB::transaction(function() use($status) {
$count = DB::select( DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)"), [ 'kid' => $status->id]);
$status->reply_count = count($count);
$status->save();
});
$exp = DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)");
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$count = DB::select($expQuery, [ 'kid' => $status->id ]);
$status->reply_count = count($count);
$status->save();
} else {
$status->reply_count = $status->reply_count + 1;
$status->save();
}
StatusService::del($comment->id);
StatusService::del($status->id);
Cache::forget('status:replies:all:' . $comment->id);
Cache::forget('status:replies:all:' . $status->id);
if ($actor->id === $target->id || $status->comments_disabled == true) {
return true;
}

View file

@ -11,6 +11,7 @@ use DB;
use Storage;
use Illuminate\Support\Str;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\PublicTimelineService;
use App\{
AccountInterstitial,
@ -49,7 +50,12 @@ use App\{
UserFilter,
UserSetting,
};
use App\Models\Conversation;
use App\Models\Poll;
use App\Models\PollVote;
use App\Models\Portfolio;
use App\Models\UserPronoun;
use App\Jobs\StatusPipeline\StatusDelete;
class DeleteAccountPipeline implements ShouldQueue
{
@ -57,6 +63,11 @@ class DeleteAccountPipeline implements ShouldQueue
protected $user;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $deleteWhenMissingModels = true;
public function __construct(User $user)
{
$this->user = $user;
@ -65,144 +76,107 @@ class DeleteAccountPipeline implements ShouldQueue
public function handle()
{
$user = $this->user;
$this->deleteUserColumns($user);
AccountService::del($user->profile_id);
$profile = $user->profile;
$id = $user->profile_id;
Status::whereProfileId($id)->chunk(50, function($statuses) {
foreach($statuses as $status) {
StatusDelete::dispatch($status);
}
});
DB::transaction(function() use ($user) {
AccountLog::whereItemType('App\User')->whereItemId($user->id)->forceDelete();
});
AccountLog::whereItemType('App\User')->whereItemId($user->id)->forceDelete();
DB::transaction(function() use ($user) {
AccountInterstitial::whereUserId($user->id)->delete();
});
AccountInterstitial::whereUserId($user->id)->delete();
DB::transaction(function() use ($user) {
if($user->profile) {
$avatar = $user->profile->avatar;
$path = $avatar->media_path;
if(!in_array($path, [
'public/avatars/default.jpg',
'public/avatars/default.png'
])) {
if(config('pixelfed.cloud_storage')) {
$disk = Storage::disk(config('filesystems.cloud'));
$disk->delete($path);
}
$disk = Storage::disk(config('filesystems.local'));
$disk->delete($path);
}
// Delete Avatar
$profile->avatar->forceDelete();
$avatar->forceDelete();
}
// Delete Poll Votes
PollVote::whereProfileId($id)->delete();
$id = $user->profile_id;
// Delete Polls
Poll::whereProfileId($id)->delete();
ImportData::whereProfileId($id)
->cursor()
->each(function($data) {
$path = storage_path('app/'.$data->path);
if(is_file($path)) {
unlink($path);
}
$data->delete();
});
ImportJob::whereProfileId($id)
->cursor()
->each(function($data) {
$path = storage_path('app/'.$data->media_json);
if(is_file($path)) {
unlink($path);
}
$data->delete();
});
MediaTag::whereProfileId($id)->delete();
Bookmark::whereProfileId($id)->forceDelete();
EmailVerification::whereUserId($user->id)->forceDelete();
StatusHashtag::whereProfileId($id)->delete();
DirectMessage::whereFromId($id)->orWhere('to_id', $id)->delete();
StatusArchived::whereProfileId($id)->delete();
UserPronoun::whereProfileId($id)->delete();
FollowRequest::whereFollowingId($id)
->orWhere('follower_id', $id)
->forceDelete();
Follower::whereProfileId($id)
->orWhere('following_id', $id)
->forceDelete();
Like::whereProfileId($id)->forceDelete();
});
// Delete Portfolio
Portfolio::whereProfileId($id)->delete();
DB::transaction(function() use ($user) {
$pid = $this->user->profile_id;
StoryView::whereProfileId($pid)->delete();
$stories = Story::whereProfileId($pid)->get();
foreach($stories as $story) {
$path = storage_path('app/'.$story->path);
ImportData::whereProfileId($id)
->cursor()
->each(function($data) {
$path = storage_path('app/'.$data->path);
if(is_file($path)) {
unlink($path);
}
$story->forceDelete();
}
$data->delete();
});
DB::transaction(function() use ($user) {
$medias = Media::whereUserId($user->id)->get();
foreach($medias as $media) {
if(config('pixelfed.cloud_storage')) {
$disk = Storage::disk(config('filesystems.cloud'));
$disk->delete($media->media_path);
$disk->delete($media->thumbnail_path);
ImportJob::whereProfileId($id)
->cursor()
->each(function($data) {
$path = storage_path('app/'.$data->media_json);
if(is_file($path)) {
unlink($path);
}
$disk = Storage::disk(config('filesystems.local'));
$disk->delete($media->media_path);
$disk->delete($media->thumbnail_path);
$media->forceDelete();
$data->delete();
});
MediaTag::whereProfileId($id)->delete();
Bookmark::whereProfileId($id)->forceDelete();
EmailVerification::whereUserId($user->id)->forceDelete();
StatusHashtag::whereProfileId($id)->delete();
DirectMessage::whereFromId($id)->orWhere('to_id', $id)->delete();
Conversation::whereFromId($id)->orWhere('to_id', $id)->delete();
StatusArchived::whereProfileId($id)->delete();
UserPronoun::whereProfileId($id)->delete();
FollowRequest::whereFollowingId($id)
->orWhere('follower_id', $id)
->forceDelete();
Follower::whereProfileId($id)
->orWhere('following_id', $id)
->each(function($follow) {
FollowerService::remove($follow->profile_id, $follow->following_id);
$follow->delete();
});
FollowerService::delCache($id);
Like::whereProfileId($id)->forceDelete();
Mention::whereProfileId($id)->forceDelete();
StoryView::whereProfileId($id)->delete();
$stories = Story::whereProfileId($id)->get();
foreach($stories as $story) {
$path = storage_path('app/'.$story->path);
if(is_file($path)) {
unlink($path);
}
});
$story->forceDelete();
}
DB::transaction(function() use ($user) {
Mention::whereProfileId($user->profile_id)->forceDelete();
Notification::whereProfileId($user->profile_id)
->orWhere('actor_id', $user->profile_id)
->forceDelete();
});
UserDevice::whereUserId($user->id)->forceDelete();
UserFilter::whereUserId($user->id)->forceDelete();
UserSetting::whereUserId($user->id)->forceDelete();
DB::transaction(function() use ($user) {
$collections = Collection::whereProfileId($user->profile_id)->get();
foreach ($collections as $collection) {
$collection->items()->delete();
$collection->delete();
}
Contact::whereUserId($user->id)->delete();
HashtagFollow::whereUserId($user->id)->delete();
OauthClient::whereUserId($user->id)->delete();
DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
ProfileSponsor::whereProfileId($user->profile_id)->delete();
});
Mention::whereProfileId($id)->forceDelete();
Notification::whereProfileId($id)
->orWhere('actor_id', $id)
->forceDelete();
DB::transaction(function() use ($user) {
Status::whereProfileId($user->profile_id)->forceDelete();
Report::whereUserId($user->id)->forceDelete();
PublicTimelineService::warmCache(true, 400);
$this->deleteProfile($user);
});
}
$collections = Collection::whereProfileId($id)->get();
foreach ($collections as $collection) {
$collection->items()->delete();
$collection->delete();
}
Contact::whereUserId($user->id)->delete();
HashtagFollow::whereUserId($user->id)->delete();
OauthClient::whereUserId($user->id)->delete();
DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
ProfileSponsor::whereProfileId($id)->delete();
protected function deleteProfile($user) {
DB::transaction(function() use ($user) {
Profile::whereUserId($user->id)->delete();
$this->deleteUserSettings($user);
});
}
protected function deleteUserSettings($user) {
DB::transaction(function() use ($user) {
UserDevice::whereUserId($user->id)->forceDelete();
UserFilter::whereUserId($user->id)->forceDelete();
UserSetting::whereUserId($user->id)->forceDelete();
});
Report::whereUserId($user->id)->forceDelete();
PublicTimelineService::warmCache(true, 400);
$this->deleteUserColumns($user);
AccountService::del($user->profile_id);
Profile::whereUserId($user->id)->delete();
}
protected function deleteUserColumns($user)

View file

@ -39,6 +39,7 @@ use App\{
ReportLog,
StatusHashtag,
Status,
StatusView,
Story,
StoryView,
User,
@ -46,6 +47,10 @@ use App\{
UserFilter,
UserSetting,
};
use App\Models\Conversation;
use App\Models\Poll;
use App\Models\PollVote;
use App\Services\AccountService;
class DeleteRemoteProfilePipeline implements ShouldQueue
{
@ -53,6 +58,11 @@ class DeleteRemoteProfilePipeline implements ShouldQueue
protected $profile;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $deleteWhenMissingModels = true;
public function __construct(Profile $profile)
{
$this->profile = $profile;
@ -61,80 +71,85 @@ class DeleteRemoteProfilePipeline implements ShouldQueue
public function handle()
{
$profile = $this->profile;
$pid = $profile->id;
if($profile->domain == null || $profile->private_key) {
return;
}
DB::transaction(function() use ($profile) {
$profile->avatar->forceDelete();
$profile->status = 'delete';
$profile->save();
$id = $profile->id;
AccountService::del($pid);
MediaTag::whereProfileId($id)->delete();
StatusHashtag::whereProfileId($id)->delete();
DirectMessage::whereFromId($id)->delete();
FollowRequest::whereFollowingId($id)
->orWhere('follower_id', $id)
->forceDelete();
Follower::whereProfileId($id)
->orWhere('following_id', $id)
->forceDelete();
Like::whereProfileId($id)->forceDelete();
// Delete statuses
Status::whereProfileId($pid)
->chunk(50, function($statuses) {
foreach($statuses as $status) {
DeleteRemoteStatusPipeline::dispatch($status)->onQueue('delete');
}
});
DB::transaction(function() use ($profile) {
$pid = $profile->id;
StoryView::whereProfileId($pid)->delete();
$stories = Story::whereProfileId($pid)->get();
foreach($stories as $story) {
$path = storage_path('app/'.$story->path);
if(is_file($path)) {
unlink($path);
}
$story->forceDelete();
// Delete Poll Votes
PollVote::whereProfileId($pid)->delete();
// Delete Polls
Poll::whereProfileId($pid)->delete();
// Delete Avatar
$profile->avatar->forceDelete();
// Delete media tags
MediaTag::whereProfileId($pid)->delete();
// Delete DMs
DirectMessage::whereFromId($pid)->orWhere('to_id', $pid)->delete();
Conversation::whereFromId($pid)->orWhere('to_id', $pid)->delete();
// Delete FollowRequests
FollowRequest::whereFollowingId($pid)
->orWhere('follower_id', $pid)
->delete();
// Delete relationships
Follower::whereProfileId($pid)
->orWhere('following_id', $pid)
->delete();
// Delete likes
Like::whereProfileId($pid)->forceDelete();
// Delete Story Views + Stories
StoryView::whereProfileId($pid)->delete();
$stories = Story::whereProfileId($pid)->get();
foreach($stories as $story) {
$path = storage_path('app/'.$story->path);
if(is_file($path)) {
unlink($path);
}
});
$story->forceDelete();
}
DB::transaction(function() use ($profile) {
$medias = Media::whereProfileId($profile->id)->get();
foreach($medias as $media) {
$path = storage_path('app/'.$media->media_path);
$thumb = storage_path('app/'.$media->thumbnail_path);
if(is_file($path)) {
unlink($path);
// Delete mutes/blocks
UserFilter::whereFilterableType('App\Profile')->whereFilterableId($pid)->delete();
// Delete mentions
Mention::whereProfileId($pid)->forceDelete();
// Delete notifications
Notification::whereProfileId($pid)
->orWhere('actor_id', $pid)
->chunk(50, function($notifications) {
foreach($notifications as $n) {
$n->forceDelete();
}
if(is_file($thumb)) {
unlink($thumb);
}
$media->forceDelete();
}
});
});
DB::transaction(function() use ($profile) {
Mention::whereProfileId($profile->id)->forceDelete();
Notification::whereProfileId($profile->id)
->orWhere('actor_id', $profile->id)
->forceDelete();
});
// Delete reports
Report::whereProfileId($profile->id)->orWhere('reported_profile_id')->forceDelete();
DB::transaction(function() use ($profile) {
Status::whereProfileId($profile->id)
->cursor()
->each(function($status) {
AccountInterstitial::where('item_type', 'App\Status')
->where('item_id', $status->id)
->delete();
$status->forceDelete();
});
Report::whereProfileId($profile->id)->forceDelete();
$this->deleteProfile($profile);
});
}
protected function deleteProfile($profile) {
DB::transaction(function() use ($profile) {
Profile::findOrFail($profile->id)->delete();
});
// Delete profile
Profile::findOrFail($profile->id)->delete();
return 1;
}
}

View file

@ -19,10 +19,12 @@ use App\Status;
use App\StatusHashtag;
use App\StatusView;
use App\Notification;
use App\Services\AccountService;
use App\Services\NetworkTimelineService;
use App\Services\StatusService;
use App\Jobs\ProfilePipeline\DecrementPostCount;
use App\Jobs\MediaPipeline\MediaDeletePipeline;
use Cache;
class DeleteRemoteStatusPipeline implements ShouldQueue
{
@ -30,6 +32,11 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
protected $status;
public $timeout = 30;
public $tries = 2;
public $maxExceptions = 1;
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
@ -37,7 +44,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
*/
public function __construct(Status $status)
{
$this->status = $status->withoutRelations();
$this->status = $status;
}
/**
@ -49,9 +56,12 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
{
$status = $this->status;
if(AccountService::get($status->profile_id, true)) {
DecrementPostCount::dispatch($status->profile_id)->onQueue('feed');
}
NetworkTimelineService::del($status->id);
StatusService::del($status->id, true);
DecrementPostCount::dispatchNow($status->profile_id);
Bookmark::whereStatusId($status->id)->delete();
Notification::whereItemType('App\Status')
->whereItemId($status->id)
@ -62,13 +72,14 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
Media::whereStatusId($status->id)
->get()
->each(function($media) {
MediaDeletePipeline::dispatchNow($media);
MediaDeletePipeline::dispatch($media)->onQueue('mmo');
});
Mention::whereStatusId($status->id)->forceDelete();
Report::whereObjectType('App\Status')->whereObjectId($status->id)->delete();
StatusHashtag::whereStatusId($status->id)->delete();
StatusView::whereStatusId($status->id)->delete();
Status::whereReblogOfId($status->id)->forceDelete();
$status->delete();
$status->forceDelete();
return 1;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Jobs\FollowPipeline;
use App\Follower;
use App\Notification;
use Cache;
use Illuminate\Bus\Queueable;
@ -11,6 +12,8 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Log;
use Illuminate\Support\Facades\Redis;
use App\Services\AccountService;
use App\Services\FollowerService;
class FollowPipeline implements ShouldQueue
{
@ -46,9 +49,45 @@ class FollowPipeline implements ShouldQueue
$actor = $follower->actor;
$target = $follower->target;
if(!$actor || !$target) {
return;
}
Cache::forget('profile:following:' . $actor->id);
Cache::forget('profile:following:' . $target->id);
FollowerService::add($actor->id, $target->id);
$actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY . $actor->id);
if(!$actorProfileSync) {
FollowServiceWarmCache::dispatch($actor->id)->onQueue('low');
} else {
if($actor->following_count) {
$actor->increment('following_count');
} else {
$count = Follower::whereProfileId($actor->id)->count();
$actor->following_count = $count;
$actor->save();
}
Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $actor->id, 1, 604800);
AccountService::del($actor->id);
}
$targetProfileSync = Cache::get(FollowerService::FOLLOWERS_SYNC_KEY . $target->id);
if(!$targetProfileSync) {
FollowServiceWarmCache::dispatch($target->id)->onQueue('low');
} else {
if($target->followers_count) {
$target->increment('followers_count');
} else {
$count = Follower::whereFollowingId($target->id)->count();
$target->followers_count = $count;
$target->save();
}
Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $target->id, 1, 604800);
AccountService::del($target->id);
}
if($target->domain || !$target->private_key) {
return;
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Jobs\FollowPipeline;
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\Services\AccountService;
use App\Services\FollowerService;
use Cache;
use DB;
use App\Profile;
class FollowServiceWarmCache implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $profileId;
public $tries = 5;
public $timeout = 5000;
public $failOnTimeout = false;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($profileId)
{
$this->profileId = $profileId;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$id = $this->profileId;
$account = AccountService::get($id, true);
if(!$account) {
Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $id, 1, 604800);
Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $id, 1, 604800);
return;
}
DB::table('followers')
->select('id', 'following_id', 'profile_id')
->whereFollowingId($id)
->orderBy('id')
->chunk(200, function($followers) use($id) {
foreach($followers as $follow) {
FollowerService::add($follow->profile_id, $id);
}
});
DB::table('followers')
->select('id', 'following_id', 'profile_id')
->whereProfileId($id)
->orderBy('id')
->chunk(200, function($followers) use($id) {
foreach($followers as $follow) {
FollowerService::add($id, $follow->following_id);
}
});
Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $id, 1, 604800);
Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $id, 1, 604800);
$profile = Profile::find($id);
if($profile) {
$profile->following_count = DB::table('followers')->whereProfileId($id)->count();
$profile->followers_count = DB::table('followers')->whereFollowingId($id)->count();
$profile->save();
}
AccountService::del($id);
return;
}
}

View file

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

View file

@ -39,16 +39,18 @@ class ImageOptimize implements ShouldQueue
*/
public function handle()
{
if(config('pixelfed.optimize_image') == false) {
return;
}
$media = $this->media;
$path = storage_path('app/'.$media->media_path);
if (!is_file($path) || $media->skip_optimize) {
return;
}
ImageResize::dispatch($media);
if(config('pixelfed.optimize_image') == false) {
ImageThumbnail::dispatch($media)->onQueue('mmo');
return;
} else {
ImageResize::dispatch($media)->onQueue('mmo');
return;
}
}
}

View file

@ -49,12 +49,16 @@ class ImageResize implements ShouldQueue
return;
}
if(!config('pixelfed.optimize_image')) {
ImageThumbnail::dispatch($media)->onQueue('mmo');
return;
}
try {
$img = new Image();
$img->resizeImage($media);
} catch (Exception $e) {
}
ImageThumbnail::dispatch($media);
ImageThumbnail::dispatch($media)->onQueue('mmo');
}
}

View file

@ -59,6 +59,6 @@ class ImageThumbnail implements ShouldQueue
$media->processed_at = Carbon::now();
$media->save();
ImageUpdate::dispatch($media);
ImageUpdate::dispatch($media)->onQueue('mmo');
}
}

View file

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

View file

@ -2,32 +2,35 @@
namespace App\Jobs\InboxPipeline;
use App\Util\ActivityPub\Inbox;
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\Util\ActivityPub\Inbox;
class SharedInboxWorker implements ShouldQueue
class ActivityHandler implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $request;
protected $profile;
protected $username;
protected $headers;
protected $payload;
public $timeout = 60;
public $timeout = 300;
public $tries = 1;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($request, $payload)
public function __construct($headers, $username, $payload)
{
$this->request = $request;
$this->username = $username;
$this->headers = $headers;
$this->payload = $payload;
}
@ -38,6 +41,7 @@ class SharedInboxWorker implements ShouldQueue
*/
public function handle()
{
(new Inbox($this->request, null, $this->payload))->handleSharedInbox();
(new Inbox($this->headers, $this->username, $this->payload))->handle();
return;
}
}

View file

@ -14,8 +14,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Zttp\Zttp;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
class DeleteWorker implements ShouldQueue
{
@ -24,8 +25,8 @@ class DeleteWorker implements ShouldQueue
protected $headers;
protected $payload;
public $timeout = 120;
public $tries = 3;
public $timeout = 300;
public $tries = 1;
public $maxExceptions = 1;
/**
@ -68,50 +69,43 @@ class DeleteWorker implements ShouldQueue
))
) {
$actor = $payload['actor'];
$hash = strlen($actor) <= 48 ?
'b:' . base64_encode($actor) :
'h:' . hash('sha256', $actor);
$key = 'ap:inbox:actor-delete-exists:' . $hash;
$actorDelete = Cache::remember($key, now()->addMinutes(15), function() use($actor) {
return Profile::whereRemoteUrl($actor)
->whereNotNull('domain')
->exists();
});
if($actorDelete) {
if($this->verifySignature($headers, $payload) == true) {
Cache::set($key, false);
$profile = Profile::whereNotNull('domain')
->whereNull('status')
->whereRemoteUrl($actor)
->first();
if($profile) {
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('delete');
if($this->verifySignature($headers, $payload) == true) {
$actorDelete = Profile::whereRemoteUrl($actor)->exists();
if($actorDelete) {
if($this->verifySignature($headers, $payload) == true) {
Cache::set($key, false);
$profile = Profile::whereNotNull('domain')
->whereNull('status')
->whereRemoteUrl($actor)
->first();
if($profile) {
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('inbox');
}
return 1;
} else {
// Signature verification failed, exit.
return 1;
}
return 1;
} else {
// Signature verification failed, exit.
// Remote user doesn't exist, exit early.
return 1;
}
return 1;
} else {
// Remote user doesn't exist, exit early.
return 1;
}
return 1;
}
$profile = null;
if($this->verifySignature($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return 1;
} else if($this->blindKeyRotation($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return 1;
} else {
return 1;
$profile = null;
if($this->verifySignature($headers, $payload) == true) {
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('delete');
return 1;
} else {
return 1;
}
}
}
protected function verifySignature($headers, $payload)
@ -121,17 +115,22 @@ class DeleteWorker implements ShouldQueue
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
return false;
}
if(!$date) {
return;
return false;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
return false;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return false;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
@ -140,20 +139,20 @@ class DeleteWorker implements ShouldQueue
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return;
}
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return false;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return;
return false;
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
@ -161,11 +160,11 @@ class DeleteWorker implements ShouldQueue
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return;
return false;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
return false;
}
$inboxPath = "/f/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
@ -192,6 +191,11 @@ class DeleteWorker implements ShouldQueue
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
@ -200,10 +204,20 @@ class DeleteWorker implements ShouldQueue
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
$res = Zttp::timeout(60)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
try {
$res = Http::timeout(20)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
} catch (ConnectionException $e) {
return false;
}
if(!$res->ok()) {
return false;
}
$res = json_decode($res->body(), true, 8);
if(!isset($res['publicKey'], $res['publicKey']['id'])) {
return;

View file

@ -5,190 +5,215 @@ namespace App\Jobs\InboxPipeline;
use Cache;
use App\Profile;
use App\Util\ActivityPub\{
Helpers,
HttpSignature,
Inbox
Helpers,
HttpSignature,
Inbox
};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Zttp\Zttp;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Lottery;
class InboxValidator implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $username;
protected $headers;
protected $payload;
protected $username;
protected $headers;
protected $payload;
public $timeout = 60;
public $tries = 1;
public $timeout = 300;
public $tries = 1;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($username, $headers, $payload)
{
$this->username = $username;
$this->headers = $headers;
$this->payload = $payload;
}
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($username, $headers, $payload)
{
$this->username = $username;
$this->headers = $headers;
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$username = $this->username;
$headers = $this->headers;
$payload = json_decode($this->payload, true, 8);
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$username = $this->username;
$headers = $this->headers;
$profile = Profile::whereNull('domain')->whereUsername($username)->first();
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
return;
}
if(isset($payload['id'])) {
$lockKey = hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 3600);
}
$payload = json_decode($this->payload, true, 8);
if(!isset($headers['signature']) || !isset($headers['date'])) {
return;
}
if(isset($payload['id'])) {
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 3600);
}
if(empty($profile) || empty($headers) || empty($payload)) {
return;
}
$profile = Profile::whereNull('domain')->whereUsername($username)->first();
if($profile->status != null) {
return;
}
if(empty($profile) || empty($headers) || empty($payload)) {
return;
}
if($this->verifySignature($headers, $profile, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else if($this->blindKeyRotation($headers, $profile, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else {
return;
}
if($profile->status != null) {
return;
}
}
if($this->verifySignature($headers, $profile, $payload) == true) {
if(isset($payload['type']) && in_array($payload['type'], ['Follow', 'Accept']) ) {
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('follow');
} else {
$onQueue = Lottery::odds(1, 12)->winner(fn () => 'high')->loser(fn () => 'inbox')->choose();
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue($onQueue);
}
return;
} else {
return;
}
protected function verifySignature($headers, $profile, $payload)
{
$body = $this->payload;
$bodyDecoded = $payload;
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
if(!isset($bodyDecoded['id'])) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
$idDomain = parse_url($id, PHP_URL_HOST);
if(isset($bodyDecoded['object'])
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return;
abort(400, 'Invalid request');
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
}
$inboxPath = "/users/{$profile->username}/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) {
return true;
} else {
return false;
}
}
}
protected function blindKeyRotation($headers, $profile, $payload)
{
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
return;
}
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
$res = Zttp::timeout(60)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
$res = json_decode($res->body(), true, 8);
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
return;
}
if($res['publicKey']['id'] !== $actor->key_id) {
return;
}
$actor->public_key = $res['publicKey']['publicKeyPem'];
$actor->save();
return $this->verifySignature($headers, $profile, $payload);
}
protected function verifySignature($headers, $profile, $payload)
{
$body = $this->payload;
$bodyDecoded = $payload;
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return false;
}
if(!$date) {
return false;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return false;
}
if(!isset($bodyDecoded['id'])) {
return false;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return false;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
$idDomain = parse_url($id, PHP_URL_HOST);
if(isset($bodyDecoded['object'])
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return false;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return false;
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return false;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return false;
}
$inboxPath = "/users/{$profile->username}/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) {
return true;
} else {
return false;
}
}
protected function blindKeyRotation($headers, $profile, $payload)
{
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
return;
}
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
try {
$res = Http::timeout(20)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
} catch (ConnectionException $e) {
return false;
}
if(!$res->ok()) {
return false;
}
$res = json_decode($res->body(), true, 8);
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
return;
}
if($res['publicKey']['id'] !== $actor->key_id) {
return;
}
$actor->public_key = $res['publicKey']['publicKeyPem'];
$actor->save();
return $this->verifySignature($headers, $profile, $payload);
}
}

View file

@ -5,180 +5,195 @@ namespace App\Jobs\InboxPipeline;
use Cache;
use App\Profile;
use App\Util\ActivityPub\{
Helpers,
HttpSignature,
Inbox
Helpers,
HttpSignature,
Inbox
};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Zttp\Zttp;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
class InboxWorker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $headers;
protected $payload;
protected $headers;
protected $payload;
public $timeout = 60;
public $tries = 1;
public $timeout = 300;
public $tries = 1;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($headers, $payload)
{
$this->headers = $headers;
$this->payload = $payload;
}
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($headers, $payload)
{
$this->headers = $headers;
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = null;
$headers = $this->headers;
$payload = json_decode($this->payload, true, 8);
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = null;
$headers = $this->headers;
if(isset($payload['id'])) {
$lockKey = hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 3600);
}
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
return;
}
if(!isset($headers['signature']) || !isset($headers['date'])) {
return;
}
$payload = json_decode($this->payload, true, 8);
if(empty($headers) || empty($payload)) {
return;
}
if(isset($payload['id'])) {
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 3600);
}
if($this->verifySignature($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else if($this->blindKeyRotation($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else {
return;
}
}
if($this->verifySignature($headers, $payload) == true) {
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('shared');
return;
} else {
return;
}
}
protected function verifySignature($headers, $payload)
{
$body = $this->payload;
$bodyDecoded = $payload;
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
if(!isset($bodyDecoded['id'])) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
$idDomain = parse_url($id, PHP_URL_HOST);
if(isset($bodyDecoded['object'])
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return;
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
}
$inboxPath = "/f/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) {
return true;
} else {
return false;
}
}
protected function verifySignature($headers, $payload)
{
$body = $this->payload;
$bodyDecoded = $payload;
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return false;
}
if(!$date) {
return false;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return false;
}
if(!isset($bodyDecoded['id'])) {
return false;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
protected function blindKeyRotation($headers, $payload)
{
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
return;
}
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
$res = Zttp::timeout(60)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
$res = json_decode($res->body(), true, 8);
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
return;
}
if($res['publicKey']['id'] !== $actor->key_id) {
return;
}
$actor->public_key = $res['publicKey']['publicKeyPem'];
$actor->save();
return $this->verifySignature($headers, $payload);
}
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return false;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
$idDomain = parse_url($id, PHP_URL_HOST);
if(isset($bodyDecoded['object'])
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
if(is_array($attr)) {
if(isset($attr['id'])) {
$attr = $attr['id'];
} else {
$attr = "";
}
}
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
return false;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return false;
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return false;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return false;
}
$inboxPath = "/f/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) {
return true;
} else {
return false;
}
}
protected function blindKeyRotation($headers, $payload)
{
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
return;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
return;
}
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
try {
$res = Http::timeout(20)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
} catch (ConnectionException $e) {
return false;
}
if(!$res->ok()) {
return false;
}
$res = json_decode($res->body(), true, 8);
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
return;
}
if($res['publicKey']['id'] !== $actor->key_id) {
return;
}
$actor->public_key = $res['publicKey']['publicKeyPem'];
$actor->save();
return $this->verifySignature($headers, $payload);
}
}

View file

@ -17,6 +17,11 @@ class MediaDeletePipeline implements ShouldQueue
protected $media;
public $timeout = 300;
public $tries = 3;
public $maxExceptions = 1;
public $deleteWhenMissingModels = true;
public function __construct(Media $media)
{
$this->media = $media;
@ -39,33 +44,28 @@ class MediaDeletePipeline implements ShouldQueue
if(config_cache('pixelfed.cloud_storage') == true) {
$disk = Storage::disk(config('filesystems.cloud'));
if($path) {
if($path && $disk->exists($path)) {
$disk->delete($path);
}
if($thumb) {
if($thumb && $disk->exists($thumb)) {
$disk->delete($thumb);
}
if(count($e) > 4 && count($disk->files($i)) == 0) {
$disk->deleteDirectory($i);
}
}
$disk = Storage::disk(config('filesystems.local'));
if($path && $disk->exists($path)) {
$disk->delete($path);
}
if($thumb && $disk->exists($thumb)) {
$disk->delete($thumb);
}
if(count($e) > 4 && count($disk->files($i)) == 0) {
$disk->deleteDirectory($i);
}
$media->forceDelete();
$media->delete();
return;
return 1;
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Jobs\MediaPipeline;
use App\Media;
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\Storage;
class MediaFixLocalFilesystemCleanupPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1800;
public $tries = 5;
public $maxExceptions = 1;
public function handle()
{
if(config_cache('pixelfed.cloud_storage') == false) {
// Only run if cloud storage is enabled
return;
}
$disk = Storage::disk('local');
$cloud = Storage::disk(config('filesystems.cloud'));
Media::whereNotNull(['status_id', 'cdn_url', 'replicated_at'])
->chunk(20, function ($medias) use($disk, $cloud) {
foreach($medias as $media) {
if(!str_starts_with($media->media_path, 'public')) {
continue;
}
if($disk->exists($media->media_path) && $cloud->exists($media->media_path)) {
$disk->delete($media->media_path);
}
if($media->thumbnail_path) {
if($disk->exists($media->thumbnail_path)) {
$disk->delete($media->thumbnail_path);
}
}
$paths = explode('/', $media->media_path);
if(count($paths) === 7) {
array_pop($paths);
$baseDir = implode('/', $paths);
if(count($disk->allFiles($baseDir)) === 0) {
$disk->deleteDirectory($baseDir);
array_pop($paths);
$baseDir = implode('/', $paths);
if(count($disk->allFiles($baseDir)) === 0) {
$disk->deleteDirectory($baseDir);
array_pop($paths);
$baseDir = implode('/', $paths);
if(count($disk->allFiles($baseDir)) === 0) {
$disk->deleteDirectory($baseDir);
}
}
}
}
}
});
}
}

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