Merge pull request #1 from pixelfed/dev

traer cambios
This commit is contained in:
Xose M 2020-11-21 18:16:37 +01:00 committed by GitHub
commit 3a3afdc13c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
1351 changed files with 873177 additions and 27379 deletions

55
.circleci/config.yml Normal file
View file

@ -0,0 +1,55 @@
# PHP CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-php/ for more details
#
version: 2
jobs:
build:
docker:
# Specify the version you desire here
- image: circleci/php:7.3-cli-stretch-node
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
# Using the RAM variation mitigates I/O contention
# for database intensive operations.
# - image: circleci/mysql:5.7-ram
#
# - image: redis:2.8.19
steps:
- checkout
- run: sudo apt update && sudo apt install zlib1g-dev libsqlite3-dev
- run: sudo -E docker-php-ext-install bcmath pcntl zip
# Download and cache dependencies
# composer cache
- restore_cache:
keys:
# "composer.lock" can be used if it is committed to the repo
- v1-dependencies-{{ checksum "composer.json" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: composer install -n --prefer-dist
- save_cache:
key: composer-v1-{{ checksum "composer.lock" }}
paths:
- vendor
- run: cp .env.testing .env
- run: php artisan route:clear
- run: php artisan storage:link
- run: php artisan key:generate
- run: php artisan config:clear
# run tests with phpunit or codecept
- run: ./vendor/bin/phpunit
- store_test_results:
path: tests/_output
- store_artifacts:
path: tests/_output

View file

@ -1,6 +1,6 @@
storage
data
Dockerfile
contrib/docker/Dockerfile.*
docker-compose*.yml
.dockerignore
.git

143
.env.docker Normal file
View file

@ -0,0 +1,143 @@
## Crypto
APP_KEY=
## General Settings
APP_NAME="Pixelfed Prod"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://real.domain
APP_DOMAIN="real.domain"
ADMIN_DOMAIN="real.domain"
SESSION_DOMAIN="real.domain"
OPEN_REGISTRATION=true
ENFORCE_EMAIL_VERIFICATION=false
PF_MAX_USERS=1000
OAUTH_ENABLED=true
APP_TIMEZONE=UTC
APP_LOCALE=en
## Pixelfed Tweaks
LIMIT_ACCOUNT_SIZE=true
MAX_ACCOUNT_SIZE=1000000
MAX_PHOTO_SIZE=15000
MAX_AVATAR_SIZE=2000
MAX_CAPTION_LENGTH=500
MAX_BIO_LENGTH=125
MAX_NAME_LENGTH=30
MAX_ALBUM_LENGTH=4
IMAGE_QUALITY=80
PF_OPTIMIZE_IMAGES=true
PF_OPTIMIZE_VIDEOS=true
ADMIN_ENV_EDITOR=false
ACCOUNT_DELETION=true
ACCOUNT_DELETE_AFTER=false
MAX_LINKS_PER_POST=0
## Instance
#INSTANCE_DESCRIPTION=
INSTANCE_PUBLIC_HASHTAGS=false
#INSTANCE_CONTACT_EMAIL=
INSTANCE_PUBLIC_LOCAL_TIMELINE=false
#BANNED_USERNAMES=
STORIES_ENABLED=false
RESTRICTED_INSTANCE=false
## Mail
MAIL_DRIVER=log
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_FROM_ADDRESS="pixelfed@example.com"
MAIL_FROM_NAME="Pixelfed"
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
## Databases (MySQL)
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pixelfed
DB_USERNAME=pixelfed
DB_PASSWORD=pixelfed
## Databases (Postgres)
#DB_CONNECTION=pgsql
#DB_HOST=postgres
#DB_PORT=5432
#DB_DATABASE=pixelfed
#DB_USERNAME=postgres
#DB_PASSWORD=postgres
## Cache (Redis)
REDIS_CLIENT=phpredis
REDIS_SCHEME=tcp
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DATABASE=0
## EXPERIMENTS
EXP_LC=false
EXP_REC=false
EXP_LOOPS=false
## ActivityPub Federation
ACTIVITY_PUB=false
AP_REMOTE_FOLLOW=false
AP_SHAREDINBOX=false
AP_INBOX=false
AP_OUTBOX=false
ATOM_FEEDS=true
NODEINFO=true
WEBFINGER=true
## S3
FILESYSTEM_DRIVER=local
FILESYSTEM_CLOUD=s3
PF_ENABLE_CLOUD=false
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=
#AWS_DEFAULT_REGION=
#AWS_BUCKET=
#AWS_URL=
#AWS_ENDPOINT=
#AWS_USE_PATH_STYLE_ENDPOINT=false
## Horizon
HORIZON_DARKMODE=false
## COSTAR - Confirm Object Sentiment Transform and Reduce
PF_COSTAR_ENABLED=false
# Media
MEDIA_EXIF_DATABASE=false
## Logging
LOG_CHANNEL=stack
## Image
IMAGE_DRIVER=imagick
## Broadcasting
BROADCAST_DRIVER=log # log driver for local development
## Cache
CACHE_DRIVER=redis
## Purify
RESTRICT_HTML_TYPES=true
## Queue
QUEUE_DRIVER=redis
## Session
SESSION_DRIVER=redis
## Trusted Proxy
TRUST_PROXIES="*"
## Passport
#PASSPORT_PRIVATE_KEY=
#PASSPORT_PUBLIC_KEY=

View file

@ -1,49 +1,51 @@
APP_NAME=Laravel
APP_ENV=local
APP_NAME="Pixelfed Prod"
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
APP_DOMAIN="localhost"
ADMIN_DOMAIN="localhost"
SESSION_DOMAIN="localhost"
TRUST_PROXIES="*"
LOG_CHANNEL=stack
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
DB_DATABASE=pixelfed
DB_USERNAME=pixelfed
DB_PASSWORD=pixelfed
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
SESSION_LIFETIME=120
QUEUE_DRIVER=sync
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_DRIVER=redis
REDIS_SCHEME=tcp
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_DRIVER=log
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
SESSION_DOMAIN=".pixelfed.dev"
SESSION_SECURE_COOKIE=true
API_BASE="/api/1/"
API_SEARCH="/api/search"
MAIL_FROM_ADDRESS="pixelfed@example.com"
MAIL_FROM_NAME="Pixelfed"
OPEN_REGISTRATION=true
ENFORCE_EMAIL_VERIFICATION=true
PF_MAX_USERS=1000
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
MIX_APP_URL="${APP_URL}"
MIX_API_BASE="${API_BASE}"
MIX_API_SEARCH="${API_SEARCH}"
MAX_PHOTO_SIZE=15000
MAX_CAPTION_LENGTH=150
MAX_ALBUM_LENGTH=4
ACTIVITY_PUB=false
AP_REMOTE_FOLLOW=false
AP_INBOX=false
PF_COSTAR_ENABLED=false

65
.env.testing Normal file
View file

@ -0,0 +1,65 @@
APP_NAME="Pixelfed Test"
APP_ENV=local
APP_KEY=base64:lwX95GbNWX3XsucdMe0XwtOKECta3h/B+p9NbH2jd0E=
APP_DEBUG=true
APP_URL=https://pixelfed.dev
APP_DOMAIN="pixelfed.dev"
ADMIN_DOMAIN="pixelfed.dev"
SESSION_DOMAIN="pixelfed.dev"
TRUST_PROXIES="*"
LOG_CHANNEL=stack
DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE='tests/database.sqlite'
DB_USERNAME=
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_DRIVER=redis
REDIS_SCHEME=tcp
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=log
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="pixelfed@example.com"
MAIL_FROM_NAME="Pixelfed"
OPEN_REGISTRATION=true
ENFORCE_EMAIL_VERIFICATION=false
PF_MAX_USERS=1000
MAX_PHOTO_SIZE=15000
MAX_CAPTION_LENGTH=150
MAX_ALBUM_LENGTH=4
ACTIVITY_PUB=false
REMOTE_FOLLOW=false
ACTIVITYPUB_INBOX=false
ACTIVITYPUB_SHAREDINBOX=false
# Set these "true" to enable federation.
# You might need to also run:
# php artisan cache:clear
# php artisan optimize:clear
# php artisan optimize
PF_COSTAR_ENABLED=true
CS_BLOCKED_DOMAINS='example.org,example.net,example.com'
CS_CW_DOMAINS='example.org,example.net,example.com'
CS_UNLISTED_DOMAINS='example.org,example.net,example.com'
## Optional
#HORIZON_DARKMODE=false # Horizon theme darkmode
#HORIZON_EMBED=false # Single Docker Container mode

5
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,5 @@
# These are supported funding model platforms
github: dansup
patreon: dansup
open_collective: pixelfed

7
.gitignore vendored
View file

@ -13,3 +13,10 @@ npm-debug.log
yarn-error.log
.env
.DS_Store
.bash_profile
.bash_history
.bashrc
.gitconfig
.git-credentials
/.composer/
/nginx.conf

557
CHANGELOG.md Normal file
View file

@ -0,0 +1,557 @@
# Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.10.9...dev)
### Added
- Direct Messages ([d63569c](https://github.com/pixelfed/pixelfed/commit/d63569c))
- ActivityPubFetchService for signed GET requests ([8763bfc5](https://github.com/pixelfed/pixelfed/commit/8763bfc5))
- Custom content warnings for remote posts ([6afc61a4](https://github.com/pixelfed/pixelfed/commit/6afc61a4))
- Thai translations ([74cd536](https://github.com/pixelfed/pixelfed/commit/74cd536))
- Added Bookmarks to v1 api ([99cb48c5](https://github.com/pixelfed/pixelfed/commit/99cb48c5))
- Added New Post notification to Timeline ([a0e7c4d5](https://github.com/pixelfed/pixelfed/commit/a0e7c4d5))
- Add Instagram Import ([e2a6bdd0](https://github.com/pixelfed/pixelfed/commit/e2a6bdd0))
- Add notification preview to NotificationCard ([28445e27](https://github.com/pixelfed/pixelfed/commit/28445e27))
- Add Grid Mode to Timelines ([c1853ca8](https://github.com/pixelfed/pixelfed/commit/c1853ca8))
- Add MediaPathService ([c54b29c5](https://github.com/pixelfed/pixelfed/commit/c54b29c5))
- Add Media Tags ([711fc020](https://github.com/pixelfed/pixelfed/commit/711fc020))
- Add MediaTagService ([524c6d45](https://github.com/pixelfed/pixelfed/commit/524c6d45))
- Add MediaBlocklist feature ([ba1f7e7e](https://github.com/pixelfed/pixelfed/commit/ba1f7e7e))
- New Discover Layout, add trending hashtags, places and posts ([c251d41b](https://github.com/pixelfed/pixelfed/commit/c251d41b))
- Add Password change email notification ([de1cca4f](https://github.com/pixelfed/pixelfed/commit/de1cca4f))
### Updated
- Updated PostComponent, fix remote urls ([42716ccc](https://github.com/pixelfed/pixelfed/commit/42716ccc))
- Updated PostComponent, fix missing like button on comments ([132c1dce](https://github.com/pixelfed/pixelfed/commit/132c1dce))
- Updated PostComponent.vue, fix load more comments button ([847599ad](https://github.com/pixelfed/pixelfed/commit/847599ad))
- Updated 2FA Checkpoint, add username + logout button and numeric inputmode ([26affb11](https://github.com/pixelfed/pixelfed/commit/26affb11))
- Updated RemoteProfile, fix missing content warnings ([e487527a](https://github.com/pixelfed/pixelfed/commit/e487527a))
- Updated RemotePost component, fix missing like button on comments ([7ef90565](https://github.com/pixelfed/pixelfed/commit/7ef90565))
- Updated PublicApiControllers, fix block/mutes filtering on public timeline ([08383dd4](https://github.com/pixelfed/pixelfed/commit/08383dd4))
- Updated FixUsernames command, fixes remote username search ([0f943f67](https://github.com/pixelfed/pixelfed/commit/0f943f67))
- Updated Timeline component, fix mod tools ([b1d5eb05](https://github.com/pixelfed/pixelfed/commit/b1d5eb05))
- Updated Profile.vue component, fix pagination bug ([46767810](https://github.com/pixelfed/pixelfed/commit/46767810))
- Updated purify config, fix microformats support ([877023fb](https://github.com/pixelfed/pixelfed/commit/877023fb))
- Updated LikeController, fix likes_count bug ([996866cb](https://github.com/pixelfed/pixelfed/commit/996866cb))
- Updated AccountController, added followRequestJson method ([483548e2](https://github.com/pixelfed/pixelfed/commit/483548e2))
- Updated UserInvite model, added sender relation ([591a1929](https://github.com/pixelfed/pixelfed/commit/591a1929))
- Updated migrations, added UIKit ([fcab5010](https://github.com/pixelfed/pixelfed/commit/fcab5010))
- Updated AccountTransformer, added last_fetched_at attribute ([38b0233e](https://github.com/pixelfed/pixelfed/commit/38b0233e))
- Updated StoryItemTransformer, increase story length to 5 seconds ([924e424c](https://github.com/pixelfed/pixelfed/commit/924e424c))
- Updated StatusController, fix reblog_count bug ([1dc65e93](https://github.com/pixelfed/pixelfed/commit/1dc65e93))
- Updated NotificationCard.vue component, add follow requests at top of card, remove card-header ([5e48ffca](https://github.com/pixelfed/pixelfed/commit/5e48ffca))
- Updated RemoteProfile.vue component, add warning for empty profiles and last_fetched_at ([66f44a9d](https://github.com/pixelfed/pixelfed/commit/66f44a9d))
- Updated ApiV1Controller, enforce public timeline setting ([285bd485](https://github.com/pixelfed/pixelfed/commit/285bd485))
- Updated SearchController, fix self search bug and rank local matches higher ([f67fada2](https://github.com/pixelfed/pixelfed/commit/f67fada2))
- Updated FederationController, improve webfinger logic, fixes ([#2180](https://github.com/pixelfed/pixelfed/issues/2180)) ([302ff874](https://github.com/pixelfed/pixelfed/commit/302ff874))
- Updated ApiV1Controller, fix broken auth check on public timelines. Fixes ([#2168](https://github.com/pixelfed/pixelfed/issues/2168)) ([aa49afc7](https://github.com/pixelfed/pixelfed/commit/aa49afc7))
- Updated SearchApiV2Service, fix offset bug ([#2116](https://github.com/pixelfed/pixelfed/issues/2116)) ([a0c0c84d](https://github.com/pixelfed/pixelfed/commit/a0c0c84d))
- Updated api routes, fixes ([#2114](https://github.com/pixelfed/pixelfed/issues/2114)) ([50bbeddd](https://github.com/pixelfed/pixelfed/commit/50bbeddd))
- Updated SiteController, add legacy profile/webfinger redirect ([cfaa248c](https://github.com/pixelfed/pixelfed/commit/cfaa248c))
- Updated checkpoint view, fix recovery code bug ([3385583f](https://github.com/pixelfed/pixelfed/commit/3385583f))
- Updated Inbox, move expensive HTTP Signature validation to job queue ([f2ae45e5a](https://github.com/pixelfed/pixelfed/commit/f2ae45e5a))
- Updated MomentUI, fix bugs and improve UI ([90b89cb8](https://github.com/pixelfed/pixelfed/commit/90b89cb8))
- Updated PostComponent, improve embed model. Fixes ([#2189](https://github.com/pixelfed/pixelfed/issues/2189)) ([b12e504e](https://github.com/pixelfed/pixelfed/commit/b12e504e))
- Updated PostComponent, hide edit button after 24 hours. Fixes ([#2188](https://github.com/pixelfed/pixelfed/issues/2188)) ([a1fee6a2](https://github.com/pixelfed/pixelfed/commit/a1fee6a2))
- Updated AP Inbox, add follow notifications ([b8819fbb](https://github.com/pixelfed/pixelfed/commit/b8819fbb))
- Updated Api Transformers, fixes ([#2234](https://github.com/pixelfed/pixelfed/issues/2234)) ([63007891](https://github.com/pixelfed/pixelfed/commit/63007891))
- Updated ApiV1Controller, fix instance endpoint ([#2233](https://github.com/pixelfed/pixelfed/issues/2233)) ([b7ee9981](https://github.com/pixelfed/pixelfed/commit/b7ee9981))
- Updated AP Inbox, remove trailing comma ([5c443548](https://github.com/pixelfed/pixelfed/commit/5c443548))
- Updated AP Helpers, update bio + name ([4bee8397](https://github.com/pixelfed/pixelfed/commit/4bee8397))
- Updated Profile component, add bookmark loader ([c8d5edc9](https://github.com/pixelfed/pixelfed/commit/c8d5edc9))
- Updated PostComponent, add recent posts ([b289f2f6](https://github.com/pixelfed/pixelfed/commit/b289f2f6))
- Updated ApiV1Controller, add status ancestor and descendant context ([a0bde855](https://github.com/pixelfed/pixelfed/commit/a0bde855))
- Updated NotificationCard, improve popover image scaling ([0153e596](https://github.com/pixelfed/pixelfed/commit/0153e596))
- Updated StoryController, fix deprecated getClientSize() use ([725fc6c6](https://github.com/pixelfed/pixelfed/commit/725fc6c6))
- Updated ComposeModal, fix rotate icon direction. Fixes ([#2241](https://github.com/pixelfed/pixelfed/issues/2241)) ([e8a14640](https://github.com/pixelfed/pixelfed/commit/e8a14640))
- Updated Timeline.vue, add profile links to grid mode ([fa40f51b](https://github.com/pixelfed/pixelfed/commit/fa40f51b))
- Updated Timeline.vue, hide like counts on grid mode. Fixes ([#2293](https://github.com/pixelfed/pixelfed/issues/2293)) ([cc18159f](https://github.com/pixelfed/pixelfed/commit/cc18159f))
- Updated Timeline.vue, make grid mode photos clickable. Fixes ([#2292](https://github.com/pixelfed/pixelfed/issues/2292)) ([6db68184](https://github.com/pixelfed/pixelfed/commit/6db68184))
- Updated ComposeModal.vue, use vue tooltips. Fixes ([#2142](https://github.com/pixelfed/pixelfed/issues/2142)) ([2b753123](https://github.com/pixelfed/pixelfed/commit/2b753123))
- Updated AccountController, prevent blocking admins. ([2c440b48](https://github.com/pixelfed/pixelfed/commit/2c440b48))
- Updated Api controllers to use MediaPathService. ([58864212](https://github.com/pixelfed/pixelfed/commit/58864212))
- Updated notification components, add modlog and tagged notification types ([51862b8b](https://github.com/pixelfed/pixelfed/commit/51862b8b))
- Updated StoryController, allow video stories. ([b3b220b9](https://github.com/pixelfed/pixelfed/commit/b3b220b9))
- Updated InternalApiController, add media tags. ([ee93f459](https://github.com/pixelfed/pixelfed/commit/ee93f459))
- Updated ComposeModal.vue, add media tagging. ([421ea022](https://github.com/pixelfed/pixelfed/commit/421ea022))
- Updated NotificationTransformer, add modlog and tagged types. ([49dab6fb](https://github.com/pixelfed/pixelfed/commit/49dab6fb))
- Updated comments, fix remote reply bug. ([f330616](https://github.com/pixelfed/pixelfed/commit/f330616))
- Updated PostComponent, add tagged people to mobile layout. ([7a2c2e78](https://github.com/pixelfed/pixelfed/commit/7a2c2e78))
- Updated Tag People, allow untagging yourself. ([c9452639](https://github.com/pixelfed/pixelfed/commit/c9452639))
- Updated ComposeModal.vue, add 451 http code warning. ([b213dcda](https://github.com/pixelfed/pixelfed/commit/b213dcda))
- Updated Profile.vue, add empty follower modal placeholder. ([b542a3c5](https://github.com/pixelfed/pixelfed/commit/b542a3c5))
- Updated private profiles, add context menu to mute, block or report. ([487c4ffc](https://github.com/pixelfed/pixelfed/commit/487c4ffc))
- Updated webfinger util, fix bug preventing username with dots. ([c2d194af](https://github.com/pixelfed/pixelfed/commit/c2d194af))
- Updated upload endpoints with MediaBlocklist checks. ([597378bf](https://github.com/pixelfed/pixelfed/commit/597378bf))
- Updated Timeline.vue component, fixes ([#2352](https://github.com/pixelfed/pixelfed/issues/2352)) and ([#2343](https://github.com/pixelfed/pixelfed/issues/2343)). ([e134a9ac](https://github.com/pixelfed/pixelfed/commit/e134a9ac))
- Updated PostComponent.vue, improve MetroUI and fixes ([#2363](https://github.com/pixelfed/pixelfed/issues/2363)). ([0c8ebf26](https://github.com/pixelfed/pixelfed/commit/0c8ebf26))
- Updated Timeline.vue, fixes ([#2363](https://github.com/pixelfed/pixelfed/issues/2363)). ([f53f10fd](https://github.com/pixelfed/pixelfed/commit/f53f10fd))
- Updated Profile.vue, add atom feed link to context menu. Fixes ([#2313](https://github.com/pixelfed/pixelfed/issues/2313)). ([89f29072](https://github.com/pixelfed/pixelfed/commit/89f29072))
- Updated Hashtag.vue, add nsfw toggle. Fixes ([#2225](https://github.com/pixelfed/pixelfed/issues/2225)). ([e5aa506c](https://github.com/pixelfed/pixelfed/commit/e5aa506c))
- Updated Timeline.vue, move compose button. ([9cad8f77](https://github.com/pixelfed/pixelfed/commit/9cad8f77))
- Updated status embed, allow photo albums. Fixes ([#2374](https://github.com/pixelfed/pixelfed/issues/2374)). ([d11fac0d](https://github.com/pixelfed/pixelfed/commit/d11fac0d))
- Updated DiscoverController, fixes ([#2378](https://github.com/pixelfed/pixelfed/issues/2378)). ([8e7f4f9d](https://github.com/pixelfed/pixelfed/commit/8e7f4f9d))
- Updated SearchController, update version. ([8d923d77](https://github.com/pixelfed/pixelfed/commit/8d923d77))
- Updated email confirmation middleware, add 2FA to allow list. Fixes ([#2385](https://github.com/pixelfed/pixelfed/issues/2385)). ([27f3b29c](https://github.com/pixelfed/pixelfed/commit/27f3b29c))
- Updated NotificationTransformer, fixes ([#2389](https://github.com/pixelfed/pixelfed/issues/2389)). ([c4506ebd](https://github.com/pixelfed/pixelfed/commit/c4506ebd))
- Updated Profile + Timeline components, simplify UI. ([38d28ab4](https://github.com/pixelfed/pixelfed/commit/38d28ab4))
- Updated Profile component, make modals scrollable. ([d1c664fa](https://github.com/pixelfed/pixelfed/commit/d1c664fa))
- Updated PostComponent, fixes #2351. ([7a62a42a](https://github.com/pixelfed/pixelfed/commit/7a62a42a))
## [v0.10.9 (2020-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.10.8...v0.10.9)
### Added
- Added Profile Following Search ([e3280c11](https://github.com/pixelfed/pixelfed/commit/e3280c11))
- Added Trusted Devices to Sudo Mode ([0c82c970](https://github.com/pixelfed/pixelfed/commit/0c82c970))
- Added reply modal to posts and timelines ([974e6bda](https://github.com/pixelfed/pixelfed/commit/974e6bda))
- Added remote posts and profiles ([95bce31e](https://github.com/pixelfed/pixelfed/commit/95bce31e))
- Added Labs deprecation page ([9b215001](https://github.com/pixelfed/pixelfed/commit/9b215001))
- Added new landing page ([84e203a9](https://github.com/pixelfed/pixelfed/commit/84e203a9))
### Fixed
- Stories on postgres instances ([5ffa71da](https://github.com/pixelfed/pixelfed/commit/5ffa71da))
### Updated
- Updated StatusController, restrict edits to 24 hours ([ae24433b](https://github.com/pixelfed/pixelfed/commit/ae24433b))
- Updated RateLimit, add max post edits per hour and day ([51fbfcdc](https://github.com/pixelfed/pixelfed/commit/51fbfcdc))
- Updated Timeline.vue, move announcements from sidebar to top of timeline ([228f5044](https://github.com/pixelfed/pixelfed/commit/228f5044))
- Updated lexer autolinker and extractor, add support for mentioned usernames containing dashes, periods and underscore characters ([f911c96d](https://github.com/pixelfed/pixelfed/commit/f911c96d))
- Updated Story apis, move FE to v0 and add v1 for oauth clients ([92654fab](https://github.com/pixelfed/pixelfed/commit/92654fab))
- Updated robots.txt ([25101901](https://github.com/pixelfed/pixelfed/commit/25101901))
- Updated mail panel blade view, fix markdown bug ([cbc63b04](https://github.com/pixelfed/pixelfed/commit/cbc63b04))
- Updated self-diagnosis checks ([03f808c7](https://github.com/pixelfed/pixelfed/commit/03f808c7))
- Updated DiscoverController, fixes #2009 ([b04c7170](https://github.com/pixelfed/pixelfed/commit/b04c7170))
- Updated DeleteAccountPipeline, fixes [#2016](https://github.com/pixelfed/pixelfed/issues/2016), a bug affecting account deletion.
- Updated PlaceController, fixes [#2017](https://github.com/pixelfed/pixelfed/issues/2017), a postgres bug affecting country pagination in the places directory ([dd5fa3a4](https://github.com/pixelfed/pixelfed/commit/dd5fa3a4))
- Updated confirm email blade view, remove html5 entity that doesn't display properly ([aa26fa1d](https://github.com/pixelfed/pixelfed/commit/aa26fa1d))
- Updated ApiV1Controller, fix update_credentials endpoint ([a73fad75](https://github.com/pixelfed/pixelfed/commit/a73fad75))
- Updated AdminUserController, add moderation method ([a4cf21ea](https://github.com/pixelfed/pixelfed/commit/a4cf21ea))
- Updated BaseApiController, invalidate session after account deletion ([826978ce](https://github.com/pixelfed/pixelfed/commit/826978ce))
- Updated AdminUserController, add account deletion handler ([9be19ad8](https://github.com/pixelfed/pixelfed/commit/9be19ad8))
- Updated ContactController, fixes [#2042](https://github.com/pixelfed/pixelfed/issues/2042) ([c9057e87](https://github.com/pixelfed/pixelfed/commit/c9057e87))
- Updated Media model, fix remote media preview ([9947050b](https://github.com/pixelfed/pixelfed/commit/9947050b))
- Updated PostComponent, improve likes modal ([664fd272](https://github.com/pixelfed/pixelfed/commit/664fd272))
- Updated StoryViewer, preload media ([336571d0](https://github.com/pixelfed/pixelfed/commit/336571d0))
- Updated StoryCompose, add expand label for lightbox preview ([fdf59753](https://github.com/pixelfed/pixelfed/commit/fdf59753))
- Updated session config, increase session timeout from 2 days to 60 days ([b8795271](https://github.com/pixelfed/pixelfed/commit/b8795271))
- Updated WebfingerService, cache lookup ([8b9faf31](https://github.com/pixelfed/pixelfed/commit/8b9faf31))
- Updated v1 notifications api, fix optional params ([4e3c952c](https://github.com/pixelfed/pixelfed/commit/4e3c952c))
- Updated ApiV1Controller, fix unfavourite bug [#2088](https://github.com/pixelfed/pixelfed/issues/2088) ([3a828522](https://github.com/pixelfed/pixelfed/commit/3a828522))
- Updated SharePipeline, fix item relation bug ([b5899648](https://github.com/pixelfed/pixelfed/commit/b5899648))
- Updated Profile.vue, add v-once to thumbnails to prevent re-render ([a54685f6](https://github.com/pixelfed/pixelfed/commit/a54685f6))
- Updated SearchResults.vue, improve layout ([7e41b4ae](https://github.com/pixelfed/pixelfed/commit/7e41b4ae))
- Updated PostMenu.vue, fix styling of list-group ([4c3b0b7d](https://github.com/pixelfed/pixelfed/commit/4c3b0b7d))
- Updated PostComponent.vue, update styling ([844566b9](https://github.com/pixelfed/pixelfed/commit/844566b9))
- Updated NotificationCard.vue, fix share notifications ([3cb676b1](https://github.com/pixelfed/pixelfed/commit/3cb676b1))
- Updated PostComponent.vue, remove like count from title, fixes [#2091](https://github.com/pixelfed/pixelfed/issues/2091) ([6026998c](https://github.com/pixelfed/pixelfed/commit/6026998c))
- Updated SearchController, add WebfingerService support ([869b4ff7](https://github.com/pixelfed/pixelfed/commit/869b4ff7))
- Updated Profile model, use change_count for version ([0eae9f8b](https://github.com/pixelfed/pixelfed/commit/0eae9f8b))
- Updated Timeline.vue, add remote post/profile links ([d4147083](https://github.com/pixelfed/pixelfed/commit/d4147083))
- Updated StoryTimelineComponent, added list prop for new timeline layout ([1692a95a](https://github.com/pixelfed/pixelfed/commit/1692a95a))
- Updated blank layout, add sharedData js ([4a293ed9](https://github.com/pixelfed/pixelfed/commit/4a293ed9))
- Updated oauth api, allow multiple redirect_uris. Fixes #[2106](https://github.com/pixelfed/pixelfed/issues/2106) ([0540a28a](https://github.com/pixelfed/pixelfed/commit/0540a28a))
- Updated ActivityPub Outbox, fixes #[2100](https://github.com/pixelfed/pixelfed/issues/2100) ([c84cee5a](https://github.com/pixelfed/pixelfed/commit/c84cee5a))
- Updated ApiV1Controller, fixes #[2112](https://github.com/pixelfed/pixelfed/issues/2112) ([324ccd0a](https://github.com/pixelfed/pixelfed/commit/324ccd0a))
- Updated StatusTransformer, fixes #[2113](https://github.com/pixelfed/pixelfed/issues/2113) ([eefa6e0d](https://github.com/pixelfed/pixelfed/commit/eefa6e0d))
- Updated InternalApiController, limit remote profile ui to remote profiles ([d918a68e](https://github.com/pixelfed/pixelfed/commit/d918a68e))
- Updated NotificationCard, fix pagination bug #[2019](https://github.com/pixelfed/pixelfed/issues/2019) ([32beaad5](https://github.com/pixelfed/pixelfed/commit/32beaad5))
## [v0.10.8 (2020-01-29)](https://github.com/pixelfed/pixelfed/compare/v0.10.7...v0.10.8)
### Added
- Added ```BANNED_USERNAMES``` .env var, an optional comma separated string to ban specific usernames from being used ([6cdd64c6](https://github.com/pixelfed/pixelfed/commit/6cdd64c6))
- Added RestrictedAccess middleware for Restricted Mode ([17c1a83d](https://github.com/pixelfed/pixelfed/commit/17c1a83d))
- Added FailedJob garbage collection ([5d424f12](https://github.com/pixelfed/pixelfed/commit/5d424f12))
- Added Password Reset garbage collection ([829c41e1](https://github.com/pixelfed/pixelfed/commit/829c41e1))
### Fixed
- Fixed Story Compose bug affecting postgres instances ([#1918](https://github.com/pixelfed/pixelfed/pull/1918))
- Fixed header background bug on MomentUI profiles ([#1933](https://github.com/pixelfed/pixelfed/pull/1933))
- Fixed TRUST_PROXIES configuration ([#1941](https://github.com/pixelfed/pixelfed/pull/1941))
- Fixed settings page default language ([4223a11e](https://github.com/pixelfed/pixelfed/commit/4223a11e))
- Fixed DeleteAccountPipeline bug that did not use proper media paths ([578d2f35](https://github.com/pixelfed/pixelfed/commit/578d2f35))
- Fixed mastoapi StatusTransformer, fix in_reply_to_id cast to string instead of int ([6ed00c94](https://github.com/pixelfed/pixelfed/commit/6ed00c94))
### Updated
- Updated presenter components, load fallback image on errors ([273170c5](https://github.com/pixelfed/pixelfed/commit/273170c5))
- Updated Story model, hide json attribute by default ([de89403c](https://github.com/pixelfed/pixelfed/commit/de89403c))
- Updated compose view, add deprecation notice for v3 ([57e155b9](https://github.com/pixelfed/pixelfed/commit/57e155b9))
- Updated StoryController, orientate story media and strip exif ([07a13fcf](https://github.com/pixelfed/pixelfed/commit/07a13fcf))
- Updated admin reports, fixed 404 bug ([dbd5c4cf](https://github.com/pixelfed/pixelfed/commit/dbd5c4cf))
- Updated AdminController, abstracted dashboard stats to AdminStatsService ([41abe9d2](https://github.com/pixelfed/pixelfed/commit/41abe9d2))
- Updated StoryCompose component, added upload progress page ([2de3c56f](https://github.com/pixelfed/pixelfed/commit/2de3c56f))
- Updated instance config, cleanup and add restricted mode ([3be32597](https://github.com/pixelfed/pixelfed/commit/3be32597))
- Update RelationshipSettings Controller, fixes #1605 ([4d2da2f1](https://github.com/pixelfed/pixelfed/commit/4d2da2f1))
- Updated password reset, now expires after 24 hours ([829c41e1](https://github.com/pixelfed/pixelfed/commit/829c41e1))
- Updated nav layout ([73249dc2](https://github.com/pixelfed/pixelfed/commit/73249dc2))
- Updated views with noscript warnings ([eaca43a6](https://github.com/pixelfed/pixelfed/commit/eaca43a6))
### Changed
## [v0.10.7 (2020-01-07)](https://github.com/pixelfed/pixelfed/compare/v0.10.6...v0.10.7)
### Added
- Added drafts API endpoint for Camera Roll ([bad2ecde](https://github.com/pixelfed/pixelfed/commit/bad2ecde))
- Added AccountService ([885a1258](https://github.com/pixelfed/pixelfed/commit/885a1258))
- Added post embeds ([1fecf717](https://github.com/pixelfed/pixelfed/commit/1fecf717))
- Added profile embeds ([fb7a3cf0](https://github.com/pixelfed/pixelfed/commit/fb7a3cf0))
- Added Force MetroUI labs experiment ([#1889](https://github.com/pixelfed/pixelfed/pull/1889))
- Added Stories, to enable add ```STORIES_ENABLED=true``` to ```.env``` and run ```php artisan config:cache && php artisan cache:clear```. If opcache is enabled you may need to reload the web server.
### Fixed
- Fixed like and share/reblog count on profiles ([86cb7d09](https://github.com/pixelfed/pixelfed/commit/86cb7d09))
- Fixed non federating self boosts ([0c59a55e](https://github.com/pixelfed/pixelfed/commit/0c59a55e))
- Fixed CORS issues with API endpoints ([6d6f517d](https://github.com/pixelfed/pixelfed/commit/6d6f517d))
- Fixed mixed albums not appearing on timelines ([e01dff45](https://github.com/pixelfed/pixelfed/commit/e01dff45))
### Changed
- Removed ```relationship``` from ```AccountTransformer``` ([4d084ac5](https://github.com/pixelfed/pixelfed/commit/4d084ac5))
- Updated ```notification``` api endpoint to use ```NotificationService``` ([f4039ce2](https://github.com/pixelfed/pixelfed/commit/f4039ce2)) ([6ef7597](https://github.com/pixelfed/pixelfed/commit/6ef7597))
- Update footer to use localization for the ```Places``` link ([39712714](https://github.com/pixelfed/pixelfed/commit/39712714))
- Updated ComposeModal.vue, added a caption counter. Fixes [#1722](https://github.com/pixelfed/pixelfed/issues/1722). ([009c6ee8](https://github.com/pixelfed/pixelfed/commit/009c6ee8))
- Updated Notifications to use the NotificationService ([f4039ce2](https://github.com/pixelfed/pixelfed/commit/f4039ce218f93a5578225dfdba66f0359c8fc72c))
- Updated PrivacySettings controller, clear cache after updating ([d8d11d7b](https://github.com/pixelfed/pixelfed/commit/d8d11d7b))
- Updated BaseApiController, add timestamp to signed media previews for client side cache invalidation ([73c08987](https://github.com/pixelfed/pixelfed/commit/73c08987))
- Updated AdminInstanceController, remove db transaction from instance scan ([5773434a](https://github.com/pixelfed/pixelfed/commit/5773434a))
- Updated Help Center view, added outdated warning ([0e611d00](https://github.com/pixelfed/pixelfed/commit/0e611d00))
- Updated language view, added English version of language names ([ebb998d2](https://github.com/pixelfed/pixelfed/commit/ebb998d2))
- Updated app.js, added App.utils like ```.format.count```, ```.filters``` and ```.emoji``` ([34c13b6e](https://github.com/pixelfed/pixelfed/commit/34c13b6e))
- Updated CollectionCompose.vue component, fix api namespace change ([71ed965c](https://github.com/pixelfed/pixelfed/commit/71ed965c))
- Updated PostComponent, mark caption sensitive if post is and use util.emoji ([35d51215](https://github.com/pixelfed/pixelfed/commit/35d51215))
- Updated Profile.vue component, use formatted counts ([30f14961](https://github.com/pixelfed/pixelfed/commit/30f14961))
- Updated Timeline.vue component, use formatted counts, util.emoji and increase pagination limit to 5 ([abfc9fe7](https://github.com/pixelfed/pixelfed/commit/abfc9fe7))
- Updated album presenters, use better carousel ([31b114cc](https://github.com/pixelfed/pixelfed/commit/31b114cc)) ([0617fada](https://github.com/pixelfed/pixelfed/commit/0617fada)) ([767fc887](https://github.com/pixelfed/pixelfed/commit/767fc887))
- Updated Timeline.vue component, remove tap for lightbox as it conflicts with new carousel ([96e25ad2](https://github.com/pixelfed/pixelfed/commit/96e25ad2))
- Updated ComposeModal.vue, added album support, editing and UI tweaks ([3aaad81e](https://github.com/pixelfed/pixelfed/commit/3aaad81e))
- Updated InternalApiController, increase license limit to 140 to match UI counter ([b3c18aec](https://github.com/pixelfed/pixelfed/commit/b3c18aec))
- Updated album carousels, fix height bug ([8380822a](https://github.com/pixelfed/pixelfed/commit/8380822a))
- Updated MediaController, add timestamp to signed preview url ([49efaae9](https://github.com/pixelfed/pixelfed/commit/49efaae9))
- Updated BaseApiController, uncache verify_credentials method ([3fa9ac8b](https://github.com/pixelfed/pixelfed/commit/3fa9ac8b))
- Updated StatusHashtagService, reduce cached hashtag count ttl from 6 hours to 5 minutes ([126886e8](https://github.com/pixelfed/pixelfed/commit/126886e8))
- Updated Hashtag.vue component, added formatted posts count ([c71f3dd1](https://github.com/pixelfed/pixelfed/commit/c71f3dd1))
- Updated FixLikes command, fix postgres support ([771f9c46](https://github.com/pixelfed/pixelfed/commit/771f9c46))
- Updated Settings, hide sponsors feature until re-implemented in Profile UI ([c4dd8449](https://github.com/pixelfed/pixelfed/commit/c4dd8449))
- Updated Status view, added ```video``` open graph tag support ([#1799](https://github.com/pixelfed/pixelfed/pull/1799))
- Updated AccountTransformer, added ```local``` attribute ([d2a90f11](https://github.com/pixelfed/pixelfed/commit/d2a90f11))
- Updated Laravel framework from v5.8 to v6.x ([3aff6de33](https://github.com/pixelfed/pixelfed/commit/3aff6de33))
- Updated FollowerController to fix bug affecting private profiles ([a429d961](https://github.com/pixelfed/pixelfed/commit/a429d961))
- Updated StatusTransformer, added ```local``` attribute ([484bb509](https://github.com/pixelfed/pixelfed/commit/484bb509))
- Updated PostComponent, fix bug affecting MomentUI and non authenticated users ([7b3fe215](https://github.com/pixelfed/pixelfed/commit/7b3fe215))
- Updated FixUsernames command to allow usernames containing ```.``` ([e5d77c6d](https://github.com/pixelfed/pixelfed/commit/e5d77c6d))
- Updated landing page, add age check ([d11e82c3](https://github.com/pixelfed/pixelfed/commit/d11e82c3))
- Updated ApiV1Controller, add ```mobile_apis``` to /api/v1/instance endpoint ([57407463](https://github.com/pixelfed/pixelfed/commit/57407463))
- Updated PublicTimelineService, add video media scopes ([7b00eba3](https://github.com/pixelfed/pixelfed/commit/7b00eba3))
- Updated PublicApiController, add AccountService ([5ebd2c8a](https://github.com/pixelfed/pixelfed/commit/5ebd2c8a))
- Updated CommentController, fix scope bug ([45ecad2a](https://github.com/pixelfed/pixelfed/45ecad2a))
- Updated CollectionController, increase limit from 18 to 50. ([c2826fd3](https://github.com/pixelfed/pixelfed/c2826fd3))
## Deprecated
## [v0.10.6 (2019-09-30)](https://github.com/pixelfed/pixelfed/compare/v0.10.5...v0.10.6)
### Added
- Added ```/api/v1/accounts/update_credentials``` endpoint [6afd6970](https://github.com/pixelfed/pixelfed/commit/6afd6970)
- Added ```/api/v1/accounts/{id}/followers``` endpoint [41c91cba](https://github.com/pixelfed/pixelfed/commit/41c91cba)
- Added ```/api/v1/accounts/{id}/following``` endpoint [607eb51b](https://github.com/pixelfed/pixelfed/commit/607eb51b)
- Added ```/api/v1/accounts/{id}/statuses``` endpoint [8ce6c1f2](https://github.com/pixelfed/pixelfed/commit/8ce6c1f2)
- Added ```/api/v1/accounts/{id}/follow``` endpoint [f3839026](https://github.com/pixelfed/pixelfed/commit/f3839026)
- Added ```/api/v1/accounts/{id}/unfollow``` endpoint [fadc96b2](https://github.com/pixelfed/pixelfed/commit/fadc96b2)
- Added ```/api/v1/accounts/relationships``` endpoint [4b9f7d6b](https://github.com/pixelfed/pixelfed/commit/4b9f7d6b)
- Added ```/api/v1/accounts/search``` endpoint [b1fccf6d](https://github.com/pixelfed/pixelfed/commit/b1fccf6d)
- Added ```/api/v1/blocks``` endpoint [ac9f1bc0](https://github.com/pixelfed/pixelfed/commit/ac9f1bc0)
- Added ```/api/v1/accounts/{id}/block``` endpoint [c6b1ed97](https://github.com/pixelfed/pixelfed/commit/c6b1ed97)
- Added ```/api/v1/accounts/{id}/unblock``` endpoint [35226c99](https://github.com/pixelfed/pixelfed/commit/35226c99)
- Added ```/api/v1/custom_emojis``` endpoint [6e43431a](https://github.com/pixelfed/pixelfed/commit/6e43431a)
- Added ```/api/v1/domain_blocks``` endpoint [83a6313f](https://github.com/pixelfed/pixelfed/commit/83a6313f)
- Added ```/api/v1/endorsements``` endpoint [1f16221e](https://github.com/pixelfed/pixelfed/commit/1f16221e)
- Added ```/api/v1/favourites``` endpoint [b9cc06da](https://github.com/pixelfed/pixelfed/commit/b9cc06da)
- Added ```/api/v1/statuses/{id}/favourite``` endpoint [4edeba17](https://github.com/pixelfed/pixelfed/commit/4edeba17)
- Added ```/api/v1/statuses/{id}/unfavourite``` endpoint [437e18e3](https://github.com/pixelfed/pixelfed/commit/437e18e3)
- Added ```/api/v1/filters``` endpoint [b3d82edd](https://github.com/pixelfed/pixelfed/commit/b3d82edd)
- Added ```/api/v1/follow_requests``` endpoint [97269136](https://github.com/pixelfed/pixelfed/commit/97269136)
- Added ```/api/v1/follow_requests/{id}/authorize``` endpoint [7bdd9b2a](https://github.com/pixelfed/pixelfed/commit/7bdd9b2a)
- Added ```/api/v1/follow_requests/{id}/reject``` endpoint [62aa922a](https://github.com/pixelfed/pixelfed/commit/62aa922a)
- Added ```/api/v1/suggestions``` endpoint [e52aeeed](https://github.com/pixelfed/pixelfed/commit/e52aeeed)
- Added ```/api/v1/lists``` endpoint [2a106c4e](https://github.com/pixelfed/pixelfed/commit/2a106c4e)
- Added ```/api/v1/accounts/{id}/lists``` endpoint [dba172df](https://github.com/pixelfed/pixelfed/commit/dba172df)
- Added ```/api/v1/lists/{id}/accounts``` endpoint [dba172df](https://github.com/pixelfed/pixelfed/commit/dba172df)
- Added ```/api/v1/media``` endpoint [39f3e313](https://github.com/pixelfed/pixelfed/commit/39f3e313)
- Added ```/api/v1/media/{id}``` endpoint [fcf231f4](https://github.com/pixelfed/pixelfed/commit/fcf231f4)
- Added ```/api/v1/mutes``` endpoint [b280d183](https://github.com/pixelfed/pixelfed/commit/b280d183)
- Added ```/api/v1/accounts/{id}/mute``` endpoint [3e98dce4](https://github.com/pixelfed/pixelfed/commit/3e98dce4)
- Added ```/api/v1/accounts/{id}/unmute``` endpoint [41c96ddd](https://github.com/pixelfed/pixelfed/commit/41c96ddd)
- Added ```/api/v1/notifications``` endpoint [39449f36](https://github.com/pixelfed/pixelfed/commit/39449f36)
- Added ```/api/v1/timelines/home``` endpoint [cf3405d8](https://github.com/pixelfed/pixelfed/commit/cf3405d8)
- Added ```/api/v1/conversations``` endpoint [336f9069](https://github.com/pixelfed/pixelfed/commit/336f9069)
- Added ```/api/v1/timelines/public``` endpoint [f3eeb9c9](https://github.com/pixelfed/pixelfed/commit/f3eeb9c9)
- Added ```/api/v1/statuses/{id}/card``` endpoint [92251208](https://github.com/pixelfed/pixelfed/commit/92251208)
- Added ```/api/v1/statuses/{id}/reblogged_by``` endpoint [118006ed](https://github.com/pixelfed/pixelfed/commit/118006ed)
- Added ```/api/v1/statuses/{id}/favourited_by``` endpoint [5cdff57d](https://github.com/pixelfed/pixelfed/commit/5cdff57d)
- Added POST ```/api/v1/statuses``` endpoint [3aa729a3](https://github.com/pixelfed/pixelfed/commit/3aa729a3)
- Added DELETE ```/api/v1/statuses``` endpoint [0a20b832](https://github.com/pixelfed/pixelfed/commit/0a20b832)
- Added POST ```/api/v1/statuses/{id}/reblog``` endpoint [43cef282](https://github.com/pixelfed/pixelfed/commit/43cef282)
- Added POST ```/api/v1/statuses/{id}/unreblog``` endpoint [3147fe5c](https://github.com/pixelfed/pixelfed/commit/3147fe5c)
- Added GET ```/api/v1/timelines/tag/{hashtag}``` endpoint [2ff53be4](https://github.com/pixelfed/pixelfed/commit/2ff53be4)
### Fixed
- Update developer settings pages, fix vue bug [cd365ab3](https://github.com/pixelfed/pixelfed/commit/cd365ab3)
- Update User model, fix filter relationship [5a0c295e](https://github.com/pixelfed/pixelfed/commit/5a0c295e)
### Changed
- Updated Inbox Accept.Follow to use id of remote object [#1715](https://github.com/pixelfed/pixelfed/pull/1715)
- Update StatusTransformer, make spoiler_text non-nullable [b66cf9cd](https://github.com/pixelfed/pixelfed/commit/b66cf9cd)
- Update FollowerController, make follow and unfollow methods public [6237897d](https://github.com/pixelfed/pixelfed/commit/6237897d)
- Update DiscoverComponent, change api namespace [35275572](https://github.com/pixelfed/pixelfed/commit/35275572)
## Deprecated
- Removed deprecated AttachmentTransformer, superceeded by MediaTransformer [9b5aac4f](https://github.com/pixelfed/pixelfed/commit/9b5aac4f)
### To enable mobile app support
- Run ```php artisan passport:keys```
- Add ```OAUTH_ENABLED=true``` to .env
- Run ```php artisan config:cache```
## [v0.10.5 (2019-09-24)](https://github.com/pixelfed/pixelfed/compare/v0.10.4...v0.10.5)
### Added
- Added ```software``` back to AccountTransformer [93c687c7](https://github.com/pixelfed/pixelfed/commit/93c687c7)
### Fixed
- Fixed cache bug in privacy and terms pages [#1712](https://github.com/pixelfed/pixelfed/commit/fe522da8db7a8b0d7c18d405abcb885f8678f35c)
### Changed
## [v0.10.4 (2019-09-24)](https://github.com/pixelfed/pixelfed/compare/v0.10.3...v0.10.4)
### Added
- Added Welsh translations [#1706](https://github.com/pixelfed/pixelfed/pull/1706)
- Added Api v1 controller [85835f5a](https://github.com/pixelfed/pixelfed/commit/85835f5a6712dea0562df4be897087de5305750f)
- Added database migration that adds a language column to the users table [c87d8c16](https://github.com/pixelfed/pixelfed/commit/c87d8c16)
- Added persistent preferred language [18bc9c30](https://github.com/pixelfed/pixelfed/commit/18bc9c30)
### Fixed
- Fixed count bug in StatusHashtagService [#1694](https://github.com/pixelfed/pixelfed/pull/1694)
- Fixed private account bug [#1699](https://github.com/pixelfed/pixelfed/pull/1699)
- Fixed comments on MomentUI posts [#1704](https://github.com/pixelfed/pixelfed/pull/1704)
### Changed
- Updated EmailService, added new domains [#1690](https://github.com/pixelfed/pixelfed/pull/1690)
- Updated quill.js to v1.3.7 [#1692](https://github.com/pixelfed/pixelfed/pull/1692)
- Cache ProfileController [#1700](https://github.com/pixelfed/pixelfed/pull/1700)
- Updated ComposeUI v4, made cropping optional [#1702](https://github.com/pixelfed/pixelfed/pull/1702)
- Updated DiscoverController, limit Loops to local only posts [#1703](https://github.com/pixelfed/pixelfed/pull/1703)
- Namespaced internal apis [3c306c5e](https://github.com/pixelfed/pixelfed/commit/3c306c5e179d35dbe19a6a1bd9533350e4b96524)
- Updated .env.example with proper remote follow variable [0697f780](https://github.com/pixelfed/pixelfed/commit/0697f780d3a5cba72148f0a767d5a35124a3d9b4)
- Updated show all comments view [0a5eaa31](https://github.com/pixelfed/pixelfed/pull/1708/commits/0a5eaa3118cb09c61d3e5442fe3bf8439a2a12af)
- Updated language page layout [01fb5af](https://github.com/pixelfed/pixelfed/pull/1708/commits/01fb5af19e803488c5794b545d218771f6fce6d7)
- Updated privacy policy page layout [a4229d5](https://github.com/pixelfed/pixelfed/pull/1708/commits/a4229d5d30faea11e7a72d122c4a5762d867aaf3)
- Updated terms page layout [4f8c5e5](https://github.com/pixelfed/pixelfed/pull/1708/commits/4f8c5e5519949c63c702c724a00d8575db4e0014)
- Update v1 API, added /api/v1/instance endpoint [951b6fa0](https://github.com/pixelfed/pixelfed/commit/951b6fa0) [9dc2234b](https://github.com/pixelfed/pixelfed/commit/99dc2234b)
## Deprecated
- Remove deprecated profile following/followers [#1697](https://github.com/pixelfed/pixelfed/pull/1697)
- Remove old comment permalink [05f6598](https://github.com/pixelfed/pixelfed/pull/1708/commits/05f659896d903e1ff41dba810f125d721fa057e7)
## [v0.10.3 (2019-09-08)](https://github.com/pixelfed/pixelfed/compare/v0.10.2...v0.10.3)
### Added
- Append ```.json``` to local status urls to view ActivityPub object [#1666](https://github.com/pixelfed/pixelfed/pull/1666)
### Fixed
- Reverted ```strict``` Same-Site Cookies to ```null``` to fix 2FA/session expiry [#1667](https://github.com/pixelfed/pixelfed/pull/1667)
- Fixed AP errors by storing ActivityPub object id and url [#1668](https://github.com/pixelfed/pixelfed/pull/1668) [#1683](https://github.com/pixelfed/pixelfed/pull/1683)
- Fixed content warnings that had filter applied [#1669](https://github.com/pixelfed/pixelfed/pull/1669)
### Changed
- Japanese Translations [#1673](https://github.com/pixelfed/pixelfed/pull/1673)
- Occitan Translations [#1679](https://github.com/pixelfed/pixelfed/pull/1679)
- Use footer partial on landing page [#1681](https://github.com/pixelfed/pixelfed/pull/1681)
- Change admin badge so it doesn't look like a verified badge [#1684](https://github.com/pixelfed/pixelfed/pull/1684)
### Deprecated
- Personalized Discover has been deprecated due to low use [#1670](https://github.com/pixelfed/pixelfed/pull/1670)
## [v0.10.2 (2019-09-06)](https://github.com/pixelfed/pixelfed/compare/v0.10.1...v0.10.2)
### Fixed
- Typo in Inbox prevented proper federation support [#1664](https://github.com/pixelfed/pixelfed/pull/1664)
## [v0.10.1 (2019-09-06)](https://github.com/pixelfed/pixelfed/compare/v0.10.0...v0.10.1)
### Added
- Remote follows! Search for an actor URI, send AP Follow, plus handle incoming AP Accept Follow
- Compose UI v4: a rework of the v3 flow to allow basic cropping and better support future post types
- Profile badges show if a user is following you or is an admin
- Show confirmation message when muting or blocking a user from a post
- Allow "read more" to be disabled on posts [#1545](https://github.com/pixelfed/pixelfed/pull/1545)
- Loops! Discover short videos
- Preliminary support for profile PropertyValue metadata
- Preliminary support for Direct Messages
- Places! Run the artisan task `import:cities`
- Emails are now validated and banned email domains are disallowed at signup. Artisan task `email:bancheck` will validate existing users.
- .env vars `REDIS_SCHEME` and `REDIS_PATH` allow for using Redis over a Unix socket instead of TCP [#1602](https://github.com/pixelfed/pixelfed/pull/1602)
- .env var `IMAGE_DRIVER` allows using imagick instead of gd
### Fixed
- Show delete button while composing video posts [#1529](https://github.com/pixelfed/pixelfed/pull/1529)
- Show pending follow requests on private profiles
- Allow muted users to comment on your posts [#1537](https://github.com/pixelfed/pixelfed/pull/1537)
- Bugs with carousel cursor and tooltips
- Collections can now be deleted from collection page
- Compose modal now indicates album media limits
- Unlisted and private posts are now delivered
- Don't show Register link in navbar when registrations are closed
### Changed
- Use vue-masonry for Moment UI layout [#1536](https://github.com/pixelfed/pixelfed/pull/1536)
- User post limit changed from 20/hr to 50/hr
- Better mobile profile layout
- Dark mode is now a bit bluer
- Sample nginx.conf in contrib/ now uses HTTPS instead of HTTP. Docs updated to reference this file
- Updated register form
- Allow users to edit email after registrations
## [v0.10.0 (2019-07-17)](https://github.com/pixelfed/pixelfed/compare/v0.9.6...v0.10.0)
### Added
- Collections! Add posts to Collections, similar to categories. [#1511](https://github.com/pixelfed/pixelfed/pull/1511)
- Profile donate links: add links to Patreon, Liberapay, and OpenCollective on your profile [#1500](https://github.com/pixelfed/pixelfed/pull/1500)
### Fixed
- Show correct mode when viewing followers / following
### Changed
- Profile model now uses snowflake id [#1502](https://github.com/pixelfed/pixelfed/pull/1502)
### Removed
- OStatus legacy code has been removed [#1510](https://github.com/pixelfed/pixelfed/pull/1510)
## [v0.9.6 (2019-07-10)](https://github.com/pixelfed/pixelfed/compare/v0.9.5...v0.9.6)
### Fixed
- Hashtag post count off-by-one [#1485](https://github.com/pixelfed/pixelfed/pull/1485)
## [v0.9.5 (2019-07-10)](https://github.com/pixelfed/pixelfed/compare/v0.9.4...v0.9.5)
### Added
- Add StatusService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [425ec91](https://github.com/pixelfed/pixelfed/commit/425ec91)
- Add PublicTimelineService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [734e892](https://github.com/pixelfed/pixelfed/commit/734e892)
- Add RelationshipSettings trait [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [bf8340f](https://github.com/pixelfed/pixelfed/commit/bf8340f)
- Add Remote Follows [#1388](https://github.com/pixelfed/pixelfed/pull/1388)
- Add Relationship Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [b10e03d](https://github.com/pixelfed/pixelfed/commit/b10e03d)
- Add Configuration Editor to Admin Dashboard [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [323dca1](https://github.com/pixelfed/pixelfed/commit/323dca1)
- Add Migration, adding profile_id to users table [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [bdfe633](https://github.com/pixelfed/pixelfed/commit/bdfe633)
- Add Media configuration [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
- Add Content Warnings to comments [#1430](https://github.com/pixelfed/pixelfed/pull/1430), [42d81fc](https://github.com/pixelfed/pixelfed/commit/42d81fc) [8d4b3bd](https://github.com/pixelfed/pixelfed/commit/8d4b3bd) [73e162e4](https://github.com/pixelfed/pixelfed/commit/3e162e4)
- Add new rate limits [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [1f1df2d](https://github.com/pixelfed/pixelfed/commit/1f1df2d)
- Add RegenerateThumbnails command to force thumbnail regeneration [#1437](https://github.com/pixelfed/pixelfed/pull/1437) [a3be4cd](https://github.com/pixelfed/pixelfed/commit/a3be4cd)
- Add Pages Editor to Admin Dashboard [#1438](https://github.com/pixelfed/pixelfed/pull/1438) [ef3e30d](https://github.com/pixelfed/pixelfed/commit/ef3e30d) [718375a](https://github.com/pixelfed/pixelfed/commit/718375a) [79524a0](https://github.com/pixelfed/pixelfed/commit/79524a0) [13ceef0](https://github.com/pixelfed/pixelfed/commit/13ceef0) [2fbcd6d](https://github.com/pixelfed/pixelfed/commit/2fbcd6d) [bb207a4](https://github.com/pixelfed/pixelfed/commit/bb207a4) [ef07e31](https://github.com/pixelfed/pixelfed/commit/ef07e31) [aca5114](https://github.com/pixelfed/pixelfed/commit/aca5114) [59fcfc2](https://github.com/pixelfed/pixelfed/commit/59fcfc2) [e3cfd81](https://github.com/pixelfed/pixelfed/commit/e3cfd81) [7ade78b](https://github.com/pixelfed/pixelfed/commit/7ade78b) [4539afa](https://github.com/pixelfed/pixelfed/commit/4539afa) [1dbfcae](https://github.com/pixelfed/pixelfed/commit/1dbfcae)
### Changed
- Update SearchController, fix AP verb typo [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [dc8acf9](https://github.com/pixelfed/pixelfed/commit/dc8acf9)
- Update StatusTransformer, increase media cache ttl to 14 days [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [f35718b](https://github.com/pixelfed/pixelfed/commit/f35718b)
- Update webpack config, extract vendor librarys [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [b42db89](https://github.com/pixelfed/pixelfed/commit/b42db89)
- Update admin statuses view, make table header light [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [44afcc7](https://github.com/pixelfed/pixelfed/commit/44afcc7)
- Update settings, move disable/delete to Security Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [ca0d638](https://github.com/pixelfed/pixelfed/commit/ca0d638)
- Update Installer command [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [506dd8b](https://github.com/pixelfed/pixelfed/commit/506dd8b)
- Update UserObserver [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [4ee3d10](https://github.com/pixelfed/pixelfed/commit/4ee3d10)
- Update AuthLogin listener [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [c27c751](https://github.com/pixelfed/pixelfed/commit/c27c751) [1e8b092](https://github.com/pixelfed/pixelfed/commit/1e8b092)
- Update Image Optimization to not store EXIF by default [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
- Update Settings, hide OAuth/Developer pages when not enabled [#1413](https://github.com/pixelfed/pixelfed/pull/1413)
- Update Presenter Components, move alt tag and filters to ```<img>``` element [#1415](https://github.com/pixelfed/pixelfed/pull/1415)
- Update Api Controllers, add missing caption limit to ```composePost()``` and missing ```is_nsfw``` attribute to comment queries [#1429](https://github.com/pixelfed/pixelfed/pull/1429), [1cff278](https://github.com/pixelfed/pixelfed/commit/1cff278)
- Update instances admin view, add scan button to find new instances [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [a94a3ee](https://github.com/pixelfed/pixelfed/commit/a94a3ee)
- Update registration page, add links to terms and privacy pages [#1488](https://github.com/pixelfed/pixelfed/pull/1488)
### Removed
- Remove Classic Compose UI [#1434](https://github.com/pixelfed/pixelfed/pull/1434), [72bffd1](https://github.com/pixelfed/pixelfed/commit/72bffd1) [a2640af](https://github.com/pixelfed/pixelfed/commit/a2640af)
-
## [v0.9.4 (2019-06-03)](https://github.com/pixelfed/pixelfed/compare/v0.9.0...v0.9.4)
PSA: Due to the removal of Google Recaptcha, a one-time manual intervention is required. Please try the following after installing with composer:
```
rm -rf bootstrap/cache/*
composer dump-autoload
php artisan config:cache
```
### Added
- Notification service
- Notification card on timeline
- Double-tap to like posts (no animation yet)
- Moderator Mode for timelines
- Emoji reaction bar
- Like and reply to comments
- Hello Loops! Short videos will now loop and be discoverable from the Discover page.
- Labs: Optional profile recommendations
- Labs: Show full caption instead of "read more" button
- Labs: Simple "distraction-free" timeline -- no buttons, just images and captions
### Changed
- Refactored notification view into a Vue component
- Preparations for Circles, DMs, and other upcoming functionality
- Default limit of 7500 follows
- Default limit of 20 follows per hour
- Default limit of 5 mentions per comment/caption
- Default limit of 30 hashtags per comment/caption
- Default limit of 2 links per comment/caption
- Thumbnail info overlays on profiles should now scale down to small screens (#1234)
- Moment UI containers are now properly sized (#1236)
- Album posts now have contrast for next/prev arrows (#1238)
- Filter previews now fit the image instead of stretching it (#1239)
### Removed
- Google Recaptcha is no longer supported (#1231)
- Lightbox has been deprecated in favor of double-tap-to-like; it will return as a dedicated button in the future (#1277)
## [v0.9.0 (2019-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.8.6...v0.9.0)
### Added
- Allow users to delete existing profile photos.
- Preliminary support for managing developer tokens, as well as authorizing apps
- Unmute and unblock users more easily. Profiles now reflect muting/blocking status.
- Lazy-loading images with `loading="lazy"`, as supported in Blink
- Added Network Timeline which includes non-local posts
- Add broadcast events for real-time updates
- Compose view now shows upload progress bar
- You can now audit logged-in devices
- Added WIP installer
- Moment UI! This alternative profile view is less square and more full-width pictures.
### Changed
- Allow admins to view reported private posts
- Show sensitivity and privacy/audience in status views
- Cleanup of legacy code
- `commentsDisabled` has been replaced with preliminary support for Litepub Capability Enforcement (LiCE)
- `rel="me"` now added to profile websites
- Posts from locked accounts now default to followers-only
### Removed
- Removed identicons due to SVG compatibility issues with federation. New users will instead be assigned a default avatar.
## [v0.8.6 (2019-04-06)](https://github.com/pixelfed/pixelfed/compare/v0.8.5...v0.8.6)
### Added
- Add COSTAR - Confirm Object Sentiment Transform and Reduce
COSTAR is a filtering system that allows admins to define environment variables that will dynamically apply certain policies to posts of a defined scope, similar to Pleroma's MRF system.
Scopes:
- Domain: apply to posts from a specific website
- Actor: apply to posts from a specific profile/user
- Keyword: apply to posts containing a specific string
Policies:
- Block: Default blocks the defined scope
- CW: Automatically rewrites the scope to apply a warning
- Unlist: Removes the scope from public timelines

View file

@ -34,13 +34,13 @@ This Code of Conduct applies both within project spaces and in public spaces whe
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at danielsupernault@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@pixelfed.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/4/

22
CONTRIBUTING.md Normal file
View file

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

View file

@ -1,31 +0,0 @@
FROM php:7.2.6-fpm-alpine
ARG COMPOSER_VERSION="1.6.5"
ARG COMPOSER_CHECKSUM="67bebe9df9866a795078bb2cf21798d8b0214f2e0b2fd81f2e907a8ef0be3434"
RUN apk add --no-cache --virtual .build build-base autoconf imagemagick-dev libtool && \
apk --no-cache add imagemagick git && \
docker-php-ext-install pdo_mysql pcntl && \
pecl install imagick && \
docker-php-ext-enable imagick pcntl imagick && \
curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /tmp/composer.phar && \
echo "${COMPOSER_CHECKSUM} /tmp/composer.phar" | sha256sum -c - && \
install -m0755 -o root -g root /tmp/composer.phar /usr/bin/composer.phar && \
ln -sf /usr/bin/composer.phar /usr/bin/composer && \
rm /tmp/composer.phar && \
apk --no-cache del --purge .build
COPY . /var/www/html/
WORKDIR /var/www/html
RUN install -d -m0755 -o www-data -g www-data \
/var/www/html/storage \
/var/www/html/storage/framework \
/var/www/html/storage/logs \
/var/www/html/storage/framework/sessions \
/var/www/html/storage/framework/views \
/var/www/html/storage/framework/cache && \
composer install --prefer-source --no-interaction
VOLUME ["/var/www/html"]
ENV PATH="~/.composer/vendor/bin:./vendor/bin:${PATH}"

View file

@ -1,4 +1,40 @@
# PixelFed
Federated Image Sharing
<p align="center"><img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/logos/pixelfed-full-color.svg" width="300px"></p>
> This project is still in active development and not yet ready for use.
<p align="center">
<a href="https://circleci.com/gh/pixelfed/pixelfed"><img src="https://circleci.com/gh/pixelfed/pixelfed.svg?style=svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/pixelfed/pixelfed"><img src="https://poser.pugx.org/pixelfed/pixelfed/license.svg" alt="License"></a>
</p>
## Introduction
A free and ethical photo sharing platform, powered by ActivityPub federation.
<p align="center">
<img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/media/Screen%20Shot%202019-09-08%20at%2010.40.54%20PM.png">
</p>
## Official Documentation
Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://docs.pixelfed.org/).
## License
Pixelfed is open-sourced software licensed under the AGPL license.
## Communication
The ways you can communicate on the project are below. Before interacting, please
read through the [Code Of Conduct](CODE_OF_CONDUCT.md).
* IRC: [#pixelfed](irc://chat.freenode.net/pixelfed) on irc.freenode.net
* Mastodon: [@pixelfed@mastodon.social](https://mastodon.social/@pixelfed)
* E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org)
## Pixelfed Sponsors
We would like to extend our thanks to the following sponsors for funding Pixelfed development. If you are interested in becoming a sponsor, please visit the Pixelfed [Patreon Page](https://www.patreon.com/dansup/overview)
- [NLnet Foundation](https://nlnet.nl) and [NGI0
Discovery](https://nlnet.nl/discovery/), part of the [Next Generation
Internet](https://ngi.eu) initiative.

13
app/AccountLog.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AccountLog extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
}

21
app/Activity.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
protected $dates = ['processed_at'];
protected $fillable = ['data', 'to_id', 'from_id', 'object_type'];
public function toProfile()
{
return $this->belongsTo(Profile::class, 'to_id');
}
public function fromProfile()
{
return $this->belongsTo(Profile::class, 'from_id');
}
}

View file

@ -15,4 +15,10 @@ class Avatar extends Model
* @var array
*/
protected $dates = ['deleted_at'];
protected $fillable = ['profile_id'];
public function profile()
{
return $this->belongsTo(Profile::class);
}
}

View file

@ -6,5 +6,16 @@ use Illuminate\Database\Eloquent\Model;
class Bookmark extends Model
{
protected $fillable = ['profile_id', 'status_id'];
protected $fillable = ['profile_id', 'status_id'];
public function status()
{
return $this->belongsTo(Status::class);
}
public function profile()
{
return $this->belongsTo(Profile::class);
}
}

39
app/Circle.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Circle extends Model
{
protected $fillable = [
'profile_id',
'name',
'description',
'bcc',
'scope',
'active'
];
public function members()
{
return $this->hasManyThrough(
Profile::class,
CircleProfile::class,
'circle_id',
'id',
'id',
'profile_id'
);
}
public function owner()
{
return $this->belongsTo(Profile::class, 'profile_id');
}
public function url()
{
return url("/i/circle/show/{$this->id}");
}
}

13
app/CircleProfile.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class CircleProfile extends Model
{
protected $fillable = [
'circle_id',
'profile_id'
];
}

50
app/Collection.php Normal file
View file

@ -0,0 +1,50 @@
<?php
namespace App;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
class Collection extends Model
{
use HasSnowflakePrimary;
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
public $fillable = ['profile_id', 'published_at'];
public $dates = ['published_at'];
public function profile()
{
return $this->belongsTo(Profile::class);
}
public function items()
{
return $this->hasMany(CollectionItem::class);
}
public function posts()
{
return $this->hasManyThrough(
Status::class,
CollectionItem::class,
'collection_id',
'id',
'id',
'object_id'
);
}
public function url()
{
return url("/c/{$this->id}");
}
}

30
app/CollectionItem.php Normal file
View file

@ -0,0 +1,30 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
class CollectionItem extends Model
{
use HasSnowflakePrimary;
public $fillable = [
'collection_id',
'object_type',
'object_id',
'order'
];
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
public function collection()
{
return $this->belongsTo(Collection::class);
}
}

View file

@ -8,11 +8,11 @@ class Comment extends Model
{
public function profile()
{
return $this->belongsTo(Profile::class);
return $this->belongsTo(Profile::class);
}
public function status()
{
return $this->belongsTo(Status::class);
return $this->belongsTo(Status::class);
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Avatar;
use Cache, DB;
use Illuminate\Support\Str;
class AvatarDefaultMigration extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:avatars';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Replace old svg identicon avatars with default png avatar';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info('Running avatar migration...');
$count = Avatar::whereChangeCount(0)->count();
if($count == 0) {
$this->info('Found no avatars needing to be migrated!');
exit;
}
$bar = $this->output->createProgressBar($count);
$this->info("Found {$count} avatars that may need to be migrated");
Avatar::whereChangeCount(0)->chunk(50, function($avatars) use ($bar) {
foreach($avatars as $avatar) {
if($avatar->media_path == 'public/avatars/default.png' || $avatar->thumb_path == 'public/avatars/default.png') {
continue;
}
if(Str::endsWith($avatar->media_path, '/avatar.svg') == false) {
// do not modify non-default avatars
continue;
}
DB::transaction(function() use ($avatar, $bar) {
if(is_file(storage_path('app/' . $avatar->media_path))) {
@unlink(storage_path('app/' . $avatar->media_path));
}
if(is_file(storage_path('app/' . $avatar->thumb_path))) {
@unlink(storage_path('app/' . $avatar->thumb_path));
}
$avatar->media_path = 'public/avatars/default.png';
$avatar->thumb_path = 'public/avatars/default.png';
$avatar->change_count = $avatar->change_count + 1;
$avatar->save();
Cache::forget('avatar:' . $avatar->profile_id);
$bar->advance();
});
}
});
$bar->finish();
}
}

View file

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

View file

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

View file

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\FailedJob;
class FailedJobGC extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gc:failedjobs';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete failed jobs over 1 month old';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
FailedJob::chunk(50, function($jobs) {
foreach($jobs as $job) {
if($job->failed_at->lt(now()->subMonth())) {
$job->delete();
}
}
});
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{
Like,
Media,
Profile,
Status,
User
};
class FixDuplicateProfiles extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:profile:duplicates';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix duplicate profiles';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
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();
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();
}
$bar->advance();
}
$bar->finish();
}
}

View file

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

View file

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{Like, Status};
use DB;
class FixLikes extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:likes';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix Like counts';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$chunk = 100;
$limit = Like::select('status_id')->groupBy('status_id')->get()->count();
if($limit > 1000) {
if($this->confirm('We have found more than 1000 records to update, this may take a few moments. Are you sure you want to continue?') == false) {
$this->error('Cancelling command...');
return;
}
}
$bar = $this->output->createProgressBar($limit);
$this->line(' ');
$this->info(' Starting like count fix ...');
$this->line(' ');
$bar->start();
Like::selectRaw('count(id) as count, status_id')
->groupBy(['status_id','id'])
->chunk($chunk, function($likes) use($bar) {
foreach($likes as $like) {
$s = Status::find($like['status_id']);
if($s && $s->likes_count == 0) {
$s->likes_count = $like['count'];
$s->save();
}
$bar->advance();
}
});
$bar->finish();
$this->line(' ');
$this->line(' ');
}
}

View file

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{Profile, User};
use DB;
use App\Util\Lexer\RestrictedNames;
class FixUsernames extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:usernames';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix invalid usernames';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->line(' ');
$this->info('Collecting data ...');
$this->line(' ');
$this->restrictedCheck();
}
protected function restrictedCheck()
{
$affected = collect([]);
$restricted = RestrictedNames::get();
$users = User::chunk(100, function($users) use($affected, $restricted) {
foreach($users as $user) {
if($user->is_admin || $user->status == 'deleted') {
continue;
}
if(in_array($user->username, $restricted)) {
$affected->push($user);
}
$val = str_replace(['-', '_', '.'], '', $user->username);
if(!ctype_alnum($val)) {
$this->info('Found invalid username: ' . $user->username);
$affected->push($user);
}
}
});
if($affected->count() > 0) {
$this->info('Found: ' . $affected->count() . ' affected usernames');
$opts = [
'Random replace (assigns random username)',
'Best try replace (assigns alpha numeric username)',
'Manual replace (manually set username)',
'Skip (do not replace. Use at your own risk)'
];
foreach($affected as $u) {
$old = $u->username;
$this->info("Found user: {$old}");
$opt = $this->choice('Select fix method:', $opts, 3);
switch ($opt) {
case $opts[0]:
$new = "user_" . str_random(6);
$this->info('New username: ' . $new);
break;
case $opts[1]:
$new = filter_var($old, FILTER_SANITIZE_STRING|FILTER_FLAG_STRIP_LOW);
if(strlen($new) < 6) {
$new = $new . '_' . str_random(4);
}
$this->info('New username: ' . $new);
break;
case $opts[2]:
$new = $this->ask('Enter new username:');
$this->info('New username: ' . $new);
break;
case $opts[3]:
$new = false;
break;
default:
$new = "user_" . str_random(6);
break;
}
if($new) {
DB::transaction(function() use($u, $new) {
$profile = $u->profile;
$profile->username = $new;
$u->username = $new;
$u->save();
$profile->save();
});
}
$this->info('Selected: ' . $opt);
}
$this->info('Fixed ' . $affected->count() . ' usernames!');
} else {
$this->info('No restricted usernames found!');
}
$this->line(' ');
$this->versionZeroTenNineFix();
}
protected function versionZeroTenNineFix()
{
$profiles = Profile::whereNotNull('domain')
->whereNull('private_key')
->where('username', 'not like', '@%@%')
->get();
$count = $profiles->count();
if($count > 0) {
$this->info("Found {$count} remote usernames to fix ...");
$this->line(' ');
} else {
$this->info('No remote fixes found!');
$this->line(' ');
return;
}
foreach($profiles as $p) {
$this->info("Fixed $p->username => $p->webfinger");
$p->username = $p->webfinger ?? "@{$p->username}@{$p->domain}";
if(Profile::whereUsername($p->username)->exists()) {
return;
}
$p->save();
}
if($count > 0) {
$this->line(' ');
}
}
}

View file

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

View file

@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class Installer extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'install';
/**
* The console command description.
*
* @var string
*/
protected $description = 'CLI Installer';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->welcome();
}
protected function welcome()
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Welcome to the Pixelfed Installer!');
$this->info(' ');
$this->info(' ');
$this->info('Pixelfed version: ' . config('pixelfed.version'));
$this->line(' ');
$this->info('Scanning system...');
$this->preflightCheck();
}
protected function preflightCheck()
{
$this->line(' ');
$this->info('Checking for installed dependencies...');
$redis = Redis::connection();
if($redis->ping()) {
$this->info('- Found redis!');
} else {
$this->error('- Redis not found, aborting installation');
exit;
}
$this->checkPhpDependencies();
$this->checkPermissions();
$this->envCheck();
}
protected function checkPhpDependencies()
{
$extensions = [
'bcmath',
'ctype',
'curl',
'json',
'mbstring',
'openssl'
];
$ffmpeg = exec('which ffmpeg');
if(empty($ffmpeg)) {
$this->error('FFmpeg not found, please install it.');
$this->error('Cancelling installation.');
exit;
} else {
$this->info('- Found FFmpeg!');
}
$this->line('');
$this->info('Checking for required php extensions...');
foreach($extensions as $ext) {
if(extension_loaded($ext) == false) {
$this->error("- {$ext} extension not found, aborting installation");
exit;
} else {
}
}
$this->info("- Required PHP extensions found!");
}
protected function checkPermissions()
{
$this->line('');
$this->info('Checking for proper filesystem permissions...');
$paths = [
base_path('bootstrap'),
base_path('storage')
];
foreach($paths as $path) {
if(is_writeable($path) == false) {
$this->error("- Invalid permission found! Aborting installation.");
$this->error(" Please make the following path writeable by the web server:");
$this->error(" $path");
exit;
} else {
$this->info("- Found valid permissions for {$path}");
}
}
}
protected function envCheck()
{
if(!file_exists(base_path('.env')) || filesize(base_path('.env')) == 0) {
$this->line('');
$this->info('No .env configuration file found. We will create one now!');
$this->createEnv();
} else {
$confirm = $this->confirm('Found .env file, do you want to overwrite it?');
if(!$confirm) {
$this->info('Cancelling installation.');
exit;
}
$confirm = $this->confirm('Are you really sure you want to overwrite it?');
if(!$confirm) {
$this->info('Cancelling installation.');
exit;
}
$this->error('Warning ... if you did not backup your .env before its overwritten it will be permanently deleted.');
$confirm = $this->confirm('The application may be installed already, are you really sure you want to overwrite it?');
if(!$confirm) {
$this->info('Cancelling installation.');
exit;
}
}
$this->postInstall();
}
protected function createEnv()
{
$this->line('');
// copy env
if(!file_exists(app()->environmentFilePath())) {
exec('cp .env.example .env');
$this->call('key:generate');
}
$name = $this->ask('Site name [ex: Pixelfed]');
$this->updateEnvFile('APP_NAME', $name ?? 'pixelfed');
$domain = $this->ask('Site Domain [ex: pixelfed.com]');
$this->updateEnvFile('APP_DOMAIN', $domain ?? 'example.org');
$this->updateEnvFile('ADMIN_DOMAIN', $domain ?? 'example.org');
$this->updateEnvFile('SESSION_DOMAIN', $domain ?? 'example.org');
$this->updateEnvFile('APP_URL', 'https://' . $domain ?? 'https://example.org');
$database = $this->choice('Select database driver', ['mysql', 'pgsql'], 0);
$this->updateEnvFile('DB_CONNECTION', $database ?? 'mysql');
switch ($database) {
case 'mysql':
$database_host = $this->ask('Select database host', '127.0.0.1');
$this->updateEnvFile('DB_HOST', $database_host ?? 'mysql');
$database_port = $this->ask('Select database port', 3306);
$this->updateEnvFile('DB_PORT', $database_port ?? 3306);
$database_db = $this->ask('Select database', 'pixelfed');
$this->updateEnvFile('DB_DATABASE', $database_db ?? 'pixelfed');
$database_username = $this->ask('Select database username', 'pixelfed');
$this->updateEnvFile('DB_USERNAME', $database_username ?? 'pixelfed');
$db_pass = str_random(64);
$database_password = $this->secret('Select database password', $db_pass);
$this->updateEnvFile('DB_PASSWORD', $database_password);
break;
}
$cache = $this->choice('Select cache driver', ["redis", "apc", "array", "database", "file", "memcached"], 0);
$this->updateEnvFile('CACHE_DRIVER', $cache ?? 'redis');
$session = $this->choice('Select session driver', ["redis", "file", "cookie", "database", "apc", "memcached", "array"], 0);
$this->updateEnvFile('SESSION_DRIVER', $session ?? 'redis');
$redis_host = $this->ask('Set redis host', 'localhost');
$this->updateEnvFile('REDIS_HOST', $redis_host);
$redis_password = $this->ask('Set redis password', 'null');
$this->updateEnvFile('REDIS_PASSWORD', $redis_password);
$redis_port = $this->ask('Set redis port', 6379);
$this->updateEnvFile('REDIS_PORT', $redis_port);
$open_registration = $this->choice('Allow new registrations?', ['true', 'false'], 1);
$this->updateEnvFile('OPEN_REGISTRATION', $open_registration);
$enforce_email_verification = $this->choice('Enforce email verification?', ['true', 'false'], 0);
$this->updateEnvFile('ENFORCE_EMAIL_VERIFICATION', $enforce_email_verification);
}
protected function updateEnvFile($key, $value)
{
$envPath = app()->environmentFilePath();
$payload = file_get_contents($envPath);
if ($existing = $this->existingEnv($key, $payload)) {
$payload = str_replace("{$key}={$existing}", "{$key}=\"{$value}\"", $payload);
$this->storeEnv($payload);
} else {
$payload = $payload . "\n{$key}=\"{$value}\"\n";
$this->storeEnv($payload);
}
}
protected function existingEnv($needle, $haystack)
{
preg_match("/^{$needle}=[^\r\n]*/m", $haystack, $matches);
if ($matches && count($matches)) {
return substr($matches[0], strlen($needle) + 1);
}
return false;
}
protected function storeEnv($payload)
{
$file = fopen(app()->environmentFilePath(), 'w');
fwrite($file, $payload);
fclose($file);
}
protected function postInstall()
{
$this->callSilent('config:cache');
//$this->callSilent('route:cache');
$this->info('Pixelfed has been successfully installed!');
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Util\Media\Filter;
use App\Media;
class MediaFix extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:fix';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix media on v0.10.8+';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if(!version_compare(config('pixelfed.version'),'0.10.8','ge')) {
$this->error('Please update to version 0.10.8 or newer.');
exit;
}
$classes = Filter::classes();
Media::whereNotNull('filter_class')
->chunk(50, function($filters) use($classes) {
foreach($filters as $filter) {
$match = $filter->filter_class ? in_array($filter->filter_class, $classes) : true;
if(!$match) {
$filter->filter_class = null;
$filter->filter_name = null;
$filter->save();
}
}
});
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\{Media, Status};
use Carbon\Carbon;
class MediaGarbageCollector extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:gc';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete media uploads not attached to any active statuses';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$limit = 20000;
$gc = Media::doesntHave('status')
->where('created_at', '<', Carbon::now()->subHours(1)->toDateTimeString())
->orderBy('created_at','asc')
->take($limit)
->get();
$bar = $this->output->createProgressBar($gc->count());
$bar->start();
foreach($gc as $media) {
$path = storage_path("app/$media->media_path");
$thumb = storage_path("app/$media->thumbnail_path");
if(is_file($path)) {
unlink($path);
}
if(is_file($thumb)) {
unlink($thumb);
}
$media->forceDelete();
$bar->advance();
}
$bar->finish();
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\EmailVerification;
class PasswordResetGC extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gc:passwordreset';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete password reset tokens over 24 hours old';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
EmailVerification::where('created_at', '<', now()->subMinutes(1441))
->chunk(50, function($emails) {
foreach($emails as $em) {
$em->delete();
}
});
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use DB;
class RegenerateThumbnails extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'regenerate:thumbnails';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate thumbnails';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
DB::transaction(function() {
Media::whereIn('mime', ['image/jpeg', 'image/png'])
->chunk(50, function($medias) {
foreach($medias as $media) {
\App\Jobs\ImageOptimizePipeline\ImageThumbnail::dispatch($media);
}
});
});
}
}

View file

@ -2,9 +2,10 @@
namespace App\Console\Commands;
use App\{Follower, Profile};
use Illuminate\Console\Command;
use App\Follower;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Profile;
use Illuminate\Console\Command;
class SeedFollows extends Command
{
@ -39,19 +40,24 @@ class SeedFollows extends Command
*/
public function handle()
{
$limit = 10000;
$limit = 100;
for ($i=0; $i < $limit; $i++) {
for ($i = 0; $i < $limit; $i++) {
try {
$actor = Profile::inRandomOrder()->firstOrFail();
$target = Profile::inRandomOrder()->firstOrFail();
$actor = Profile::whereDomain(false)->inRandomOrder()->firstOrFail();
$target = Profile::whereDomain(false)->inRandomOrder()->firstOrFail();
$follow = new Follower;
$follow->profile_id = $actor->id;
$follow->following_id = $target->id;
$follow->save();
if($actor->id == $target->id) {
continue;
}
FollowPipeline::dispatch($follow);
$follow = Follower::firstOrCreate([
'profile_id' => $actor->id,
'following_id' => $target->id
]);
if($follow->wasRecentlyCreated == true) {
FollowPipeline::dispatch($follow);
}
} catch (Exception $e) {
continue;
}

View file

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

View file

@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\{
DB,
Storage
};
use App\{
Story,
StoryView
};
class StoryGC extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'story:gc';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear expired Stories';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->directoryScan();
$this->deleteViews();
$this->deleteStories();
}
protected function directoryScan()
{
$day = now()->day;
if($day != 3) {
return;
}
$monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
$t1 = Storage::directories('public/_esm.t1');
$t2 = Storage::directories('public/_esm.t2');
$dirs = array_merge($t1, $t2);
foreach($dirs as $dir) {
$hash = last(explode('/', $dir));
if($hash != $monthHash) {
$this->info('Found directory to delete: ' . $dir);
$this->deleteDirectory($dir);
}
}
}
protected function deleteDirectory($path)
{
Storage::deleteDirectory($path);
}
protected function deleteViews()
{
StoryView::where('created_at', '<', now()->subMinutes(1441))->delete();
}
protected function deleteStories()
{
$stories = Story::where('created_at', '<', now()->subMinutes(1441))->take(50)->get();
if($stories->count() == 0) {
exit;
}
foreach($stories as $story) {
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
DB::transaction(function() use($story) {
StoryView::whereStoryId($story->id)->delete();
$story->delete();
});
}
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Schema;
use Illuminate\Console\Command;
use App\Jobs\ImageOptimizePipeline\ImageThumbnail;
class UpdateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run pixelfed schema updates between versions.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->update();
}
public function update()
{
$v = $this->getVersionFile();
if($v && isset($v['commit_hash']) && $v['commit_hash'] == exec('git rev-parse HEAD') && \App\StatusHashtag::whereNull('profile_id')->count() == 0) {
$this->info('No updates found.');
return;
}
$bar = $this->output->createProgressBar(\App\StatusHashtag::whereNull('profile_id')->count());
\App\StatusHashtag::whereNull('profile_id')->with('status')->chunk(50, function($sh) use ($bar) {
foreach($sh as $status_hashtag) {
if(!$status_hashtag->status) {
$status_hashtag->delete();
} else {
$status_hashtag->profile_id = $status_hashtag->status->profile_id;
$status_hashtag->save();
}
$bar->advance();
}
});
$this->updateVersionFile();
$bar->finish();
}
protected function getVersionFile()
{
$path = storage_path('app/version.json');
return is_file($path) ?
json_decode(file_get_contents($path), true) :
false;
}
protected function updateVersionFile() {
$path = storage_path('app/version.json');
$contents = [
'commit_hash' => exec('git rev-parse HEAD'),
'version' => config('pixelfed.version'),
'timestamp' => date('c')
];
$json = json_encode($contents, JSON_PRETTY_PRINT);
file_put_contents($path, $json);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:admin {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Make a user an admin, or remove admin privileges.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;
}
$this->info('Found username: ' . $user->username);
$state = $user->is_admin ? 'Remove admin privileges from this user?' : 'Add admin privileges to this user?';
$confirmed = $this->confirm($state);
if(!$confirmed) {
exit;
}
$user->is_admin = !$user->is_admin;
$user->save();
$this->info('Successfully changed permissions!');
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:create';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new user';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info('Creating a new user...');
$name = $this->ask('Name');
$username = $this->ask('Username');
if(User::whereUsername($username)->exists()) {
$this->error('Username already in use, please try again...');
exit;
}
$email = $this->ask('Email');
if(User::whereEmail($email)->exists()) {
$this->error('Email already in use, please try again...');
exit;
}
$password = $this->secret('Password');
$confirm = $this->secret('Confirm Password');
if($password !== $confirm) {
$this->error('Password mismatch, please try again...');
exit;
}
$is_admin = $this->confirm('Make this user an admin?');
$confirm_email = $this->confirm('Manually verify email address?');
if($this->confirm('Are you sure you want to create this user?') &&
$username &&
$name &&
$email &&
$password
) {
$user = new User;
$user->username = $username;
$user->name = $name;
$user->email = $email;
$user->password = bcrypt($password);
$user->is_admin = $is_admin;
$user->email_verified_at = $confirm_email ? now() : null;
$user->save();
$this->info('Created new user!');
}
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
class UserDelete extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:delete {id} {--force}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete account';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->argument('id');
$force = $this->option('force');
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;
}
if($user->status == 'deleted' && $force == false) {
$this->error('Account has already been deleted.');
return;
}
if($user->is_admin == true) {
$this->error('Cannot delete an admin account from CLI.');
exit;
}
if(!$this->confirm('Are you sure you want to delete this account?')) {
exit;
}
$confirmation = $this->ask('Enter the username to confirm deletion');
if($confirmation !== $user->username) {
$this->error('Username does not match, exiting...');
exit;
}
if($user->status !== 'deleted') {
$profile = $user->profile;
$profile->status = $user->status = 'deleted';
$profile->save();
$user->save();
}
DeleteAccountPipeline::dispatch($user)->onQueue('high');
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserShow extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:show {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show user info';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;
}
$this->info('User ID: ' . $user->id);
$this->info('Username: ' . $user->username);
$this->info('Email: ' . $user->email);
$this->info('Joined: ' . $user->created_at->diffForHumans());
$this->info('Status Count: ' . $user->statuses()->count());
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserSuspend extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:suspend {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Suspend a local user.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;
}
$this->info('Found user, username: ' . $user->username);
if($this->confirm('Are you sure you want to suspend this user?')) {
$profile = $user->profile;
$user->status = $profile->status = 'suspended';
$user->save();
$profile->save();
$this->info('User account has been suspended.');
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserTable extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:table {limit=10}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Display latest users';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$limit = $this->argument('limit');
$headers = ['ID', 'Username', 'Name', 'Registered'];
$users = User::orderByDesc('id')->take($limit)->get(['id', 'username', 'name', 'created_at'])->toArray();
$this->table($headers, $users);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
class UserUnsuspend extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:unsuspend {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Unsuspend a local user.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = $this->argument('id');
$user = User::whereUsername($id)->orWhere('id', $id)->first();
if(!$user) {
$this->error('Could not find any user with that username or id.');
exit;
}
$this->info('Found user, username: ' . $user->username);
if($this->confirm('Are you sure you want to unsuspend this user?')) {
$profile = $user->profile;
$user->status = $profile->status = null;
$user->save();
$profile->save();
$this->info('User account has been unsuspended.');
}
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use App\Jobs\VideoPipeline\VideoThumbnail as Pipeline;
class VideoThumbnail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'video:thumbnail';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate missing video thumbnails';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$limit = 10;
$videos = Media::whereMime('video/mp4')
->whereNull('thumbnail_path')
->take($limit)
->get();
foreach($videos as $video) {
Pipeline::dispatchNow($video);
}
}
}

View file

@ -19,14 +19,20 @@ class Kernel extends ConsoleKernel
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @param \Illuminate\Console\Scheduling\Schedule $schedule
*
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('media:optimize')
->hourly();
$schedule->command('media:gc')
->hourly();
$schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('story:gc')->everyFiveMinutes();
$schedule->command('gc:failedjobs')->dailyAt(3);
$schedule->command('gc:passwordreset')->dailyAt('09:41');
}
/**

18
app/Contact.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Contact extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function adminUrl()
{
return url('/i/admin/messages/show/' . $this->id);
}
}

50
app/DirectMessage.php Normal file
View file

@ -0,0 +1,50 @@
<?php
namespace App;
use Auth;
use Illuminate\Database\Eloquent\Model;
class DirectMessage extends Model
{
public function status()
{
return $this->hasOne(Status::class, 'id', 'status_id');
}
public function url()
{
return config('app.url') . '/account/direct/m/' . $this->status_id;
}
public function author()
{
return $this->hasOne(Profile::class, 'id', 'from_id');
}
public function recipient()
{
return $this->hasOne(Profile::class, 'id', 'to_id');
}
public function me()
{
return Auth::user()->profile->id === $this->from_id;
}
public function toText()
{
$actorName = $this->author->username;
return "{$actorName} sent a direct message.";
}
public function toHtml()
{
$actorName = $this->author->username;
$actorUrl = $this->author->url();
$url = $this->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> sent a <a href='{$url}' class='dm-link'>direct message</a>.";
}
}

64
app/DiscoverCategory.php Normal file
View file

@ -0,0 +1,64 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use App\{Status, StatusHashtag};
class DiscoverCategory extends Model
{
protected $fillable = ['slug'];
public function media()
{
return $this->belongsTo(Media::class);
}
public function url()
{
return url('/discover/c/'.$this->slug);
}
public function editUrl()
{
return url('/i/admin/discover/category/edit/' . $this->id);
}
public function thumb()
{
return $this->media->thumb();
}
public function mediaUrl()
{
return $this->media->url();
}
public function items()
{
return $this->hasMany(DiscoverCategoryHashtag::class, 'discover_category_id');
}
public function hashtags()
{
return $this->hasManyThrough(
Hashtag::class,
DiscoverCategoryHashtag::class,
'discover_category_id',
'id',
'id',
'hashtag_id'
);
}
public function posts()
{
return Status::select('*')
->join('status_hashtags', 'statuses.id', '=', 'status_hashtags.status_id')
->join('hashtags', 'status_hashtags.hashtag_id', '=', 'hashtags.id')
->join('discover_category_hashtags', 'hashtags.id', '=', 'discover_category_hashtags.hashtag_id')
->join('discover_categories', 'discover_category_hashtags.discover_category_id', '=', 'discover_categories.id')
->where('discover_categories.id', $this->id);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class DiscoverCategoryHashtag extends Model
{
protected $fillable = [
'discover_category_id',
'hashtag_id'
];
}

View file

@ -8,8 +8,14 @@ class EmailVerification extends Model
{
public function url()
{
$base = config('app.url');
$path = '/i/confirm-email/' . $this->user_token . '/' . $this->random_token;
return "{$base}{$path}";
$base = config('app.url');
$path = '/i/confirm-email/'.$this->user_token.'/'.$this->random_token;
return "{$base}{$path}";
}
public function user()
{
return $this->belongsTo(User::class);
}
}

51
app/Events/NewMention.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use App\User;
class NewMention implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
protected $user;
protected $data;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(User $user, $data)
{
$this->user = $user;
$this->data = $data;
}
public function broadcastAs()
{
return 'notification.new.mention';
}
public function broadcastOn()
{
return new PrivateChannel('App.User.' . $this->user->id);
}
public function broadcastWith()
{
return ['id' => $this->user->id];
}
public function via()
{
return 'broadcast';
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Events\Notification;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use App\Status;
use App\Transformer\Api\StatusTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class NewPublicPost implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
protected $status;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
public function broadcastAs()
{
return 'status';
}
public function broadcastOn()
{
return new Channel('firehost.public');
}
public function broadcastWith()
{
$resource = new Fractal\Resource\Item($this->status, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return [
'entity' => $res
];
}
public function via()
{
return 'broadcast';
}
}

View file

@ -2,8 +2,8 @@
namespace App\Exceptions;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
@ -29,10 +29,11 @@ class Handler extends ExceptionHandler
/**
* Report or log an exception.
*
* @param \Exception $exception
* @param \Exception $exception
*
* @return void
*/
public function report(Exception $exception)
public function report(Throwable $exception)
{
parent::report($exception);
}
@ -40,11 +41,12 @@ class Handler extends ExceptionHandler
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Exception $exception
* @param \Illuminate\Http\Request $request
* @param \Exception $exception
*
* @return \Illuminate\Http\Response
*/
public function render($request, Exception $exception)
public function render($request, Throwable $exception)
{
return parent::render($request, $exception);
}

19
app/FailedJob.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class FailedJob extends Model
{
const CREATED_AT = 'failed_at';
const UPDATED_AT = 'failed_at';
public $timestamps = 'failed_at';
public function getFailedAtAttribute($val)
{
return Carbon::parse($val);
}
}

30
app/FollowRequest.php Normal file
View file

@ -0,0 +1,30 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class FollowRequest extends Model
{
protected $fillable = ['follower_id', 'following_id'];
public function follower()
{
return $this->belongsTo(Profile::class, 'follower_id', 'id');
}
public function following()
{
return $this->belongsTo(Profile::class, 'following_id', 'id');
}
public function actor()
{
return $this->belongsTo(Profile::class, 'follower_id', 'id');
}
public function target()
{
return $this->belongsTo(Profile::class, 'following_id', 'id');
}
}

View file

@ -6,32 +6,46 @@ use Illuminate\Database\Eloquent\Model;
class Follower extends Model
{
protected $fillable = ['profile_id', 'following_id', 'local_profile'];
const MAX_FOLLOWING = 7500;
const FOLLOW_PER_HOUR = 30;
public function actor()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');
return $this->belongsTo(Profile::class, 'profile_id', 'id');
}
public function target()
{
return $this->belongsTo(Profile::class, 'following_id', 'id');
return $this->belongsTo(Profile::class, 'following_id', 'id');
}
public function profile()
{
return $this->belongsTo(Profile::class, 'following_id', 'id');
return $this->belongsTo(Profile::class, 'following_id', 'id');
}
public function permalink($append = null)
{
$path = $this->actor->permalink("#accepts/follows/{$this->id}{$append}");
return url($path);
}
public function toText()
{
$actorName = $this->actor->username;
return "{$actorName} " . __('notification.startedFollowingYou');
$actorName = $this->actor->username;
return "{$actorName} ".__('notification.startedFollowingYou');
}
public function toHtml()
{
$actorName = $this->actor->username;
$actorUrl = $this->actor->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> " .
$actorName = $this->actor->username;
$actorUrl = $this->actor->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.startedFollowingYou');
}
}

View file

@ -6,11 +6,11 @@ use Illuminate\Database\Eloquent\Model;
class Hashtag extends Model
{
protected $fillable = ['name','slug'];
public $fillable = ['name', 'slug'];
public function posts()
{
return $this->hasManyThrough(
return $this->hasManyThrough(
Status::class,
StatusHashtag::class,
'hashtag_id',
@ -20,9 +20,8 @@ class Hashtag extends Model
);
}
public function url()
public function url($suffix = '')
{
return config('routes.hashtag.base') . $this->slug;
return config('routes.hashtag.base').$this->slug.$suffix;
}
}

19
app/HashtagFollow.php Normal file
View file

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

View file

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

View file

@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Admin;
use DB, Cache;
use App\{
DiscoverCategory,
DiscoverCategoryHashtag,
Hashtag,
Media,
Profile,
StatusHashtag
};
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
trait AdminDiscoverController
{
public function discoverHome()
{
$categories = DiscoverCategory::orderByDesc('id')->paginate(10);
return view('admin.discover.home', compact('categories'));
}
public function discoverCreateCategory()
{
return view('admin.discover.create-category');
}
public function discoverCreateCategoryStore(Request $request)
{
$this->validate($request, [
'name' => 'required|string|min:1',
'active' => 'required|boolean',
'media' => 'nullable|integer|min:1'
]);
$name = $request->input('name');
$slug = str_slug($name);
$active = $request->input('active');
$media = (int) $request->input('media');
$media = Media::findOrFail($media);
$category = DiscoverCategory::firstOrNew(['slug' => $slug]);
$category->name = $name;
$category->active = $active;
$category->media_id = $media->id;
$category->save();
return $category;
}
public function discoverCategoryEdit(Request $request, $id)
{
$category = DiscoverCategory::findOrFail($id);
return view('admin.discover.show', compact('category'));
}
public function discoverCategoryUpdate(Request $request, $id)
{
$this->validate($request, [
'name' => 'required|string|min:1',
'active' => 'required|boolean',
'media' => 'nullable|integer|min:1',
'hashtags' => 'nullable|string'
]);
$name = $request->input('name');
$slug = str_slug($name);
$active = $request->input('active');
$media = (int) $request->input('media');
$media = Media::findOrFail($media);
$category = DiscoverCategory::findOrFail($id);
$category->name = $name;
$category->active = $active;
$category->media_id = $media->id;
$category->save();
return $category;
}
public function discoveryCategoryTagStore(Request $request)
{
$this->validate($request, [
'category_id' => 'required|integer|min:1',
'hashtag' => 'required|string',
'action' => 'required|string|min:1|max:6'
]);
$category_id = $request->input('category_id');
$category = DiscoverCategory::findOrFail($category_id);
$hashtag = Hashtag::whereName($request->input('hashtag'))->firstOrFail();
$tag = DiscoverCategoryHashtag::firstOrCreate([
'hashtag_id' => $hashtag->id,
'discover_category_id' => $category->id
]);
if($request->input('action') == 'delete') {
$tag->delete();
return [];
}
return $tag;
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Admin;
use DB, Cache;
use App\{Instance, Profile};
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
trait AdminInstanceController
{
public function instances(Request $request)
{
$this->validate($request, [
'filter' => [
'nullable',
'string',
'min:1',
'max:20',
Rule::in(['autocw', 'unlisted', 'banned'])
],
]);
if($request->has('filter') && $request->filled('filter')) {
switch ($request->filter) {
case 'autocw':
$instances = Instance::whereAutoCw(true)->orderByDesc('id')->paginate(5);
break;
case 'unlisted':
$instances = Instance::whereUnlisted(true)->orderByDesc('id')->paginate(5);
break;
case 'banned':
$instances = Instance::whereBanned(true)->orderByDesc('id')->paginate(5);
break;
}
} else {
$instances = Instance::orderByDesc('id')->paginate(5);
}
return view('admin.instances.home', compact('instances'));
}
public function instanceScan(Request $request)
{
Profile::whereNotNull('domain')
->latest()
->groupBy(['domain', 'id'])
->where('created_at', '>', now()->subMonths(2))
->chunk(100, function($domains) {
foreach($domains as $domain) {
Instance::firstOrCreate([
'domain' => $domain->domain
]);
}
});
return redirect()->back();
}
public function instanceShow(Request $request, $id)
{
$instance = Instance::findOrFail($id);
return view('admin.instances.show', compact('instance'));
}
public function instanceEdit(Request $request, $id)
{
$this->validate($request, [
'action' => [
'required',
'string',
'min:1',
'max:20',
Rule::in(['autocw', 'unlist', 'ban'])
],
]);
$instance = Instance::findOrFail($id);
$unlisted = $instance->unlisted;
$autocw = $instance->auto_cw;
$banned = $instance->banned;
switch ($request->action) {
case 'autocw':
$instance->auto_cw = $autocw == true ? false : true;
$instance->save();
break;
case 'unlist':
$instance->unlisted = $unlisted == true ? false : true;
$instance->save();
break;
case 'ban':
$instance->banned = $banned == true ? false : true;
$instance->save();
break;
}
return response()->json([]);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin;
use DB, Cache;
use App\{
Media,
MediaBlocklist,
Profile,
Status
};
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
trait AdminMediaController
{
public function media(Request $request)
{
$this->validate($request, [
'layout' => [
'nullable',
'string',
'min:1',
'max:13',
Rule::in(['grid','list', 'banned', 'addbanned'])
],
'search' => 'nullable|string|min:1|max:20'
]);
if($request->filled('search')) {
$profiles = Profile::where('username', 'like', '%'.$request->input('search').'%')->pluck('id')->toArray();
$media = Media::whereHas('status')
->with('status')
->orderby('id', 'desc')
->whereIn('profile_id', $profiles)
->orWhere('mime', $request->input('search'))
->paginate(12);
return view('admin.media.home', compact('media'));
}
if($request->input('layout') == 'banned') {
$media = MediaBlocklist::latest()->paginate(12);
return view('admin.media.home', compact('media'));
}
$media = Media::whereHas('status')->with('status')->orderby('id', 'desc')->paginate(12);
return view('admin.media.home', compact('media'));
}
public function mediaShow(Request $request, $id)
{
$media = Media::findOrFail($id);
return view('admin.media.show', compact('media'));
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin;
use Cache;
use App\Report;
use Carbon\Carbon;
use Illuminate\Http\Request;
trait AdminReportController
{
public function updateReport(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|string',
]);
$action = $request->input('action');
$actions = [
'ignore',
'cw',
'unlist',
'delete',
'shadowban',
'ban',
];
if (!in_array($action, $actions)) {
return abort(403);
}
$report = Report::findOrFail($id);
$this->handleReportAction($report, $action);
return response()->json(['msg'=> 'Success']);
}
public function handleReportAction(Report $report, $action)
{
$item = $report->reported();
$report->admin_seen = Carbon::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;
break;
case 'unlist':
$item->visibility = 'unlisted';
$item->save();
Cache::forget('profiles:private');
break;
case 'delete':
// Todo: fire delete job
$report->admin_seen = null;
break;
case 'shadowban':
// Todo: fire delete job
$report->admin_seen = null;
break;
case 'ban':
// Todo: fire delete job
$report->admin_seen = null;
break;
default:
$report->admin_seen = null;
break;
}
$report->save();
return $this;
}
protected function actionMap()
{
return [
'1' => 'ignore',
'2' => 'cw',
'3' => 'unlist',
'4' => 'delete',
'5' => 'shadowban',
'6' => 'ban'
];
}
public function bulkUpdateReport(Request $request)
{
$this->validate($request, [
'action' => 'required|integer|min:1|max:10',
'ids' => 'required|array'
]);
$action = $this->actionMap()[$request->input('action')];
$ids = $request->input('ids');
$reports = Report::whereIn('id', $ids)->whereNull('admin_seen')->get();
foreach($reports as $report) {
$this->handleReportAction($report, $action);
}
$res = [
'message' => 'Success',
'code' => 200
];
return response()->json($res);
}
}

View file

@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\Admin;
use Artisan, Cache, DB;
use Illuminate\Http\Request;
use Carbon\Carbon;
use App\{Comment, Like, Media, Page, Profile, Report, Status, User};
use App\Http\Controllers\Controller;
use App\Util\Lexer\PrettyNumber;
trait AdminSettingsController
{
public function settings(Request $request)
{
return view('admin.settings.home');
}
public function settingsBackups(Request $request)
{
$path = storage_path('app/'.config('app.name'));
$files = is_dir($path) ? new \DirectoryIterator($path) : [];
return view('admin.settings.backups', compact('files'));
}
public function settingsConfig(Request $request)
{
$editor = config('pixelfed.admin.env_editor');
$config = !$editor ? false : file_get_contents(base_path('.env'));
$backup = !$editor ? false : (is_file(base_path('.env.backup')) ? file_get_contents(base_path('.env.backup')) : false);
return view('admin.settings.config', compact('editor', 'config', 'backup'));
}
public function settingsConfigStore(Request $request)
{
if(config('pixelfed.admin.env_editor') !== true) {
abort(400);
}
$res = $request->input('res');
$old = file_get_contents(app()->environmentFilePath());
if(empty($old) || $old != $res) {
$oldFile = fopen(app()->environmentFilePath().'.backup', 'w');
fwrite($oldFile, $old);
fclose($oldFile);
}
$file = fopen(app()->environmentFilePath(), 'w');
fwrite($file, $res);
fclose($file);
Artisan::call('config:cache');
return ['msg' => 200];
}
public function settingsConfigRestore(Request $request)
{
if(config('pixelfed.admin.env_editor') !== true) {
abort(400);
}
$res = file_get_contents(app()->environmentFilePath().'.backup');
if(empty($res)) {
abort(400, 'No backup exists.');
}
$file = fopen(app()->environmentFilePath(), 'w');
fwrite($file, $res);
fclose($file);
Artisan::call('config:cache');
return ['msg' => 200];
}
public function settingsMaintenance(Request $request)
{
return view('admin.settings.maintenance');
}
public function settingsStorage(Request $request)
{
$storage = [];
return view('admin.settings.storage', compact('storage'));
}
public function settingsFeatures(Request $request)
{
return view('admin.settings.features');
}
public function settingsHomeStore(Request $request)
{
$this->validate($request, [
'APP_NAME' => 'required|string',
]);
// Artisan::call('config:clear');
return redirect()->back();
}
public function settingsPages(Request $request)
{
$pages = Page::orderByDesc('updated_at')->paginate(10);
return view('admin.pages.home', compact('pages'));
}
public function settingsPageEdit(Request $request)
{
return view('admin.pages.edit');
}
public function settingsSystem(Request $request)
{
$sys = [
'pixelfed' => config('pixelfed.version'),
'php' => phpversion(),
'laravel' => app()->version(),
];
switch (config('database.default')) {
case 'pgsql':
$sys['database'] = [
'name' => 'Postgres',
'version' => explode(' ', DB::select(DB::raw('select version();'))[0]->version)[1]
];
break;
case 'mysql':
$sys['database'] = [
'name' => 'MySQL',
'version' => DB::select( DB::raw("select version()") )[0]->{'version()'}
];
break;
default:
$sys['database'] = [
'name' => 'Unknown',
'version' => '?'
];
break;
}
return view('admin.settings.system', compact('sys'));
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers\Admin;
use Cache, DB;
use Illuminate\Http\Request;
use App\{Contact, Like, Media, Page, Profile, Report, Status, User};
trait AdminSupportController
{
}

View file

@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\Admin;
use Cache, DB;
use Illuminate\Http\Request;
use App\ModLog;
use App\Profile;
use App\User;
use App\Mail\AdminMessage;
use Illuminate\Support\Facades\Mail;
use App\Services\ModLogService;
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
trait AdminUserController
{
public function users(Request $request)
{
$col = $request->query('col') ?? 'id';
$dir = $request->query('dir') ?? 'desc';
$users = User::select('id', 'username', 'status')
->withCount('statuses')
->orderBy($col, $dir)
->simplePaginate(10);
return view('admin.users.home', compact('users'));
}
public function userShow(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
return view('admin.users.show', compact('user', 'profile'));
}
public function userEdit(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
return view('admin.users.edit', compact('user', 'profile'));
}
public function userEditSubmit(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
$changed = false;
$fields = [];
if($request->filled('name') && $request->input('name') != $user->name) {
$fields['name'] = ['old' => $user->name, 'new' => $request->input('name')];
$user->name = $profile->name = $request->input('name');
$changed = true;
}
if($request->filled('username') && $request->input('username') != $user->username) {
$fields['username'] = ['old' => $user->username, 'new' => $request->input('username')];
$user->username = $profile->username = $request->input('username');
$changed = true;
}
if($request->filled('email') && $request->input('email') != $user->email) {
if(filter_var($request->input('email'), FILTER_VALIDATE_EMAIL) == false) {
abort(500, 'Invalid email address');
}
$fields['email'] = ['old' => $user->email, 'new' => $request->input('email')];
$user->email = $request->input('email');
$changed = true;
}
if($request->input('bio') != $profile->bio) {
$fields['bio'] = ['old' => $user->bio, 'new' => $request->input('bio')];
$profile->bio = $request->input('bio');
$changed = true;
}
if($request->input('website') != $profile->website) {
$fields['website'] = ['old' => $user->website, 'new' => $request->input('website')];
$profile->website = $request->input('website');
$changed = true;
}
if($changed == true) {
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.edit')
->metadata([
'fields' => $fields
])
->accessLevel('admin')
->save();
$profile->save();
$user->save();
}
return redirect('/i/admin/users/show/' . $user->id);
}
public function userActivity(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
$logs = $user->accountLog()->orderByDesc('created_at')->paginate(10);
return view('admin.users.activity', compact('user', 'profile', 'logs'));
}
public function userMessage(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
return view('admin.users.message', compact('user', 'profile'));
}
public function userMessageSend(Request $request, $id)
{
$this->validate($request, [
'message' => 'required|string|min:5|max:500'
]);
$user = User::findOrFail($id);
$profile = $user->profile;
$message = $request->input('message');
Mail::to($user->email)->send(new AdminMessage($message));
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.mail')
->metadata([
'message' => $message
])
->accessLevel('admin')
->save();
return redirect('/i/admin/users/show/' . $user->id);
}
public function userModTools(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
return view('admin.users.modtools', compact('user', 'profile'));
}
public function userModLogs(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
$logs = ModLog::whereObjectUid($user->id)
->orderByDesc('created_at')
->simplePaginate(10);
return view('admin.users.modlogs', compact('user', 'profile', 'logs'));
}
public function userModLogsMessage(Request $request, $id)
{
$this->validate($request, [
'message' => 'required|string|min:5|max:500'
]);
$user = User::findOrFail($id);
$profile = $user->profile;
$msg = $request->input('message');
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->message($msg)
->accessLevel('admin')
->save();
return redirect('/i/admin/users/modlogs/' . $user->id);
}
public function userDelete(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
return view('admin.users.delete', compact('user', 'profile'));
}
public function userDeleteProcess(Request $request, $id)
{
$user = User::findOrFail($id);
$profile = $user->profile;
if(config('pixelfed.account_deletion') == false) {
abort(404);
}
if($user->is_admin == true) {
$mid = $request->user()->id;
abort_if($user->id < $mid, 403);
}
$ts = now()->addMonth();
$user->status = 'delete';
$profile->status = 'delete';
$user->delete_after = $ts;
$profile->delete_after = $ts;
$user->save();
$profile->save();
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
Cache::forget('profiles:private');
DeleteAccountPipeline::dispatch($user)->onQueue('high');
$msg = "Successfully deleted {$user->username}!";
$request->session()->flash('status', $msg);
return redirect('/i/admin/users/list');
}
public function userModerate(Request $request)
{
$this->validate($request, [
'profile_id' => 'required|exists:profiles,id',
'action' => 'required|in:cw,no_autolink,unlisted'
]);
$pid = $request->input('profile_id');
$action = $request->input('action');
$profile = Profile::findOrFail($pid);
if($profile->user->is_admin == true) {
$mid = $request->user()->id;
abort_if($profile->user_id < $mid, 403);
}
switch ($action) {
case 'cw':
$profile->cw = !$profile->cw;
$msg = "Success!";
break;
case 'no_autolink':
$profile->no_autolink = !$profile->no_autolink;
$msg = "Success!";
break;
case 'unlisted':
$profile->unlisted = !$profile->unlisted;
$msg = "Success!";
break;
}
$profile->save();
ModLogService::boot()
->objectUid($profile->user_id)
->objectId($profile->user_id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => $action,
'message' => $msg
])
->accessLevel('admin')
->save();
$request->session()->flash('status', $msg);
return redirect('/i/admin/users/modtools/' . $profile->user_id);
}
public function userModLogDelete(Request $request, $id)
{
$this->validate($request, [
'mid' => 'required|integer|exists:mod_logs,id'
]);
$user = User::findOrFail($id);
$uid = $request->user()->id;
$mid = $request->input('mid');
$ml = ModLog::whereUserId($uid)->findOrFail($mid)->delete();
$msg = "Successfully deleted modlog comment!";
$request->session()->flash('status', $msg);
return redirect('/i/admin/users/modlogs/' . $user->id);
}
}

View file

@ -2,43 +2,324 @@
namespace App\Http\Controllers;
use App\{
Contact,
Hashtag,
Newsroom,
OauthClient,
Profile,
Report,
Status,
User
};
use DB, Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use App\{Comment, Like, Media, Profile, Status, User};
use App\Http\Controllers\Admin\{
AdminDiscoverController,
AdminInstanceController,
AdminReportController,
AdminMediaController,
AdminSettingsController,
AdminSupportController,
AdminUserController
};
use Illuminate\Validation\Rule;
use App\Services\AdminStatsService;
class AdminController extends Controller
{
public function __construct()
{
return $this->middleware('admin');
}
use AdminReportController,
AdminDiscoverController,
AdminMediaController,
AdminSettingsController,
AdminInstanceController,
AdminUserController;
public function home()
{
return view('admin.home');
}
public function __construct()
{
$this->middleware('admin');
$this->middleware('dangerzone');
$this->middleware('twofactor');
}
public function users(Request $request)
{
$users = User::orderBy('id', 'desc')->paginate(10);
return view('admin.users.home', compact('users'));
}
public function home()
{
$data = AdminStatsService::get();
return view('admin.home', compact('data'));
}
public function statuses(Request $request)
{
$statuses = Status::orderBy('id', 'desc')->simplePaginate(10);
return view('admin.statuses.home', compact('statuses'));
}
public function showStatus(Request $request, $id)
{
$status = Status::findOrFail($id);
return view('admin.statuses.show', compact('status'));
}
public function reports(Request $request)
{
$this->validate($request, [
'filter' => 'nullable|string|in:all,open,closed'
]);
$filter = $request->input('filter');
$reports = Report::orderBy('created_at','desc')
->when($filter, function($q, $filter) {
return $filter == 'open' ?
$q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen');
})
->paginate(4);
return view('admin.reports.home', compact('reports'));
}
public function showReport(Request $request, $id)
{
$report = Report::findOrFail($id);
return view('admin.reports.show', compact('report'));
}
public function profiles(Request $request)
{
$this->validate($request, [
'search' => 'nullable|string|max:250',
'filter' => [
'nullable',
'string',
Rule::in(['all', 'local', 'remote'])
]
]);
$search = $request->input('search');
$filter = $request->input('filter');
$limit = 12;
$profiles = Profile::select('id','username')
->whereNull('status')
->when($search, function($q, $search) {
return $q->where('username', 'like', "%$search%");
})->when($filter, function($q, $filter) {
if($filter == 'local') {
return $q->whereNull('domain');
}
if($filter == 'remote') {
return $q->whereNotNull('domain');
}
return $q;
})->orderByDesc('id')
->simplePaginate($limit);
return view('admin.profiles.home', compact('profiles'));
}
public function profileShow(Request $request, $id)
{
$profile = Profile::findOrFail($id);
$user = $profile->user;
return view('admin.profiles.edit', compact('profile', 'user'));
}
public function appsHome(Request $request)
{
$filter = $request->input('filter');
if(in_array($filter, ['revoked'])) {
$apps = OauthClient::with('user')
->whereNotNull('user_id')
->whereRevoked(true)
->orderByDesc('id')
->paginate(10);
} else {
$apps = OauthClient::with('user')
->whereNotNull('user_id')
->orderByDesc('id')
->paginate(10);
}
return view('admin.apps.home', compact('apps'));
}
public function 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);
return view('admin.messages.home', compact('messages'));
}
public function messagesShow(Request $request, $id)
{
$message = Contact::findOrFail($id);
return view('admin.messages.show', compact('message'));
}
public function messagesMarkRead(Request $request)
{
$this->validate($request, [
'id' => 'required|integer|min:1'
]);
$id = $request->input('id');
$message = Contact::findOrFail($id);
if($message->read_at) {
return;
}
$message->read_at = now();
$message->save();
return;
}
public function newsroomHome(Request $request)
{
$newsroom = Newsroom::latest()->paginate(10);
return view('admin.newsroom.home', compact('newsroom'));
}
public function newsroomCreate(Request $request)
{
return view('admin.newsroom.create');
}
public function newsroomEdit(Request $request, $id)
{
$news = Newsroom::findOrFail($id);
return view('admin.newsroom.edit', compact('news'));
}
public function newsroomDelete(Request $request, $id)
{
$news = Newsroom::findOrFail($id);
$news->delete();
return redirect('/i/admin/newsroom');
}
public function newsroomUpdate(Request $request, $id)
{
$this->validate($request, [
'title' => 'required|string|min:1|max:100',
'summary' => 'nullable|string|max:200',
'body' => 'nullable|string'
]);
$changed = false;
$changedFields = [];
$news = Newsroom::findOrFail($id);
$fields = [
'title' => 'string',
'summary' => 'string',
'body' => 'string',
'category' => 'string',
'show_timeline' => 'boolean',
'auth_only' => 'boolean',
'show_link' => 'boolean',
'force_modal' => 'boolean',
'published' => 'published'
];
foreach($fields as $field => $type) {
switch ($type) {
case 'string':
if($request->{$field} != $news->{$field}) {
if($field == 'title') {
$news->slug = str_slug($request->{$field});
}
$news->{$field} = $request->{$field};
$changed = true;
array_push($changedFields, $field);
}
break;
case 'boolean':
$state = $request->{$field} == 'on' ? true : false;
if($state != $news->{$field}) {
$news->{$field} = $state;
$changed = true;
array_push($changedFields, $field);
}
break;
case 'published':
$state = $request->{$field} == 'on' ? true : false;
$published = $news->published_at != null;
if($state != $published) {
$news->published_at = $state ? now() : null;
$changed = true;
array_push($changedFields, $field);
}
break;
}
}
if($changed) {
$news->save();
}
$redirect = $news->published_at ? $news->permalink() : $news->editUrl();
return redirect($redirect);
}
public function statuses(Request $request)
{
$statuses = Status::orderBy('id', 'desc')->paginate(10);
return view('admin.statuses.home', compact('statuses'));
}
public function newsroomStore(Request $request)
{
$this->validate($request, [
'title' => 'required|string|min:1|max:100',
'summary' => 'nullable|string|max:200',
'body' => 'nullable|string'
]);
$changed = false;
$changedFields = [];
$news = new Newsroom();
$fields = [
'title' => 'string',
'summary' => 'string',
'body' => 'string',
'category' => 'string',
'show_timeline' => 'boolean',
'auth_only' => 'boolean',
'show_link' => 'boolean',
'force_modal' => 'boolean',
'published' => 'published'
];
foreach($fields as $field => $type) {
switch ($type) {
case 'string':
if($request->{$field} != $news->{$field}) {
if($field == 'title') {
$news->slug = str_slug($request->{$field});
}
$news->{$field} = $request->{$field};
$changed = true;
array_push($changedFields, $field);
}
break;
public function showStatus(Request $request, $id)
{
$status = Status::findOrFail($id);
return view('admin.statuses.show', compact('status'));
}
case 'boolean':
$state = $request->{$field} == 'on' ? true : false;
if($state != $news->{$field}) {
$news->{$field} = $state;
$changed = true;
array_push($changedFields, $field);
}
break;
case 'published':
$state = $request->{$field} == 'on' ? true : false;
$published = $news->published_at != null;
if($state != $published) {
$news->published_at = $state ? now() : null;
$changed = true;
array_push($changedFields, $field);
}
break;
public function media(Request $request)
{
$media = Status::whereHas('media')->orderby('id', 'desc')->paginate(12);
return view('admin.media.home', compact('media'));
}
}
}
if($changed) {
$news->save();
}
$redirect = $news->published_at ? $news->permalink() : $news->editUrl();
return redirect($redirect);
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,341 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\{
Controller,
AvatarController
};
use Auth, Cache, Storage, URL;
use Carbon\Carbon;
use App\{
Avatar,
Notification,
Media,
Profile,
Status
};
use App\Transformer\Api\{
AccountTransformer,
NotificationTransformer,
MediaTransformer,
MediaDraftTransformer,
StatusTransformer
};
use League\Fractal;
use App\Util\Media\Filter;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Jobs\AvatarPipeline\AvatarOptimize;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\VideoPipeline\{
VideoOptimize,
VideoPostProcess,
VideoThumbnail
};
use App\Services\NotificationService;
use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
class BaseApiController extends Controller
{
protected $fractal;
public function __construct()
{
// $this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
public function notifications(Request $request)
{
abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$pg = $request->input('pg');
if($pg == true) {
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::whereProfileId($pid)
->whereDate('created_at', '>', $timeago)
->latest()
->simplePaginate(10);
$resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer());
$res = $this->fractal->createData($resource)->toArray();
} else {
$this->validate($request, [
'page' => 'nullable|integer|min:1|max:10',
'limit' => 'nullable|integer|min:1|max:40'
]);
$limit = $request->input('limit') ?? 10;
$page = $request->input('page') ?? 1;
$end = (int) $page * $limit;
$start = (int) $end - $limit;
$res = NotificationService::get($pid, $start, $end);
}
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
->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->whereVisibility('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);
$this->validate($request, [
'upload' => 'required|mimes:jpeg,png,gif|max:'.config('pixelfed.max_avatar_size'),
]);
try {
$user = Auth::user();
$profile = $user->profile;
$file = $request->file('upload');
$path = (new AvatarController())->getPath($user, $file);
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
$currentAvatar = storage_path('app/'.$profile->avatar->media_path);
$loc = $request->file('upload')->storeAs($public, $name);
$avatar = Avatar::whereProfileId($profile->id)->firstOrFail();
$opath = $avatar->media_path;
$avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null;
$avatar->save();
Cache::forget("avatar:{$profile->id}");
AvatarOptimize::dispatch($user->profile, $currentAvatar);
} catch (Exception $e) {
}
return response()->json([
'code' => 200,
'msg' => 'Avatar successfully updated',
]);
}
public function showTempMedia(Request $request, $profileId, $mediaId, $timestamp)
{
abort_if(!$request->user(), 403);
abort_if(!$request->hasValidSignature(), 404);
abort_if(Auth::user()->profile_id != $profileId, 404);
$media = Media::whereProfileId(Auth::user()->profile_id)->findOrFail($mediaId);
$path = storage_path('app/'.$media->media_path);
return response()->file($path);
}
public function uploadMedia(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'file.*' => function() {
return [
'required',
'mimes:' . config('pixelfed.media_types'),
'max:' . config('pixelfed.max_photo_size'),
];
},
'filter_name' => 'nullable|string|max:24',
'filter_class' => 'nullable|alpha_dash|max:24'
]);
$user = Auth::user();
$profile = $user->profile;
if(config('pixelfed.enforce_account_limit') == true) {
$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
return Media::whereUserId($user->id)->sum('size') / 1000;
});
$limit = (int) config('pixelfed.max_account_size');
if ($size >= $limit) {
abort(403, 'Account size limit reached.');
}
}
$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
$photo = $request->file('file');
$mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), $mimes) == false) {
return;
}
$storagePath = MediaPathService::get($user, 2);
$path = $photo->store($storagePath);
$hash = \hash_file('sha256', $photo);
abort_if(MediaBlocklistService::exists($hash) == true, 451);
$media = new Media();
$media->status_id = null;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->original_sha256 = $hash;
$media->size = $photo->getSize();
$media->mime = $photo->getMimeType();
$media->filter_class = $filterClass;
$media->filter_name = $filterName;
$media->save();
$url = URL::temporarySignedRoute(
'temp-media', now()->addHours(1), ['profileId' => $profile->id, 'mediaId' => $media->id, 'timestamp' => time()]
);
switch ($media->mime) {
case 'image/jpeg':
case 'image/png':
ImageOptimize::dispatch($media);
break;
case 'video/mp4':
VideoThumbnail::dispatch($media);
$preview_url = '/storage/no-preview.png';
$url = '/storage/no-preview.png';
break;
default:
break;
}
$resource = new Fractal\Resource\Item($media, new MediaTransformer());
$res = $this->fractal->createData($resource)->toArray();
$res['preview_url'] = $url;
$res['url'] = $url;
return response()->json($res);
}
public function deleteMedia(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'id' => 'required|integer|min:1|exists:media,id'
]);
$media = Media::whereNull('status_id')
->whereUserId(Auth::id())
->findOrFail($request->input('id'));
Storage::delete($media->media_path);
Storage::delete($media->thumbnail_path);
$media->forceDelete();
return response()->json([
'msg' => 'Successfully deleted',
'code' => 200
]);
}
public function verifyCredentials(Request $request)
{
$user = $request->user();
abort_if(!$user, 403);
if($user->status != null) {
Auth::logout();
return redirect('/login');
}
$resource = new Fractal\Resource\Item($user->profile, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
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);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\{Profile, Status, User};
use Cache;
class InstanceApiController extends Controller {
protected function getData()
{
$contact = Cache::remember('api:v1:instance:contact', now()->addMinutes(1440), function() {
$admin = User::whereIsAdmin(true)->first()->profile;
return [
'id' => $admin->id,
'username' => $admin->username,
'acct' => $admin->username,
'display_name' => e($admin->name),
'locked' => (bool) $admin->is_private,
'created_at' => $admin->created_at->format('c'),
'note' => e($admin->bio),
'url' => $admin->url(),
'avatar' => $admin->avatarUrl(),
'avatar_static' => $admin->avatarUrl(),
'header' => null,
'header_static' => null,
'moved' => null,
'fields' => null,
'bot' => null,
];
});
$res = [
'uri' => config('pixelfed.domain.app'),
'title' => config('app.name'),
'description' => '',
'version' => config('pixelfed.version'),
'urls' => [],
'stats' => [
'user_count' => User::count(),
'status_count' => Status::whereNull('uri')->count(),
'domain_count' => Profile::whereNotNull('domain')
->groupBy('domain')
->pluck('domain')
->count()
],
'thumbnail' => '',
'languages' => [],
'contact_account' => $contact
];
return $res;
}
public function instance()
{
$res = Cache::remember('api:v1:instance', now()->addMinutes(60), function() {
return json_encode($this->getData());
});
return response($res)->header('Content-Type', 'application/json');
}
}

View file

@ -2,31 +2,108 @@
namespace App\Http\Controllers;
use Auth;
use App\Like;
use App\Http\Controllers\Api\BaseApiController;
use App\{
Follower,
Like,
Place,
Profile,
UserFilter
};
use Auth, Cache;
use Illuminate\Support\Facades\Redis;
use App\Util\Site\Config;
use Illuminate\Http\Request;
use App\Services\SuggestionService;
class ApiController extends Controller
class ApiController extends BaseApiController
{
public function __construct()
{
$this->middleware('auth');
}
// todo: deprecate and remove
public function hydrateLikes(Request $request)
{
$this->validate($request, [
'min' => 'nullable|integer|min:1',
'max' => 'nullable|integer',
]);
$profile = Auth::user()->profile;
$likes = Like::whereProfileId($profile->id)
->orderBy('id', 'desc')
->take(1000)
->pluck('status_id');
return response()->json($likes);
return response()->json([]);
}
public function siteConfiguration(Request $request)
{
return response()->json(Config::get());
}
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());
}
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

@ -2,7 +2,9 @@
namespace App\Http\Controllers\Auth;
use App\AccountLog;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class LoginController extends Controller
@ -25,7 +27,7 @@ class LoginController extends Controller
*
* @var string
*/
protected $redirectTo = '/home';
protected $redirectTo = '/';
/**
* Create a new controller instance.
@ -40,20 +42,43 @@ class LoginController extends Controller
/**
* Validate the user login request.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Http\Request $request
*
* @return void
*/
public function validateLogin($request)
{
$rules = [
$this->username() => 'required|string',
'password' => 'required|string',
$this->username() => 'required|email',
'password' => 'required|string|min:6',
];
if(config('pixelfed.recaptcha')) {
$rules['g-recaptcha-response'] = 'required|recaptcha';
}
$this->validate($request, $rules);
}
/**
* The user has been authenticated.
*
* @param \Illuminate\Http\Request $request
* @param mixed $user
*
* @return mixed
*/
protected function authenticated($request, $user)
{
if($user->status == 'deleted') {
return;
}
$log = new AccountLog();
$log->user_id = $user->id;
$log->item_id = $user->id;
$log->item_type = 'App\User';
$log->action = 'auth.login';
$log->message = 'Account Login';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
}
}

View file

@ -2,12 +2,15 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\User;
use App\Util\Lexer\RestrictedNames;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use App\Services\EmailService;
class RegisterController extends Controller
{
@ -29,7 +32,7 @@ class RegisterController extends Controller
*
* @var string
*/
protected $redirectTo = '/home';
protected $redirectTo = '/';
/**
* Create a new controller instance.
@ -39,30 +42,81 @@ class RegisterController extends Controller
public function __construct()
{
$this->middleware('guest');
$this->openRegistrationCheck();
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @param array $data
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
$this->validateUsername($data['username']);
if(config('database.default') == 'pgsql') {
$data['username'] = strtolower($data['username']);
$data['email'] = strtolower($data['email']);
}
$usernameRules = [
'required',
'min:2',
'max:15',
'unique:users',
function ($attribute, $value, $fail) {
$dash = substr_count($value, '-');
$underscore = substr_count($value, '_');
$period = substr_count($value, '.');
$rules = [
'name' => 'required|string|max:255',
'username' => 'required|alpha_dash|min:2|max:15|unique:users',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
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_alpha($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($value, $restricted)) {
return $fail('Username cannot be used.');
}
},
];
if(config('pixelfed.recaptcha')) {
$rules['g-recaptcha-response'] = 'required|recaptcha';
}
$emailRules = [
'required',
'string',
'email',
'max:255',
'unique:users',
function ($attribute, $value, $fail) {
$banned = EmailService::isBanned($value);
if($banned) {
return $fail('Email is invalid.');
}
},
];
$rules = [
'agecheck' => 'required|accepted',
'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
'username' => $usernameRules,
'email' => $emailRules,
'password' => 'required|string|min:12|confirmed',
];
return Validator::make($data, $rules);
}
@ -70,33 +124,69 @@ class RegisterController extends Controller
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @param array $data
*
* @return \App\User
*/
protected function create(array $data)
{
if(config('database.default') == 'pgsql') {
$data['username'] = strtolower($data['username']);
$data['email'] = strtolower($data['email']);
}
return User::create([
'name' => $data['name'],
'name' => $data['name'],
'username' => $data['username'],
'email' => $data['email'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
public function validateUsername($username)
/**
* Show the application registration form.
*
* @return \Illuminate\Http\Response
*/
public function showRegistrationForm()
{
$restricted = RestrictedNames::get();
if(config('pixelfed.open_registration')) {
$limit = config('pixelfed.max_users');
if($limit) {
abort_if($limit <= User::count(), 404);
return view('auth.register');
} else {
return view('auth.register');
}
} else {
abort(404);
}
}
if(in_array($username, $restricted)) {
/**
* Handle a registration request for the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function register(Request $request)
{
abort_if(config('pixelfed.open_registration') == false, 400);
$count = User::count();
$limit = config('pixelfed.max_users');
if(false == config('pixelfed.open_registration') || $limit && $limit <= $count) {
return abort(403);
}
}
public function openRegistrationCheck()
{
$openRegistration = config('pixelfed.open_registration');
if(false == $openRegistration) {
abort(403);
}
$this->validator($request->all())->validate();
event(new Registered($user = $this->create($request->all())));
$this->guard()->login($user);
return $this->registered($request, $user)
?: redirect($this->redirectPath());
}
}

View file

@ -2,9 +2,143 @@
namespace App\Http\Controllers;
use App\Avatar;
use App\Jobs\AvatarPipeline\AvatarOptimize;
use Auth;
use Cache;
use Illuminate\Http\Request;
use Storage;
class AvatarController extends Controller
{
//
public function __construct()
{
return $this->middleware('auth');
}
public function store(Request $request)
{
$this->validate($request, [
'avatar' => 'required|mimes:jpeg,png|max:'.config('pixelfed.max_avatar_size'),
]);
try {
$user = Auth::user();
$profile = $user->profile;
$file = $request->file('avatar');
$path = $this->getPath($user, $file);
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
$loc = $request->file('avatar')->storeAs($public, $name);
$avatar = Avatar::firstOrNew(['profile_id' => $profile->id]);
$currentAvatar = $avatar->recentlyCreated ? null : storage_path('app/'.$profile->avatar->media_path);
$avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null;
$avatar->save();
Cache::forget("avatar:{$profile->id}");
Cache::forget('user:account:id:'.$user->id);
AvatarOptimize::dispatch($user->profile, $currentAvatar);
} catch (Exception $e) {
}
return redirect()->back()->with('status', 'Avatar updated successfully. It may take a few minutes to update across the site.');
}
public function getPath($user, $file)
{
$basePath = storage_path('app/public/avatars');
$this->checkDir($basePath);
$id = $user->profile->id;
$path = $this->buildPath($id);
$dir = storage_path('app/'.$path);
$this->checkDir($dir);
$name = str_random(20).'_avatar.'.$file->guessExtension();
$res = ['root' => 'storage/app/'.$path, 'name' => $name, 'storage' => $path];
return $res;
}
public function checkDir($path)
{
if (!is_dir($path)) {
mkdir($path);
}
}
public function buildPath($id)
{
$padded = str_pad($id, 19, 0, STR_PAD_LEFT);
$parts = str_split($padded, 3);
foreach ($parts as $k => $part) {
if ($k == 0) {
$prefix = storage_path('app/public/avatars/'.$parts[0]);
$this->checkDir($prefix);
}
if ($k == 1) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1]);
$this->checkDir($prefix);
}
if ($k == 2) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2]);
$this->checkDir($prefix);
}
if ($k == 3) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 4) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 5) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 6) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5].'/'.$parts[6];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
}
return $avatarpath;
}
public function deleteAvatar(Request $request)
{
$user = Auth::user();
$profile = $user->profile;
$avatar = $profile->avatar;
if($avatar->media_path == 'public/avatars/default.png' || $avatar->thumb_path == 'public/avatars/default.png') {
return;
}
if(is_file(storage_path('app/' . $avatar->media_path))) {
@unlink(storage_path('app/' . $avatar->media_path));
}
if(is_file(storage_path('app/' . $avatar->thumb_path))) {
@unlink(storage_path('app/' . $avatar->thumb_path));
}
$avatar->media_path = 'public/avatars/default.png';
$avatar->thumb_path = 'public/avatars/default.png';
$avatar->change_count = $avatar->change_count + 1;
$avatar->save();
Cache::forget('avatar:' . $avatar->profile_id);
return response()->json(200);
}
}

View file

@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Bookmark;
use App\Status;
use Auth;
use App\{Bookmark, Profile, Status};
use Illuminate\Http\Request;
class BookmarkController extends Controller
@ -16,23 +17,26 @@ class BookmarkController extends Controller
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer|min:1'
'item' => 'required|integer|min:1',
]);
$profile = Auth::user()->profile;
$status = Status::findOrFail($request->input('item'));
$bookmark = Bookmark::firstOrCreate(
['status_id' => $status->id], ['profile_id' => $profile->id]
['status_id' => $status->id], ['profile_id' => $profile->id]
);
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
if (!$bookmark->wasRecentlyCreated) {
$bookmark->delete();
}
if ($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
} else {
$response = redirect()->back();
$response = redirect()->back();
}
return $response;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Auth;
use App\{
Circle,
CircleProfile,
Profile,
Status,
};
class CircleController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function home(Request $request)
{
$circles = Circle::whereProfileId(Auth::user()->profile->id)
->orderByDesc('created_at')
->paginate(10);
return view('account.circles.home', compact('circles'));
}
public function create(Request $request)
{
return view('account.circles.create');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => 'required|string|min:1',
'description' => 'nullable|string|max:255',
'scope' => [
'required',
'string',
Rule::in([
'public',
'private',
'unlisted',
'exclusive'
])
],
]);
$circle = Circle::firstOrCreate([
'profile_id' => Auth::user()->profile->id,
'name' => $request->input('name')
], [
'description' => $request->input('description'),
'scope' => $request->input('scope'),
'active' => false
]);
return redirect(route('account.circles'));
}
public function show(Request $request, $id)
{
$circle = Circle::findOrFail($id);
return view('account.circles.show', compact('circle'));
}
}

View file

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

View file

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

View file

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

View file

@ -3,52 +3,102 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth;
use DB;
use Cache;
use App\Comment;
use App\Jobs\CommentPipeline\CommentPipeline;
use App\Jobs\StatusPipeline\NewStatusPipeline;
use Auth, Hashids;
use App\{Comment, Profile, Status};
use App\Util\Lexer\Autolink;
use App\Profile;
use App\Status;
use App\UserFilter;
use League\Fractal;
use App\Transformer\Api\StatusTransformer;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class CommentController extends Controller
{
public function show(Request $request, $username, int $id, int $cid)
public function showAll(Request $request, $username, int $id)
{
$user = Profile::whereUsername($username)->firstOrFail();
$status = Status::whereProfileId($user->id)->whereInReplyToId($id)->findOrFail($cid);
return view('status.reply', compact('user', 'status'));
abort(404);
}
public function store(Request $request)
{
if(Auth::check() === false) { abort(403); }
$this->validate($request, [
'item' => 'required|integer',
'comment' => 'required|string|max:500'
]);
$comment = $request->input('comment');
$statusId = $request->item;
if (Auth::check() === false) {
abort(403);
}
$this->validate($request, [
'item' => 'required|integer|min:1',
'comment' => 'required|string|max:'.(int) config('pixelfed.max_caption_length'),
'sensitive' => 'nullable|boolean'
]);
$comment = $request->input('comment');
$statusId = $request->input('item');
$nsfw = $request->input('sensitive', false);
$user = Auth::user();
$profile = $user->profile;
$status = Status::findOrFail($statusId);
$user = Auth::user();
$profile = $user->profile;
$status = Status::findOrFail($statusId);
$reply = new Status();
$reply->profile_id = $profile->id;
$reply->caption = e($comment);
$reply->rendered = $comment;
$reply->in_reply_to_id = $status->id;
$reply->in_reply_to_profile_id = $status->profile_id;
$reply->save();
if($status->comments_disabled == true) {
return;
}
NewStatusPipeline::dispatch($reply, false);
CommentPipeline::dispatch($status, $reply);
$filtered = UserFilter::whereUserId($status->profile_id)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['block'])
->whereFilterableId($profile->id)
->exists();
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Comment saved', 'username' => $profile->username, 'url' => $reply->url(), 'profile' => $profile->url(), 'comment' => $reply->caption];
} else {
$response = redirect($status->url());
}
if($filtered == true) {
return;
}
return $response;
$reply = DB::transaction(function() use($comment, $status, $profile, $nsfw) {
$scope = $profile->is_private == true ? 'private' : 'public';
$autolink = Autolink::create()->autolink($comment);
$reply = new Status();
$reply->profile_id = $profile->id;
$reply->is_nsfw = $nsfw;
$reply->caption = e($comment);
$reply->rendered = $autolink;
$reply->in_reply_to_id = $status->id;
$reply->in_reply_to_profile_id = $status->profile_id;
$reply->scope = $scope;
$reply->visibility = $scope;
$reply->save();
$status->reply_count++;
$status->save();
return $reply;
});
NewStatusPipeline::dispatch($reply, false);
CommentPipeline::dispatch($status, $reply);
if ($request->ajax()) {
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$entity = new Fractal\Resource\Item($reply, new StatusTransformer());
$entity = $fractal->createData($entity)->toArray();
$response = [
'code' => 200,
'msg' => 'Comment saved',
'username' => $profile->username,
'url' => $reply->url(),
'profile' => $profile->url(),
'comment' => $reply->caption,
'entity' => $entity,
];
} else {
$response = redirect($status->url());
}
return $response;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth;
use App\Contact;
use App\Jobs\ContactPipeline\ContactPipeline;
class ContactController extends Controller
{
public function show(Request $request)
{
abort_if(!config('instance.email') && !config('instance.contact.enabled'), 404);
return view('site.contact');
}
public function store(Request $request)
{
abort_if(!config('instance.contact.enabled'), 404);
abort_if(!Auth::check(), 403);
$this->validate($request, [
'message' => 'required|string|min:5|max:500',
'request_response' => 'string|max:3'
]);
$message = $request->input('message');
$request_response = $request->input('request_response') == 'on' ? true : false;
$user = Auth::user();
$max = config('instance.contact.max_per_day');
$contact = Contact::whereUserId($user->id)
->whereDate('created_at', '>', now()->subDays($max))
->count();
if($contact >= $max) {
return redirect()->back()->with('error', 'You have recently sent a message. Please try again later.');
}
$contact = new Contact;
$contact->user_id = $user->id;
$contact->response_requested = $request_response;
$contact->message = $message;
$contact->response = '';
$contact->save();
ContactPipeline::dispatchNow($contact);
return redirect()->back()->with('status', 'Success - Your message has been sent to admins.');
}
}

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DeckController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function home()
{
return view('deck.index');
}
public function insights()
{
return view('deck.insights.index');
}
}

View file

@ -0,0 +1,665 @@
<?php
namespace App\Http\Controllers;
use Auth, Cache;
use Illuminate\Http\Request;
use App\{
DirectMessage,
Media,
Notification,
Profile,
Status,
User,
UserFilter,
UserSetting
};
use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
use App\Jobs\StatusPipeline\NewStatusPipeline;
use Illuminate\Support\Str;
use App\Util\ActivityPub\Helpers;
use App\Services\WebfingerService;
class DirectMessageController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function browse(Request $request)
{
$this->validate($request, [
'a' => 'nullable|string|in:inbox,sent,filtered',
'page' => 'nullable|integer|min:1|max:99'
]);
$profile = $request->user()->profile_id;
$action = $request->input('a', 'inbox');
$page = $request->input('page');
if($action == 'inbox') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereToId($profile)
->with(['author','status'])
->whereIsHidden(false)
->groupBy('from_id')
->latest()
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
if($action == 'sent') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereFromId($profile)
->with(['author','status'])
->groupBy('to_id')
->orderBy('createdAt', 'desc')
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
if($action == 'filtered') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereToId($profile)
->with(['author','status'])
->whereIsHidden(true)
->groupBy('from_id')
->orderBy('createdAt', 'desc')
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
return response()->json($dms);
}
public function create(Request $request)
{
$this->validate($request, [
'to_id' => 'required',
'message' => 'required|string|min:1|max:500',
'type' => 'required|in:text,emoji'
]);
$profile = $request->user()->profile;
$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
$msg = $request->input('message');
if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
if($recipient->follows($profile) == true) {
$hidden = false;
} else {
$hidden = true;
}
} else {
$hidden = false;
}
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = $msg;
$status->rendered = $msg;
$status->visibility = 'direct';
$status->scope = 'direct';
$status->in_reply_to_profile_id = $recipient->id;
$status->save();
$dm = new DirectMessage;
$dm->to_id = $recipient->id;
$dm->from_id = $profile->id;
$dm->status_id = $status->id;
$dm->is_hidden = $hidden;
$dm->type = $request->input('type');
$dm->save();
if(filter_var($msg, FILTER_VALIDATE_URL)) {
if(Helpers::validateUrl($msg)) {
$dm->type = 'link';
$dm->meta = [
'domain' => parse_url($msg, PHP_URL_HOST),
'local' => parse_url($msg, PHP_URL_HOST) ==
parse_url(config('app.url'), PHP_URL_HOST)
];
$dm->save();
}
}
$nf = UserFilter::whereUserId($recipient->id)
->whereFilterableId($profile->id)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->exists();
if($recipient->domain == null && $hidden == false && !$nf) {
$notification = new Notification();
$notification->profile_id = $recipient->id;
$notification->actor_id = $profile->id;
$notification->action = 'dm';
$notification->message = $dm->toText();
$notification->rendered = $dm->toHtml();
$notification->item_id = $dm->id;
$notification->item_type = "App\DirectMessage";
$notification->save();
}
if($recipient->domain) {
$this->remoteDeliver($dm);
}
$res = [
'id' => (string) $dm->id,
'isAuthor' => $profile->id == $dm->from_id,
'reportId' => (string) $dm->status_id,
'hidden' => (bool) $dm->is_hidden,
'type' => $dm->type,
'text' => $dm->status->caption,
'media' => null,
'timeAgo' => $dm->created_at->diffForHumans(null,null,true),
'seen' => $dm->read_at != null,
'meta' => $dm->meta
];
return response()->json($res);
}
public function thread(Request $request)
{
$this->validate($request, [
'pid' => 'required'
]);
$uid = $request->user()->profile_id;
$pid = $request->input('pid');
$max_id = $request->input('max_id');
$min_id = $request->input('min_id');
$r = Profile::findOrFail($pid);
// $r = Profile::whereNull('domain')->findOrFail($pid);
if($min_id) {
$res = DirectMessage::select('*')
->where('id', '>', $min_id)
->where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
} else if ($max_id) {
$res = DirectMessage::select('*')
->where('id', '<', $max_id)
->where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
} else {
$res = DirectMessage::where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
}
$res = $res->map(function($s) use ($uid){
return [
'id' => (string) $s->id,
'hidden' => (bool) $s->is_hidden,
'isAuthor' => $uid == $s->from_id,
'type' => $s->type,
'text' => $s->status->caption,
'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
'timeAgo' => $s->created_at->diffForHumans(null,null,true),
'seen' => $s->read_at != null,
'reportId' => (string) $s->status_id,
'meta' => json_decode($s->meta,true)
];
});
$w = [
'id' => (string) $r->id,
'name' => $r->name,
'username' => $r->username,
'avatar' => $r->avatarUrl(),
'url' => $r->url(),
'muted' => UserFilter::whereUserId($uid)
->whereFilterableId($r->id)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->first() ? true : false,
'isLocal' => (bool) !$r->domain,
'domain' => $r->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => '',
'messages' => $res
];
return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function delete(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$sid = $request->input('id');
$pid = $request->user()->profile_id;
$dm = DirectMessage::whereFromId($pid)
->whereStatusId($sid)
->firstOrFail();
$status = Status::whereProfileId($pid)
->findOrFail($dm->status_id);
if($dm->recipient->domain) {
$dmc = $dm;
$this->remoteDelete($dmc);
}
$status->delete();
$dm->delete();
return [200];
}
public function get(Request $request, $id)
{
$pid = $request->user()->profile_id;
$dm = DirectMessage::whereStatusId($id)->firstOrFail();
abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404);
return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function mediaUpload(Request $request)
{
$this->validate($request, [
'file' => function() {
return [
'required',
'mimes:' . config('pixelfed.media_types'),
'max:' . config('pixelfed.max_photo_size'),
];
},
'to_id' => 'required'
]);
$user = $request->user();
$profile = $user->profile;
$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
if($recipient->follows($profile) == true) {
$hidden = false;
} else {
$hidden = true;
}
} else {
$hidden = false;
}
if(config('pixelfed.enforce_account_limit') == true) {
$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
return Media::whereUserId($user->id)->sum('size') / 1000;
});
$limit = (int) config('pixelfed.max_account_size');
if ($size >= $limit) {
abort(403, 'Account size limit reached.');
}
}
$photo = $request->file('file');
$mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), $mimes) == false) {
abort(403, 'Invalid or unsupported mime type.');
}
$storagePath = MediaPathService::get($user, 2) . Str::random(8);
$path = $photo->store($storagePath);
$hash = \hash_file('sha256', $photo);
abort_if(MediaBlocklistService::exists($hash) == true, 451);
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = null;
$status->rendered = null;
$status->visibility = 'direct';
$status->scope = 'direct';
$status->in_reply_to_profile_id = $recipient->id;
$status->save();
$media = new Media();
$media->status_id = $status->id;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->original_sha256 = $hash;
$media->size = $photo->getSize();
$media->mime = $photo->getMimeType();
$media->caption = null;
$media->filter_class = null;
$media->filter_name = null;
$media->save();
$dm = new DirectMessage;
$dm->to_id = $recipient->id;
$dm->from_id = $profile->id;
$dm->status_id = $status->id;
$dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo';
$dm->is_hidden = $hidden;
$dm->save();
if($recipient->domain) {
$this->remoteDeliver($dm);
}
return [
'id' => $dm->id,
'reportId' => (string) $dm->status_id,
'type' => $dm->type,
'url' => $media->url()
];
}
public function composeLookup(Request $request)
{
$this->validate($request, [
'q' => 'required|string|min:2|max:50',
'remote' => 'nullable|boolean',
]);
$q = $request->input('q');
$r = $request->input('remote');
if($r && Helpers::validateUrl($q)) {
Helpers::profileFetch($q);
}
if(Str::of($q)->startsWith('@')) {
if(strlen($q) < 3) {
return [];
}
if(substr_count($q, '@') == 2) {
WebfingerService::lookup($q);
}
$q = mb_substr($q, 1);
}
$blocked = UserFilter::whereFilterableType('App\Profile')
->whereFilterType('block')
->whereFilterableId($request->user()->profile_id)
->pluck('user_id');
$blocked->push($request->user()->profile_id);
$results = Profile::select('id','domain','username')
->whereNotIn('id', $blocked)
->where('username','like','%'.$q.'%')
->orderBy('domain')
->limit(8)
->get()
->map(function($r) {
return [
'local' => (bool) !$r->domain,
'id' => (string) $r->id,
'name' => $r->username,
'privacy' => true,
'avatar' => $r->avatarUrl()
];
});
return $results;
}
public function read(Request $request)
{
$this->validate($request, [
'pid' => 'required',
'sid' => 'required'
]);
$pid = $request->input('pid');
$sid = $request->input('sid');
$dms = DirectMessage::whereToId($request->user()->profile_id)
->whereFromId($pid)
->where('status_id', '>=', $sid)
->get();
$now = now();
foreach($dms as $dm) {
$dm->read_at = $now;
$dm->save();
}
return response()->json($dms->pluck('id'));
}
public function mute(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$fid = $request->input('id');
$pid = $request->user()->profile_id;
UserFilter::firstOrCreate(
[
'user_id' => $pid,
'filterable_id' => $fid,
'filterable_type' => 'App\Profile',
'filter_type' => 'dm.mute'
]
);
return [200];
}
public function unmute(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$fid = $request->input('id');
$pid = $request->user()->profile_id;
$f = UserFilter::whereUserId($pid)
->whereFilterableId($fid)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->firstOrFail();
$f->delete();
return [200];
}
public function remoteDeliver($dm)
{
$profile = $dm->author;
$url = $dm->recipient->inbox_url;
$tags = [
[
'type' => 'Mention',
'href' => $dm->recipient->permalink(),
'name' => $dm->recipient->emailUrl(),
]
];
$body = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
]
],
'id' => $dm->status->permalink(),
'type' => 'Create',
'actor' => $dm->status->profile->permalink(),
'published' => $dm->status->created_at->toAtomString(),
'to' => [$dm->recipient->permalink()],
'cc' => [],
'object' => [
'id' => $dm->status->url(),
'type' => 'Note',
'summary' => null,
'content' => $dm->status->rendered ?? $dm->status->caption,
'inReplyTo' => null,
'published' => $dm->status->created_at->toAtomString(),
'url' => $dm->status->url(),
'attributedTo' => $dm->status->profile->permalink(),
'to' => [$dm->recipient->permalink()],
'cc' => [],
'sensitive' => (bool) $dm->status->is_nsfw,
'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) {
return [
'type' => $media->activityVerb(),
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => $media->caption,
];
})->toArray(),
'tag' => $tags,
]
];
Helpers::sendSignedObject($profile, $url, $body);
}
public function remoteDelete($dm)
{
$profile = $dm->author;
$url = $dm->recipient->inbox_url;
$body = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
]
],
'id' => $dm->status->permalink('#delete'),
'to' => [
'https://www.w3.org/ns/activitystreams#Public'
],
'type' => 'Delete',
'actor' => $dm->status->profile->permalink(),
'object' => [
'id' => $dm->status->url(),
'type' => 'Tombstone'
]
];
Helpers::sendSignedObject($profile, $url, $body);
}
}

View file

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

View file

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

View file

@ -2,30 +2,238 @@
namespace App\Http\Controllers;
use App\{
DiscoverCategory,
Follower,
Hashtag,
HashtagFollow,
Profile,
Status,
StatusHashtag,
UserFilter
};
use Auth, DB, Cache;
use Illuminate\Http\Request;
use App\{Hashtag, Follower, Profile, Status, StatusHashtag};
use Auth;
use App\Transformer\Api\AccountTransformer;
use App\Transformer\Api\AccountWithStatusesTransformer;
use App\Transformer\Api\StatusTransformer;
use App\Transformer\Api\StatusStatelessTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Services\StatusHashtagService;
class DiscoverController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
public function home()
public function home(Request $request)
{
$following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
$people = Profile::inRandomOrder()->where('id', '!=', Auth::user()->profile->id)->whereNotIn('id', $following)->take(3)->get();
$posts = Status::whereHas('media')->where('profile_id', '!=', Auth::user()->profile->id)->whereNotIn('profile_id', $following)->orderBy('created_at', 'desc')->take('21')->get();
return view('discover.home', compact('people', 'posts'));
abort_if(!Auth::check(), 403);
return view('discover.home');
}
public function showTags(Request $request, $hashtag)
{
$tag = Hashtag::whereSlug($hashtag)->firstOrFail();
$posts = $tag->posts()->has('media')->orderBy('id','desc')->paginate(12);
$count = $tag->posts()->has('media')->orderBy('id','desc')->count();
return view('discover.tags.show', compact('tag', 'posts', 'count'));
abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
$tag = Hashtag::whereName($hashtag)
->orWhere('slug', $hashtag)
->firstOrFail();
$tagCount = StatusHashtagService::count($tag->id);
return view('discover.tags.show', compact('tag', 'tagCount'));
}
public function showCategory(Request $request, $slug)
{
abort_if(!Auth::check(), 403);
$tag = DiscoverCategory::whereActive(true)
->whereSlug($slug)
->firstOrFail();
$posts = Cache::remember('discover:category-'.$tag->id.':posts', now()->addMinutes(15), function() use ($tag) {
$tagids = $tag->hashtags->pluck('id')->toArray();
$sids = StatusHashtag::whereIn('hashtag_id', $tagids)->orderByDesc('status_id')->take(500)->pluck('status_id')->toArray();
$posts = Status::whereScope('public')->whereIn('id', $sids)->whereNull('uri')->whereType('photo')->whereNull('in_reply_to_id')->whereNull('reblog_of_id')->orderByDesc('created_at')->take(39)->get();
return $posts;
});
$tag->posts_count = Cache::remember('discover:category-'.$tag->id.':posts_count', now()->addMinutes(30), function() use ($tag) {
return $tag->posts()->whereScope('public')->count();
});
return view('discover.tags.category', compact('tag', 'posts'));
}
public function showLoops(Request $request)
{
if(config('exp.loops') != true) {
return redirect('/');
}
return view('discover.loops.home');
}
public function loopsApi(Request $request)
{
abort_if(!config('exp.loops'), 403);
// todo proper pagination, maybe LoopService
$res = Cache::remember('discover:loops:recent', now()->addHours(6), function() {
$loops = Status::whereType('video')
->whereNull('uri')
->whereScope('public')
->latest()
->take(18)
->get();
$resource = new Fractal\Resource\Collection($loops, new StatusStatelessTransformer());
return $this->fractal->createData($resource)->toArray();
});
return $res;
}
public function loopWatch(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(!config('exp.loops'), 403);
$this->validate($request, [
'id' => 'integer|min:1'
]);
$id = $request->input('id');
// todo log loops
return response()->json(200);
}
public function getHashtags(Request $request)
{
$auth = Auth::check();
abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
$this->validate($request, [
'hashtag' => 'required|string|min:1|max:124',
'page' => 'nullable|integer|min:1|max:' . ($auth ? 29 : 10)
]);
$page = $request->input('page') ?? '1';
$end = $page > 1 ? $page * 9 : 0;
$tag = $request->input('hashtag');
$hashtag = Hashtag::whereName($tag)->firstOrFail();
$res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end);
if($page == 1) {
$res['follows'] = HashtagFollow::whereUserId(Auth::id())->whereHashtagId($hashtag->id)->exists();
}
return $res;
}
public function profilesDirectory(Request $request)
{
return redirect('/')->with('statusRedirect', 'The Profile Directory is unavailable at this time.');
return view('discover.profiles.home');
}
public function profilesDirectoryApi(Request $request)
{
$this->validate($request, [
'page' => 'integer|max:10'
]);
return ['error' => 'Temporarily unavailable.'];
$page = $request->input('page') ?? 1;
$key = 'discover:profiles:page:' . $page;
$ttl = now()->addHours(12);
$res = Cache::remember($key, $ttl, function() {
$profiles = Profile::whereNull('domain')
->whereNull('status')
->whereIsPrivate(false)
->has('statuses')
->whereIsSuggestable(true)
// ->inRandomOrder()
->simplePaginate(8);
$resource = new Fractal\Resource\Collection($profiles, new AccountTransformer());
return $this->fractal->createData($resource)->toArray();
});
return $res;
}
public function trendingApi(Request $request)
{
$this->validate($request, [
'range' => 'nullable|string|in:daily,monthly,alltime'
]);
$range = $request->filled('range') ?
$request->input('range') == 'alltime' ? '-1' :
($request->input('range') == 'daily' ? 1 : 31) : 1;
$key = ':api:discover:trending:v1:range:' . $range;
$ttl = now()->addHours(2);
$res = Cache::remember($key, $ttl, function() use($range) {
if($range == '-1') {
$res = Status::orderBy('likes_count','desc')
->take(12)
->get();
} else {
$res = Status::orderBy('likes_count','desc')
->take(12)
->where('created_at', '>', now()->subDays($range))
->get();
}
$resource = new Fractal\Resource\Collection($res, new StatusStatelessTransformer());
return $this->fractal->createData($resource)->toArray();
});
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function trendingHashtags(Request $request)
{
$res = StatusHashtag::select('hashtag_id', \DB::raw('count(*) as total'))
->groupBy('hashtag_id')
->orderBy('total','desc')
->where('created_at', '>', now()->subDays(4))
->take(9)
->get()
->map(function($h) {
$hashtag = $h->hashtag;
return [
'id' => $hashtag->id,
'total' => $h->total,
'name' => '#'.$hashtag->name,
'url' => $hashtag->url('?src=dsh1')
];
});
return $res;
}
public function trendingPlaces(Request $request)
{
$res = Status::select('place_id',DB::raw('count(place_id) as total'))
->whereNotNull('place_id')
->where('created_at','>',now()->subDays(14))
->groupBy('place_id')
->orderBy('total')
->limit(4)
->get()
->map(function($s){
$p = $s->place;
return [
'name' => $p->name,
'country' => $p->country,
'url' => $p->url()
];
});
return $res;
}
}

View file

@ -2,137 +2,156 @@
namespace App\Http\Controllers;
use Auth;
use App\Profile;
use League\Fractal;
use Illuminate\Http\Request;
use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger;
use App\Transformer\ActivityPub\{
ProfileOutbox,
ProfileTransformer
use App\Jobs\InboxPipeline\{
InboxWorker,
InboxValidator
};
use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
use App\{
AccountLog,
Like,
Profile,
Status,
User
};
use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger;
use Auth;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use League\Fractal;
use App\Util\Site\Nodeinfo;
use App\Util\ActivityPub\{
Helpers,
HttpSignature,
Outbox
};
use Zttp\Zttp;
class FederationController extends Controller
{
public function authCheck()
{
if(!Auth::check()) {
abort(403);
}
return;
}
public function remoteFollow()
{
$this->authCheck();
return view('federation.remotefollow');
}
public function remoteFollowStore(Request $request)
{
$this->authCheck();
$this->validate($request, [
'url' => 'required|string'
]);
if(config('pixelfed.remote_follow_enabled') !== true) {
abort(403);
}
$follower = Auth::user()->profile;
$url = $request->input('url');
RemoteFollowPipeline::dispatch($follower, $url);
return redirect()->back();
}
public function nodeinfoWellKnown()
{
$res = [
'links' => [
[
'href' => config('pixelfed.nodeinfo.url'),
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0'
]
]
];
return response()->json($res);
abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::wellKnown())
->header('Access-Control-Allow-Origin','*');
}
public function nodeinfo()
{
$res = [
'metadata' => [
'nodeName' => config('app.name'),
'software' => [
'homepage' => 'https://pixelfed.org',
'github' => 'https://github.com/pixelfed',
'follow' => 'https://mastodon.social/@pixelfed'
],
/*
TODO: Custom Features for Trending
'customFeatures' => [
'trending' => [
'description' => 'Trending API for federated discovery',
'api' => [
'url' => null,
'docs' => null
],
],
],
*/
],
'openRegistrations' => config('pixelfed.open_registration'),
'protocols' => [
'activitypub'
],
'services' => [
'inbound' => [],
'outbound' => []
],
'software' => [
'name' => 'pixelfed',
'version' => config('pixelfed.version')
],
'usage' => [
'localPosts' => \App\Status::whereLocal(true)->count(),
'users' => [
'total' => \App\User::count()
]
],
'version' => '2.0'
];
return response()->json($res);
abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::get())
->header('Access-Control-Allow-Origin','*');
}
public function webfinger(Request $request)
{
$this->validate($request, ['resource'=>'required']);
$resource = $request->input('resource');
$parsed = Nickname::normalizeProfileUrl($resource);
$username = $parsed['username'];
$user = Profile::whereUsername($username)->firstOrFail();
$webfinger = (new Webfinger($user))->generate();
return response()->json($webfinger);
abort_if(!config('federation.webfinger.enabled'), 400);
abort_if(!$request->filled('resource'), 400);
$resource = $request->input('resource');
$parsed = Nickname::normalizeProfileUrl($resource);
if($parsed['domain'] !== config('pixelfed.domain.app')) {
abort(400);
}
$username = $parsed['username'];
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
if($profile->status != null) {
return ProfileController::accountCheck($profile);
}
$webfinger = (new Webfinger($profile))->generate();
return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT)
->header('Access-Control-Allow-Origin','*');
}
public function hostMeta(Request $request)
{
abort_if(!config('federation.webfinger.enabled'), 404);
$path = route('well-known.webfinger');
$xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>';
return response($xml)->header('Content-Type', 'application/xrd+xml');
}
public function userOutbox(Request $request, $username)
{
if(config('pixelfed.activitypub_enabled') == false) {
abort(403);
}
abort_if(!config('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.outbox'), 404);
$user = Profile::whereNull('remote_url')->whereUsername($username)->firstOrFail();
$timeline = $user->statuses()->orderBy('created_at','desc')->paginate(10);
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($user, new ProfileOutbox);
$res = $fractal->createData($resource)->toArray();
return response()->json($res['data']);
$profile = Profile::whereNull('domain')
->whereNull('status')
->whereIsPrivate(false)
->whereUsername($username)
->firstOrFail();
$key = 'ap:outbox:latest_10:pid:' . $profile->id;
$ttl = now()->addMinutes(15);
$res = Cache::remember($key, $ttl, function() use($profile) {
return Outbox::get($profile);
});
return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
}
public function userInbox(Request $request, $username)
{
abort_if(!config('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.inbox'), 404);
$headers = $request->headers->all();
$payload = $request->getContent();
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
return;
}
public function userFollowing(Request $request, $username)
{
abort_if(!config('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(),
'type' => 'OrderedCollectionPage',
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
public function userFollowers(Request $request, $username)
{
abort_if(!config('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(),
'type' => 'OrderedCollectionPage',
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
}

View file

@ -2,41 +2,163 @@
namespace App\Http\Controllers;
use Auth;
use App\{Follower, Profile};
use App\{
Follower,
FollowRequest,
Profile,
UserFilter
};
use Auth, Cache;
use Illuminate\Http\Request;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Util\ActivityPub\Helpers;
class FollowerController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('auth');
}
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer',
]);
$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);
}
}
$user = Auth::user()->profile;
$target = Profile::where('id', '!=', $user->id)->findOrFail($request->input('item'));
protected function handleFollowRequest($item, $force)
{
$user = Auth::user()->profile;
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
$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($isFollowing == 0) {
$follower = new Follower;
$follower->profile_id = $user->id;
$follower->following_id = $target->id;
$follower->save();
FollowPipeline::dispatch($follower);
} else {
$follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->firstOrFail();
$follower->delete();
}
if($blocked == true) {
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
return redirect()->back();
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);
}
} 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);
}
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();
}
}
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);
return $target->url();
}
public function sendFollow($user, $target)
{
if($target->domain == null || $user->domain != null) {
return;
}
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $user->permalink('#follow/'.$target->id),
'type' => 'Follow',
'actor' => $user->permalink(),
'object' => $target->permalink()
];
$inbox = $target->sharedInbox ?? $target->inbox_url;
Helpers::sendSignedObject($user, $inbox, $payload);
}
public function sendUndoFollow($user, $target)
{
if($target->domain == null || $user->domain != null) {
return;
}
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $user->permalink('#follow/'.$target->id.'/undo'),
'type' => 'Undo',
'actor' => $user->permalink(),
'object' => [
'id' => $user->permalink('#follows/'.$target->id),
'actor' => $user->permalink(),
'object' => $target->permalink(),
'type' => 'Follow'
]
];
$inbox = $target->sharedInbox ?? $target->inbox_url;
Helpers::sendSignedObject($user, $inbox, $payload);
}
}

View file

@ -2,8 +2,6 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HashtagController extends Controller
{
//

View file

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

View file

@ -21,8 +21,8 @@ class HomeController extends Controller
*
* @return \Illuminate\Http\Response
*/
public function index()
public function index(Request $request)
{
return view('home');
return redirect('/');
}
}

View file

@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Import;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Auth, DB;
use App\{
ImportData,
ImportJob,
Profile,
User
};
use App\Jobs\ImportPipeline\ImportInstagram;
trait Instagram
{
public function instagram()
{
return view('settings.import.instagram.home');
}
public function instagramStart(Request $request)
{
$completed = ImportJob::whereProfileId(Auth::user()->profile->id)
->whereService('instagram')
->whereNotNull('completed_at')
->exists();
if($completed == true) {
return redirect(route('settings'))->with(['error' => 'You can only import from Instagram once during the beta. Please report any issues!']);
}
$job = $this->instagramRedirectOrNew();
return redirect($job->url());
}
protected function instagramRedirectOrNew()
{
$profile = Auth::user()->profile;
$exists = ImportJob::whereProfileId($profile->id)
->whereService('instagram')
->whereNull('completed_at')
->exists();
if($exists) {
$job = ImportJob::whereProfileId($profile->id)
->whereService('instagram')
->whereNull('completed_at')
->first();
} else {
$job = new ImportJob;
$job->profile_id = $profile->id;
$job->service = 'instagram';
$job->uuid = (string) Str::uuid();
$job->stage = 1;
$job->save();
}
return $job;
}
public function instagramStepOne(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(1)
->firstOrFail();
return view('settings.import.instagram.step-one', compact('profile', 'job'));
}
public function instagramStepOneStore(Request $request, $uuid)
{
$max = 'max:' . config('pixelfed.import.instagram.limits.size');
$this->validate($request, [
'media.*' => 'required|mimes:bin,jpeg,png,gif|'.$max,
//'mediajson' => 'required|file|mimes:json'
]);
$media = $request->file('media');
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(1)
->firstOrFail();
$limit = config('pixelfed.import.instagram.limits.posts');
foreach ($media as $k => $v) {
$original = $v->getClientOriginalName();
if(strlen($original) < 32 || $k > $limit) {
continue;
}
$storagePath = "import/{$job->uuid}";
$path = $v->store($storagePath);
DB::transaction(function() use ($profile, $job, $path, $original) {
$data = new ImportData;
$data->profile_id = $profile->id;
$data->job_id = $job->id;
$data->service = 'instagram';
$data->path = $path;
$data->stage = $job->stage;
$data->original_name = $original;
$data->save();
});
}
DB::transaction(function() use ($profile, $job) {
$job->stage = 2;
$job->save();
});
return redirect($job->url());
}
public function instagramStepTwo(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(2)
->firstOrFail();
return view('settings.import.instagram.step-two', compact('profile', 'job'));
}
public function instagramStepTwoStore(Request $request, $uuid)
{
$this->validate($request, [
'media' => 'required|file|max:1000'
]);
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(2)
->firstOrFail();
$media = $request->file('media');
$file = file_get_contents($media);
$json = json_decode($file, true, 5);
if(!$json || !isset($json['photos'])) {
return abort(500);
}
$storagePath = "import/{$job->uuid}";
$path = $media->store($storagePath);
$job->media_json = $path;
$job->stage = 3;
$job->save();
return redirect($job->url());
}
public function instagramStepThree(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereService('instagram')
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(3)
->firstOrFail();
return view('settings.import.instagram.step-three', compact('profile', 'job'));
}
public function instagramStepThreeStore(Request $request, $uuid)
{
$profile = Auth::user()->profile;
try {
$import = ImportJob::whereProfileId($profile->id)
->where('uuid', $uuid)
->whereNotNull('media_json')
->whereNull('completed_at')
->whereStage(3)
->firstOrFail();
ImportInstagram::dispatch($import);
} catch (Exception $e) {
\Log::info($e);
}
return redirect(route('settings'))->with(['status' => 'Import successful! It may take a few minutes to finish.']);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\Import;
use Illuminate\Http\Request;
trait Mastodon
{
public function mastodon()
{
return view('settings.import.mastodon.home');
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ImportController extends Controller
{
use Import\Instagram, Import\Mastodon;
public function __construct()
{
$this->middleware('auth');
if(config('pixelfed.import.instagram.enabled') != true) {
abort(404, 'Feature not enabled');
}
}
}

View file

@ -0,0 +1,478 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\{
DirectMessage,
DiscoverCategory,
Hashtag,
Follower,
Like,
Media,
MediaTag,
Notification,
Profile,
StatusHashtag,
Status,
UserFilter,
};
use Auth,Cache;
use Carbon\Carbon;
use League\Fractal;
use App\Transformer\Api\{
AccountTransformer,
StatusTransformer,
// StatusMediaContainerTransformer,
};
use App\Util\Media\Filter;
use App\Jobs\StatusPipeline\NewStatusPipeline;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
use App\Services\MediaTagService;
use App\Services\ModLogService;
use App\Services\PublicTimelineService;
class InternalApiController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
// deprecated v2 compose api
public function compose(Request $request)
{
return redirect('/');
}
// deprecated
public function discover(Request $request)
{
return;
}
public function discoverPosts(Request $request)
{
$profile = Auth::user()->profile;
$pid = $profile->id;
$following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(15), function() use ($pid) {
return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
});
$filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(15), function() use($pid) {
$private = Profile::whereIsPrivate(true)
->orWhere('unlisted', true)
->orWhere('status', '!=', null)
->pluck('id')
->toArray();
$filters = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')
->toArray();
return array_merge($private, $filters);
});
$following = array_merge($following, $filters);
$posts = Status::select(
'id',
'caption',
'profile_id',
'type'
)
->whereNull('uri')
->whereIn('type', ['photo','photo:album', 'video'])
->whereIsNsfw(false)
->whereVisibility('public')
->whereNotIn('profile_id', $following)
->whereDate('created_at', '>', now()->subMonths(3))
->with('media')
->inRandomOrder()
->latest()
->take(39)
->get();
$res = [
'posts' => $posts->map(function($post) {
return [
'type' => $post->type,
'url' => $post->url(),
'thumb' => $post->thumb(),
];
})
];
return response()->json($res);
}
public function directMessage(Request $request, $profileId, $threadId)
{
$profile = Auth::user()->profile;
if($profileId != $profile->id) {
abort(403);
}
$msg = DirectMessage::whereToId($profile->id)
->orWhere('from_id',$profile->id)
->findOrFail($threadId);
$thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
->whereIn('from_id', [$profile->id,$msg->from_id])
->orderBy('created_at', 'asc')
->paginate(30);
return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
}
public function statusReplies(Request $request, int $id)
{
$parent = Status::whereScope('public')->findOrFail($id);
$children = Status::whereInReplyToId($parent->id)
->orderBy('created_at', 'desc')
->take(3)
->get();
$resource = new Fractal\Resource\Collection($children, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function stories(Request $request)
{
}
public function discoverCategories(Request $request)
{
$categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
$res = $categories->map(function($item) {
return [
'name' => $item->name,
'url' => $item->url(),
'thumb' => $item->thumb()
];
});
return response()->json($res);
}
public function modAction(Request $request)
{
abort_unless(Auth::user()->is_admin, 400);
$this->validate($request, [
'action' => [
'required',
'string',
Rule::in([
'addcw',
'remcw',
'unlist'
])
],
'item_id' => 'required|integer|min:1',
'item_type' => [
'required',
'string',
Rule::in(['profile', 'status'])
]
]);
$action = $request->input('action');
$item_id = $request->input('item_id');
$item_type = $request->input('item_type');
switch($action) {
case 'addcw':
$status = Status::findOrFail($item_id);
$status->is_nsfw = true;
$status->save();
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
break;
case 'remcw':
$status = Status::findOrFail($item_id);
$status->is_nsfw = false;
$status->save();
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'remove_cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
break;
case 'unlist':
$status = Status::whereScope('public')->findOrFail($item_id);
$status->scope = $status->visibility = 'unlisted';
$status->save();
PublicTimelineService::del($status->id);
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'unlist',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
break;
}
return ['msg' => 200];
}
public function composePost(Request $request)
{
$this->validate($request, [
'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
'media.*' => 'required',
'media.*.id' => 'required|integer|min:1',
'media.*.filter_class' => 'nullable|alpha_dash|max:30',
'media.*.license' => 'nullable|string|max:140',
'media.*.alt' => 'nullable|string|max:140',
'cw' => 'nullable|boolean',
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable',
'comments_disabled' => 'nullable',
'tagged' => 'nullable'
]);
if(config('costar.enabled') == true) {
$blockedKeywords = config('costar.keyword.block');
if($blockedKeywords !== null && $request->caption) {
$keywords = config('costar.keyword.block');
foreach($keywords as $kw) {
if(Str::contains($request->caption, $kw) == true) {
abort(400, 'Invalid object');
}
}
}
}
$user = Auth::user();
$profile = $user->profile;
$visibility = $request->input('visibility');
$medias = $request->input('media');
$attachments = [];
$status = new Status;
$mimes = [];
$place = $request->input('place');
$cw = $request->input('cw');
$tagged = $request->input('tagged');
foreach($medias as $k => $media) {
if($k + 1 > config('pixelfed.max_album_length')) {
continue;
}
$m = Media::findOrFail($media['id']);
if($m->profile_id !== $profile->id || $m->status_id) {
abort(403, 'Invalid media id');
}
$m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null;
$m->license = $media['license'];
$m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null;
$m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
if($cw == true || $profile->cw == true) {
$m->is_nsfw = $cw;
$status->is_nsfw = $cw;
}
$m->save();
$attachments[] = $m;
array_push($mimes, $m->mime);
}
$mediaType = StatusController::mimeTypeCheck($mimes);
if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
abort(400, __('exception.compose.invalid.album'));
}
if($place && is_array($place)) {
$status->place_id = $place['id'];
}
if($request->filled('comments_disabled')) {
$status->comments_disabled = (bool) $request->input('comments_disabled');
}
$status->caption = strip_tags($request->caption);
$status->scope = 'draft';
$status->profile_id = $profile->id;
$status->save();
foreach($attachments as $media) {
$media->status_id = $status->id;
$media->save();
}
$visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
$cw = $profile->cw == true ? true : $cw;
$status->is_nsfw = $cw;
$status->visibility = $visibility;
$status->scope = $visibility;
$status->type = $mediaType;
$status->save();
foreach($tagged as $tg) {
$mt = new MediaTag;
$mt->status_id = $status->id;
$mt->media_id = $status->media->first()->id;
$mt->profile_id = $tg['id'];
$mt->tagged_username = $tg['name'];
$mt->is_public = true; // (bool) $tg['privacy'] ?? 1;
$mt->metadata = json_encode([
'_v' => 1,
]);
$mt->save();
MediaTagService::set($mt->status_id, $mt->profile_id);
MediaTagService::sendNotification($mt);
}
NewStatusPipeline::dispatch($status);
Cache::forget('user:account:id:'.$profile->user_id);
Cache::forget('profile:status_count:'.$profile->id);
Cache::forget($user->storageUsedKey());
return $status->url();
}
public function bookmarks(Request $request)
{
$statuses = Auth::user()->profile
->bookmarks()
->withCount(['likes','comments'])
->orderBy('created_at', 'desc')
->simplePaginate(10);
$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function accountStatuses(Request $request, $id)
{
$this->validate($request, [
'only_media' => 'nullable',
'pinned' => 'nullable',
'exclude_replies' => 'nullable',
'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'limit' => 'nullable|integer|min:1|max:24'
]);
$profile = Profile::whereNull('status')->findOrFail($id);
$limit = $request->limit ?? 9;
$max_id = $request->max_id;
$min_id = $request->min_id;
$scope = $request->only_media == true ?
['photo', 'photo:album', 'video', 'video:album'] :
['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
if($profile->is_private) {
if(!Auth::check()) {
return response()->json([]);
}
$pid = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray();
});
$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
} else {
if(Auth::check()) {
$pid = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray();
});
$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
} else {
$visibility = ['public', 'unlisted'];
}
}
$dir = $min_id ? '>' : '<';
$id = $min_id ?? $max_id;
$timeline = Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'likes_count',
'reblogs_count',
'scope',
'local',
'created_at',
'updated_at'
)->whereProfileId($profile->id)
->whereIn('type', $scope)
->where('id', $dir, $id)
->whereIn('visibility', $visibility)
->latest()
->limit($limit)
->get();
$resource = new Fractal\Resource\Collection($timeline, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function remoteProfile(Request $request, $id)
{
$profile = Profile::whereNull('status')
->whereNotNull('domain')
->findOrFail($id);
$user = Auth::user();
return view('profile.remote', compact('profile', 'user'));
}
public function remoteStatus(Request $request, $profileId, $statusId)
{
$user = Profile::whereNull('status')
->whereNotNull('domain')
->findOrFail($profileId);
$status = Status::whereProfileId($user->id)
->whereNull('reblog_of_id')
->whereIn('visibility', ['public', 'unlisted'])
->findOrFail($statusId);
$template = $status->in_reply_to_id ? 'status.reply' : 'status.remote';
return view($template, compact('user', 'status'));
}
}

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