Compare commits

..

1 commit
dev ... fix-tos

Author SHA1 Message Date
trwnh
649692dcc1
Remove auto-generated terms of service
Having an auto-generated TOS is worse than having no TOS.
2021-01-24 01:05:39 -06:00
680 changed files with 44720 additions and 142937 deletions

View file

@ -7,7 +7,7 @@ jobs:
build: build:
docker: docker:
# Specify the version you desire here # Specify the version you desire here
- image: cimg/php:7.4.26 - image: circleci/php:7.3-cli-stretch-node
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
@ -22,6 +22,7 @@ jobs:
- checkout - checkout
- run: sudo apt update && sudo apt install zlib1g-dev libsqlite3-dev - 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 # Download and cache dependencies

View file

@ -56,16 +56,11 @@ MAIL_ENCRYPTION=null
## Databases (MySQL) ## Databases (MySQL)
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_DATABASE=pixelfed_prod DB_HOST=127.0.0.1
DB_HOST=db
DB_PASSWORD=pixelfed_db_pass
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=pixelfed
DB_USERNAME=pixelfed DB_USERNAME=pixelfed
# pass the same values to the db itself DB_PASSWORD=pixelfed
MYSQL_DATABASE=pixelfed_prod
MYSQL_PASSWORD=pixelfed_db_pass
MYSQL_RANDOM_ROOT_PASSWORD=true
MYSQL_USER=pixelfed
## Databases (Postgres) ## Databases (Postgres)
#DB_CONNECTION=pgsql #DB_CONNECTION=pgsql
@ -79,7 +74,7 @@ MYSQL_USER=pixelfed
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_SCHEME=tcp REDIS_SCHEME=tcp
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PASSWORD=redis_password REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DATABASE=0 REDIS_DATABASE=0
@ -120,7 +115,7 @@ PF_COSTAR_ENABLED=false
MEDIA_EXIF_DATABASE=false MEDIA_EXIF_DATABASE=false
## Logging ## Logging
LOG_CHANNEL=stderr LOG_CHANNEL=stack
## Image ## Image
IMAGE_DRIVER=imagick IMAGE_DRIVER=imagick

View file

@ -1,365 +1,16 @@
# Release Notes # Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.2...dev) ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.10.9...dev)
### Added
- Custom Emoji ([#3166](https://github.com/pixelfed/pixelfed/pull/3166))
### Metro 2.0 UI
- Added Hovercards ([16ced7b4](https://github.com/pixelfed/pixelfed/commit/16ced7b4))
- Fix word-break on statuses ([16ced7b4](https://github.com/pixelfed/pixelfed/commit/16ced7b4))
- Add pronouns to hovercards ([33f863e8](https://github.com/pixelfed/pixelfed/commit/33f863e8))
- Improved onboarding ([042c5b6c](https://github.com/pixelfed/pixelfed/commit/042c5b6c))
### Updated
- Updated MediaStorageService, fix remote avatar bug. ([1c20d696](https://github.com/pixelfed/pixelfed/commit/1c20d696))
- Updated WebfingerService. Fixes #3167. ([aff74566](https://github.com/pixelfed/pixelfed/commit/aff74566))
- Updated ComposeModal, add max file size and allowed mime types. Fixes #3162. ([879281cc](https://github.com/pixelfed/pixelfed/commit/879281cc))
- Updated profile embeds, fix NaN bug and improve performance. ([3bd211d7](https://github.com/pixelfed/pixelfed/commit/3bd211d7))
- Updated ApiV1Controller, improve follow count cache invalidation. ([4b6effb9](https://github.com/pixelfed/pixelfed/commit/4b6effb9))
- Updated web routes, fix atom feeds for account usernames containing a dot. ([8c54ab57](https://github.com/pixelfed/pixelfed/commit/8c54ab57))
- Updated atom feeds, include media alt text. Fixes #3184. ([5d9b6863](https://github.com/pixelfed/pixelfed/commit/5d9b6863))
- Updated ApiV1Controller, add custom_emoji endpoint. ([16e72518](https://github.com/pixelfed/pixelfed/commit/16e72518))
- Updated InternalApiController, redirect remote post and profiles to Metro 2.0. ([3c35158e](https://github.com/pixelfed/pixelfed/commit/3c35158e))
- Updated BaseApiController, improve favourites endpoint. ([f063cb01](https://github.com/pixelfed/pixelfed/commit/f063cb01))
- Updated ApiV1Controller, invalidate status reply cache on new reply. ([3c261bbf](https://github.com/pixelfed/pixelfed/commit/3c261bbf))
- Updated PublicApiController, add bookmark state to timeline endpoints. ([c0b1e042](https://github.com/pixelfed/pixelfed/commit/c0b1e042))
- Updated ApiV1Controller, fix private status replies returning 404. ([73226360](https://github.com/pixelfed/pixelfed/commit/73226360))
- Updated StatusService, use BookmarkService for bookmarked state. ([a7d71551](https://github.com/pixelfed/pixelfed/commit/a7d71551))
- Updated Apis, added ReblogService to improve reblogged state for api entities ([6cfd6be5](https://github.com/pixelfed/pixelfed/commit/6cfd6be5))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.2 (2022-01-09)](https://github.com/pixelfed/pixelfed/compare/v0.11.1...v0.11.2)
### Breaking
- Dropped support for PHP 7.3 [#3041](https://github.com/pixelfed/pixelfed/pull/3041)
### Metro 2.0 UI
- Added UI Settings modal and fixed height media previews setting ([f2467e71](https://github.com/pixelfed/pixelfed/commit/f2467e71))
- Set max-width of 1440px for larger screens ([af68872a](https://github.com/pixelfed/pixelfed/commit/af68872a))
- Add link to sidebar profile card ([85964510](https://github.com/pixelfed/pixelfed/commit/85964510))
- Improved search bar, now resolves (and imports) remote accounts and posts, including webfinger addresses ([c8a667f2](https://github.com/pixelfed/pixelfed/commit/c8a667f2))
- Added user facing changelog at `/i/web/whats-new` ([e61dc66a](https://github.com/pixelfed/pixelfed/commit/e61dc66a))
### Configuration
- Enable network timeline by default ([b95aec12](https://github.com/pixelfed/pixelfed/commit/b95aec12))
### Postgres Compatibility
- Fix Story recent endpoint on postgres instances ([ddf41dc3](https://github.com/pixelfed/pixelfed/commit/ddf41dc3))
- Fix Direct Message conversations endpoint on postgres instances ([fcabc9be](https://github.com/pixelfed/pixelfed/commit/fcabc9be))
### Added
- Manual email verification requests. ([bc659387](https://github.com/pixelfed/pixelfed/commit/bc659387))
- Added StatusMentionService, fixes #3026. ([e5387d67](https://github.com/pixelfed/pixelfed/commit/e5387d67))
- Cloud Backups, a command to store backups on S3 or compatible filesystems. [#3037](https://github.com/pixelfed/pixelfed/pull/3037) ([3515a98e](https://github.com/pixelfed/pixelfed/commit/3515a98e))
- Web UI Localizations + Crowdin integration. ([f7d9b40b](https://github.com/pixelfed/pixelfed/commit/f7d9b40b)) ([7ff120c9](https://github.com/pixelfed/pixelfed/commit/7ff120c9))
- Store remote avatars locally if S3 not enabled. ([b4bd0400](https://github.com/pixelfed/pixelfed/commit/b4bd0400))
### Updated
- Updated NotificationService, fix 500 bug. ([4a609dc3](https://github.com/pixelfed/pixelfed/commit/4a609dc3))
- Updated HttpSignatures, update instance actor headers. Fixes #2935. ([a900de21](https://github.com/pixelfed/pixelfed/commit/a900de21))
- Updated NoteTransformer, fix tag array. ([7b3e672d](https://github.com/pixelfed/pixelfed/commit/7b3e672d))
- Updated video presenters, add playsinline attribute to video tags. ([0299aa5b](https://github.com/pixelfed/pixelfed/commit/0299aa5b))
- Updated RemotePost, RemoteProfile components, add fallback avatars. ([754151dc](https://github.com/pixelfed/pixelfed/commit/754151dc))
- Updated FederationController, move well-known to api middleware and cache webfinger lookups. ([4505d1f0](https://github.com/pixelfed/pixelfed/commit/4505d1f0))
- Updated InstanceActorController, improve json seralization by not escaping slashes. ([0a8eb81b](https://github.com/pixelfed/pixelfed/commit/0a8eb81b))
- Refactor following & relationship logic. Replace FollowerObserver with FollowerService and added RelationshipService to cache results. Removed NotificationTransformer includes and replaced with cached services to improve performance and reduce database queries. ([80d9b939](https://github.com/pixelfed/pixelfed/commit/80d9b939))
- Updated PublicApiController, use AccountService in accountStatuses method. ([bef959f4](https://github.com/pixelfed/pixelfed/commit/bef959f4))
- Updated auth config, add throttle limit to password resets. ([2609c86a](https://github.com/pixelfed/pixelfed/commit/2609c86a))
- Updated StatusCard component, add relationship state button. ([0436b124](https://github.com/pixelfed/pixelfed/commit/0436b124))
- Updated Timeline component, cascade relationship state change. ([f4bd5672](https://github.com/pixelfed/pixelfed/commit/f4bd5672))
- Updated Activity component, only show context button for actionable activities. ([7886fd59](https://github.com/pixelfed/pixelfed/commit/7886fd59))
- Updated Autospam service, use silent classification for better user experience. ([f0d4c172](https://github.com/pixelfed/pixelfed/commit/f0d4c172))
- Updated Profile component, improve error messages when block/mute limit reached. ([02237845](https://github.com/pixelfed/pixelfed/commit/02237845))
- Updated Activity component, fix missing types. ([5167c68d](https://github.com/pixelfed/pixelfed/commit/5167c68d))
- Updated Timeline component, apply block/mute filters client side for local and network timelines. ([be194b8a](https://github.com/pixelfed/pixelfed/commit/be194b8a))
- Updated public timeline api, use cached sorted set and client side block/mute filtering. ([37abcf38](https://github.com/pixelfed/pixelfed/commit/37abcf38))
- Updated public timeline api, add experimental cache. ([192553ff](https://github.com/pixelfed/pixelfed/commit/192553ff))
- Updated dark mode styles, fix black box on stories. Closes #2982. ([3169f68e](https://github.com/pixelfed/pixelfed/commit/3169f68e))
- Updated verify_credentials api endpoint to improve performance. ([7df3540b](https://github.com/pixelfed/pixelfed/commit/7df3540b))
- Updated Localization util, filter out .DS_Store. ([0107e8fd](https://github.com/pixelfed/pixelfed/commit/0107e8fd))
- Updated PublicApiController, fix private account statuses api. Closes #2995. ([aa2dd26c](https://github.com/pixelfed/pixelfed/commit/aa2dd26c))
- Updated Status model, use AccountService to generate urls instead of loading profile relation. ([2ae527c0](https://github.com/pixelfed/pixelfed/commit/2ae527c0))
- Updated Autospam service, add mark all as read and mark all as not spam options and filter active, spam and not spam reports. ([ae8c7517](https://github.com/pixelfed/pixelfed/commit/ae8c7517))
- Updated UserInviteController, fixes #3017. ([b8e9056e](https://github.com/pixelfed/pixelfed/commit/b8e9056e))
- Updated AccountService, add dynamic user settings methods. ([2aa73c1f](https://github.com/pixelfed/pixelfed/commit/2aa73c1f))
- Updated MediaStorageService, improve header parsing. ([9d9e9ce7](https://github.com/pixelfed/pixelfed/commit/9d9e9ce7))
- Updated SearchApiV2Service, improve performance and include hashtag post counts when applicable ([fbaed93e](https://github.com/pixelfed/pixelfed/commit/fbaed93e))
- Updated AccountTransformer, add note_text and location fields. ([98f76abb](https://github.com/pixelfed/pixelfed/commit/98f76abb))
- Updated UserSetting model, cast compose_settings and other as json. ([03420278](https://github.com/pixelfed/pixelfed/commit/03420278))
- Updated ApiV1Controller, improve settings and add discoverPosts endpoint. ([079804e6](https://github.com/pixelfed/pixelfed/commit/079804e6))
- Updated LikePipeline jobs, fix likes_count calculation. ([fe64e187](https://github.com/pixelfed/pixelfed/commit/fe64e187))
- Updated InternalApiController, prevent moderation actions against admin accounts. ([945a7e49](https://github.com/pixelfed/pixelfed/commit/945a7e49))
- Updated CommentPipeline, move reply_count calculation to comment pipeline job and improve count calculation. ([b6b0837f](https://github.com/pixelfed/pixelfed/commit/b6b0837f))
- Updated ApiV1Controller, improve statusesById perf and dispatch CommentPipeline job when applicable. ([466286af](https://github.com/pixelfed/pixelfed/commit/466286af))
- Updated MediaService, return empty array if cant find status. ([c2910e5d](https://github.com/pixelfed/pixelfed/commit/c2910e5d))
- Updated StatusService, improve cache invalidation. ([83b48b56](https://github.com/pixelfed/pixelfed/commit/83b48b56))
- Updated Hashtag component, fix spinner. ([fefbc44a](https://github.com/pixelfed/pixelfed/commit/fefbc44a))
- Updated NotificationCard, update api endpoint and add group notification types. ([e09a14d8](https://github.com/pixelfed/pixelfed/commit/e09a14d8))
- Updated ContextMenu component, fix account url paths. ([01ca1edd](https://github.com/pixelfed/pixelfed/commit/01ca1edd))
- Updated PollCard component, add showBorder prop. ([0c8fffbd](https://github.com/pixelfed/pixelfed/commit/0c8fffbd))
- Updated PhotoPresenter component, add lightbox toggle. ([0cc1365f](https://github.com/pixelfed/pixelfed/commit/0cc1365f))
- Updated console kernel, add db session garbage collector that runs twice daily. ([03b0a62a](https://github.com/pixelfed/pixelfed/commit/03b0a62a))
- Updated ComposeController, refactor compose_settings. ([edc2958b](https://github.com/pixelfed/pixelfed/commit/edc2958b))
- Updated StatusEntityLexer, prevent boosts and replies from being added to PublicTimelineService. ([32707372](https://github.com/pixelfed/pixelfed/commit/32707372))
- Updated SpaController, persist web language changes. ([7bc684e5](https://github.com/pixelfed/pixelfed/commit/7bc684e5))
- Updated LoginController, bump decayMinutes from 1 to 60. ([6bf92bed](https://github.com/pixelfed/pixelfed/commit/6bf92bed))
- Updated SPA, rewrite autolink urls to SPA when applicable. ([0837b410](https://github.com/pixelfed/pixelfed/commit/0837b410))
- Updated site config, increase ttl and enable SPA by default. ([469d49d8](https://github.com/pixelfed/pixelfed/commit/469d49d8))
- Updated Webfinger, fixes #3050. ([ff7ee3bd](https://github.com/pixelfed/pixelfed/commit/ff7ee3bd))
- Updated status api, autolink caption before returning response. ([b00a453b](https://github.com/pixelfed/pixelfed/commit/b00a453b))
- Updated Timeline, add new ui promo in timelines that can be hidden using localstorage. ([e13959ae](https://github.com/pixelfed/pixelfed/commit/e13959ae))
- Updated FederationController, increase webfinger cache ttl from 12 hours to 14 days. ([745c3580](https://github.com/pixelfed/pixelfed/commit/745c3580))
- Updated DiscoverController, add yearly option and increase limit from 15 to 30 posts. ([10b6058c](https://github.com/pixelfed/pixelfed/commit/10b6058c))
- Updated RemoteAvatarFetch job, fixed bug preventing new avatars from being stored. ([92bc2845](https://github.com/pixelfed/pixelfed/commit/92bc2845))
- Updated AccountService, fix json casting. ([e5f8f344](https://github.com/pixelfed/pixelfed/commit/e5f8f344))
- Updated ApiV1Controller, fix illegal operator bug by setting default min_id. ([415826f2](https://github.com/pixelfed/pixelfed/commit/415826f2))
- Updated StatusService, add getMastodon method for mastoapi compatibility. ([36a129fe](https://github.com/pixelfed/pixelfed/commit/36a129fe))
- Updated PublicApiController, fix accountStatuses pagination operator. ([85fc9dd0](https://github.com/pixelfed/pixelfed/commit/85fc9dd0))
- Updated PublicApiController, enforce only_media on accountStatuses method. Fixes #3105. ([861a2d36](https://github.com/pixelfed/pixelfed/commit/861a2d36))
- Updated ApiV1Controller, add mastoapi strict mode. ([46485426](https://github.com/pixelfed/pixelfed/commit/46485426))
- Updated AccountController, refresh RelationshipService on mute/block. ([6f1b0245](https://github.com/pixelfed/pixelfed/commit/6f1b0245))
- Updated ApiV1Controller, fix version on instance endpoint. ([a6261221](https://github.com/pixelfed/pixelfed/commit/a6261221))
- Updated components, fix api endpoints. Fixes #3138. ([e724633e](https://github.com/pixelfed/pixelfed/commit/e724633e))
- Updated ApiV1Controller, fix public timeline endpoint. ([80c7def3](https://github.com/pixelfed/pixelfed/commit/80c7def3))
- Updated PublicApiController, fix public timeline endpoint. ([dcb7ba9c](https://github.com/pixelfed/pixelfed/commit/dcb7ba9c))
- Updated ApiV1Controller, fix home timeline entities. ([6fc0dcb3](https://github.com/pixelfed/pixelfed/commit/6fc0dcb3))
- Updated ApiV1Controller, fix favourites endpoints ([d6d99385](https://github.com/pixelfed/pixelfed/commit/d6d99385))
- Updated ApiV1Controller, fix reblogs endpoints ([de42d84c](https://github.com/pixelfed/pixelfed/commit/de42d84c))
- Updated SearchApiV2Service, resolve remote queries. ([c8a667f2](https://github.com/pixelfed/pixelfed/commit/c8a667f2))
## [v0.11.1 (2021-09-07)](https://github.com/pixelfed/pixelfed/compare/v0.11.0...v0.11.1)
### Added
- WebP Support ([069a0e4a](https://github.com/pixelfed/pixelfed/commit/069a0e4a))
- Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540))
- Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86))
- Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc))
- Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c))
- Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
- Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a))
- Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88))
- Polls ([77092200](https://github.com/pixelfed/pixelfed/commit/77092200))
- Federated Stories (#2895)
### Updated
- Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b))
- Updated landing page, use config_cache. ([54920294](https://github.com/pixelfed/pixelfed/commit/54920294))
- Updated Timeline, implement suggested post opt out. ([66750d34](https://github.com/pixelfed/pixelfed/commit/66750d34))
- Updated Notification component, add at (@) symbol for remote profiles and local urls for remote posts and profile. ([aafd6a21](https://github.com/pixelfed/pixelfed/commit/aafd6a21))
- Updated Activity component, add at (@) symbol for remote profiles and local urls for remote posts and profile. ([a2211815](https://github.com/pixelfed/pixelfed/commit/a2211815))
- Updated Profile, add linkified bio, joined date, follows you label and improved website handling. ([8ee10436](https://github.com/pixelfed/pixelfed/commit/8ee10436))
- Updated routes, add legacy webfinger profile redirect. ([93c7af74](https://github.com/pixelfed/pixelfed/commit/93c7af74))
- Updated StoryController, fix expiration time bug. ([39e57f95](https://github.com/pixelfed/pixelfed/commit/39e57f95))
- Updated Profile component, fix remote urls. ([6e56dbed](https://github.com/pixelfed/pixelfed/commit/6e56dbed))
- Updated verify email screen, add contact admin link. ([f37952d6](https://github.com/pixelfed/pixelfed/commit/f37952d6))
- Updated RemoteProfile component, implement pagination. ([02b04a4b](https://github.com/pixelfed/pixelfed/commit/02b04a4b))
- Updated AP Helpers, generate notification for remote replies. ([8edd8294](https://github.com/pixelfed/pixelfed/commit/8edd8294))
- Updated like api, store status_profile_id and is_comment. ([c8c6b983](https://github.com/pixelfed/pixelfed/commit/c8c6b983))
- Updated Remote Post + Profile hashtag to redirect to local urls. ([1fa08644](https://github.com/pixelfed/pixelfed/commit/1fa08644))
- Updated Inbox, delete notifications on tombstone. ([ef63124d](https://github.com/pixelfed/pixelfed/commit/ef63124d))
- Updated NotificationCard, fix missing status bug. ([a3a86d46](https://github.com/pixelfed/pixelfed/commit/a3a86d46))
- Updated Activity component, fix comment bug. ([9a2db8eb](https://github.com/pixelfed/pixelfed/commit/9a2db8eb))
- Updated Inbox, fix tombstone bug. ([929ff5eb](https://github.com/pixelfed/pixelfed/commit/929ff5eb))
- Updated LikeService, skip self likes. ([3741c76d](https://github.com/pixelfed/pixelfed/commit/3741c76d))
- Updated StatusController, improve share api perf (11s to 72ms). ([d48ebb82](https://github.com/pixelfed/pixelfed/commit/d48ebb82))
- Updated ApiController, fix nulls in hashtag endpoint. ([f1208de0](https://github.com/pixelfed/pixelfed/commit/f1208de0))
- Updated SharePipeline, add Undo->Announce support. ([c8e40e0f](https://github.com/pixelfed/pixelfed/commit/c8e40e0f))
- Updated NetworkTimeline, fix remote comment urls. ([308acc91](https://github.com/pixelfed/pixelfed/commit/308acc91))
- Updated Timeline component, abstracted reusable partials. ([858f3f9e](https://github.com/pixelfed/pixelfed/commit/858f3f9e))
- Updated Timeline, fix suggested posts. ([3ba5c88c](https://github.com/pixelfed/pixelfed/commit/3ba5c88c))
- Updated Timeline, disable new post update checker and hide reaction bar on network timeline. ([1e3d3a69](https://github.com/pixelfed/pixelfed/commit/1e3d3a69))
- Updated PublicApiController, improve network timeline perf. ([e5f683fd](https://github.com/pixelfed/pixelfed/commit/e5f683fd))
- Updated Network Timeline, use existing Timeline component. ([0deaafc0](https://github.com/pixelfed/pixelfed/commit/0deaafc0))
- Updated PostComponent, show like count to owner using MomentUI. ([e9c46bab](https://github.com/pixelfed/pixelfed/commit/e9c46bab))
- Updated ContextMenu, add missing statusUrl method. ([3cffdb11](https://github.com/pixelfed/pixelfed/commit/3cffdb11))
- Updated PublicApiController, add LikeService to Network timeline. ([82895591](https://github.com/pixelfed/pixelfed/commit/82895591))
- Updated moderator api, expire cached status in StatusService. ([f215ee26](https://github.com/pixelfed/pixelfed/commit/f215ee26))
- Updated StatusHashtagService, fix null status bug. ([51a277e1](https://github.com/pixelfed/pixelfed/commit/51a277e1))
- Updated NotificationService, use zrevrangebyscore for api. ([d43e6d8d](https://github.com/pixelfed/pixelfed/commit/d43e6d8d))
- Updated ApiV1Controller, use PublicTimelineService. ([f67c67bc](https://github.com/pixelfed/pixelfed/commit/f67c67bc))
- Updated ApiV1Controller, use ProfileService for verify_credentials. ([352aa573](https://github.com/pixelfed/pixelfed/commit/352aa573))
- Updated RemotePost.vue, fix content warning button. ([7647e724](https://github.com/pixelfed/pixelfed/commit/7647e724))
- Updated AdminMediaController, improve perf and use simple pagination. ([f2686cac](https://github.com/pixelfed/pixelfed/commit/f2686cac))
- Updated PostComponent, fix MomentUI like counter. ([42c6121a](https://github.com/pixelfed/pixelfed/commit/42c6121a))
- Updated status views, remove like counts from status embed. ([1a2e41b1](https://github.com/pixelfed/pixelfed/commit/1a2e41b1))
- Updated Profile, fix unauthenticated private profiles. ([9017f7c4](https://github.com/pixelfed/pixelfed/commit/9017f7c4))
- Updated PublicApiController, impr home timeline perf. ([4fe42e5b](https://github.com/pixelfed/pixelfed/commit/4fe42e5b))
- Updated Timeline.vue, fix comment button. ([b6b5ce7c](https://github.com/pixelfed/pixelfed/commit/b6b5ce7c))
- Updated StatusEntityLexer, only add specific status types to PublicTimelineService. ([1fdcbe5b](https://github.com/pixelfed/pixelfed/commit/1fdcbe5b))
- Updated ActivityPub helpers, fix comment threading in statusFetch() method ([26b9c140](https://github.com/pixelfed/pixelfed/commit/26b9c140))
- Updated NotificationCard, fix typo in mention, share and comments. Fixes #2848. ([b37bb426](https://github.com/pixelfed/pixelfed/commit/b37bb426))
- Updated StatusCard.vue, add togglecw events to other presenters. ([9607243f](https://github.com/pixelfed/pixelfed/commit/9607243f))
- Updated presenters, fix content warning layout. ([fc56acb8](https://github.com/pixelfed/pixelfed/commit/fc56acb8))
- Updated reply blade view, fix missing avatar and media images. ([5fb33772](https://github.com/pixelfed/pixelfed/commit/5fb33772))
- Updated components, add fallback default avatar. ([726553f5](https://github.com/pixelfed/pixelfed/commit/726553f5))
- Updated job queue, separate deletes into their own queue. ([7f421392](https://github.com/pixelfed/pixelfed/commit/7f421392))
- Updated DiscoverController, use UserFilterService on trendingApi. ([135474ae](https://github.com/pixelfed/pixelfed/commit/135474ae))
- Updated PublicApiController, use UserFilterService in public timeline endpoint. ([ca6e491c](https://github.com/pixelfed/pixelfed/commit/ca6e491c))
- Updated ContextMenu, add View Profile link. ([8544bcbd](https://github.com/pixelfed/pixelfed/commit/8544bcbd))
- Updated presenters, improve content warnings. ([86422c81](https://github.com/pixelfed/pixelfed/commit/86422c81))
- Updated Timeline.vue, increase pagination limit from 3 to 12 and add empty feed placeholder. ([916e8f71](https://github.com/pixelfed/pixelfed/commit/916e8f71))
- Updated Timeline.vue, improve followed hashtags. ([728f10d7](https://github.com/pixelfed/pixelfed/commit/728f10d7))
- Updated PostComponent, use profileUrl method for comments. ([7ed65fc9](https://github.com/pixelfed/pixelfed/commit/7ed65fc9))
- Updated Timeline, fix empty timeline card. ([11eb6acd](https://github.com/pixelfed/pixelfed/commit/11eb6acd))
- Updated ap helpers, set text type when appropriate. ([9f4f983f](https://github.com/pixelfed/pixelfed/commit/9f4f983f))
- Updated StatusCard, add text support. ([ed14ee48](https://github.com/pixelfed/pixelfed/commit/ed14ee48))
- Updated PublicApiController, filter out text replies on home timeline. ([86219b57](https://github.com/pixelfed/pixelfed/commit/86219b57))
- Updated RemotePost.vue, improve text only post UI. ([b0257be2](https://github.com/pixelfed/pixelfed/commit/b0257be2))
- Updated Timeline, make text-only posts opt-in by default. ([0153ed6d](https://github.com/pixelfed/pixelfed/commit/0153ed6d))
- Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad))
- Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604))
- Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
- Updated ApiV1Controller, add default license support. ([2a791f19](https://github.com/pixelfed/pixelfed/commit/2a791f19))
- Updated StatusTransformers, remove includes and use cached services. ([09d5198c](https://github.com/pixelfed/pixelfed/commit/09d5198c))
- Updated RemotePost component, update likes reaction bar. ([1060dd23](https://github.com/pixelfed/pixelfed/commit/1060dd23))
- Updated FollowPipeline, fix cache invalidation bug. ([c1f14f89](https://github.com/pixelfed/pixelfed/commit/c1f14f89))
- Updated PublicApiController, improve accountStatuses api perf. ([bce8edd9](https://github.com/pixelfed/pixelfed/commit/bce8edd9))
- Updated ApiControllers, use NotificationService. ([f9516ac3](https://github.com/pixelfed/pixelfed/commit/f9516ac3))
- Updated Notification components, fix old notifications with missing attributes. ([b6e226ae](https://github.com/pixelfed/pixelfed/commit/b6e226ae))
- Updated LikeController, improve query perf. ([f3d6023e](https://github.com/pixelfed/pixelfed/commit/f3d6023e))
- Updated License util, add nameToId method. ([f6131ed7](https://github.com/pixelfed/pixelfed/commit/f6131ed7))
- Updated RemoteProfile, add warning about potentially out of date information. ([7274574c](https://github.com/pixelfed/pixelfed/commit/7274574c))
- Updated NotifcationCard.vue component, add refresh button for cold notification cache. ([0e178a33](https://github.com/pixelfed/pixelfed/commit/0e178a33))
- Updated RemoteProfile component, add follower modals. ([c4146a30](https://github.com/pixelfed/pixelfed/commit/c4146a30))
- Updated FollowerService, cache audience. ([22257cc2](https://github.com/pixelfed/pixelfed/commit/22257cc2))
- Updated StatusService, add non-public option and improve cache invalidation. ([15c4fdd9](https://github.com/pixelfed/pixelfed/commit/15c4fdd9))
- Updated ContactAdmin mail, set New Support Message subject. ([bc3add05](https://github.com/pixelfed/pixelfed/commit/bc3add05))
- Updated StatusTransformer, prioritize scope over deprecated visibility attribute. ([6e45021f](https://github.com/pixelfed/pixelfed/commit/6e45021f))
- Updated StatusService, invalidate profile embed cache on deletion. ([acaf630d](https://github.com/pixelfed/pixelfed/commit/acaf630d))
- Updated status.reply view, fix archived post leakage. ([4fb3d1fa](https://github.com/pixelfed/pixelfed/commit/4fb3d1fa))
- Updated PostComponents, re-add time to timestamp. ([c5281dcd](https://github.com/pixelfed/pixelfed/commit/c5281dcd))
- Updated follow intent, fix follower count leak. ([03199e2f](https://github.com/pixelfed/pixelfed/commit/03199e2f))
- Updated Status model, add poll relation and allow up to 2 urls to autolink. ([2593cdee](https://github.com/pixelfed/pixelfed/commit/2593cdee))
- Updated snowflake id generation to improve randomness. ([e5aea490](https://github.com/pixelfed/pixelfed/commit/e5aea490))
- Updated Timeline, remove recent posts. ([7641b731](https://github.com/pixelfed/pixelfed/commit/7641b731))
- Updated InstanceCrawlPipeline, remove unused variable. ([e73cf531](https://github.com/pixelfed/pixelfed/commit/e73cf531))
- Updated StoryComposeController, fix expiry bug. ([7dee8f58](https://github.com/pixelfed/pixelfed/commit/7dee8f58))
- Updated Profile, fix following count bug. ([ee9f0795](https://github.com/pixelfed/pixelfed/commit/ee9f0795))
- Updated DirectMessageController, fix autocomplete bug. ([0f00be4d](https://github.com/pixelfed/pixelfed/commit/0f00be4d))
- Updated StoryService, fix division by zero bug. ([6ae1ba0a](https://github.com/pixelfed/pixelfed/commit/6ae1ba0a))
- Updated ApiV1Controller, fix empty public timeline bug. ([0584f9ee](https://github.com/pixelfed/pixelfed/commit/0584f9ee))
## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)
### Added
- Autocomplete Support (hashtags + mentions) ([de514f7d](https://github.com/pixelfed/pixelfed/commit/de514f7d))
- Creative Commons Licenses ([552e950](https://github.com/pixelfed/pixelfed/commit/552e950))
- Network Timeline ([af7face4](https://github.com/pixelfed/pixelfed/commit/af7face4))
- Admin config settings ([f2066b74](https://github.com/pixelfed/pixelfed/commit/f2066b74))
- Profile pronouns ([fabb57a9](https://github.com/pixelfed/pixelfed/commit/fabb57a9))
- Hashtag timeline api support ([241ae036](https://github.com/pixelfed/pixelfed/commit/241ae036))
- New admin dashboard layout ([eb7d5a4e](https://github.com/pixelfed/pixelfed/commit/eb7d5a4e))
- Fresh about page layout ([92dc7af6](https://github.com/pixelfed/pixelfed/commit/92dc7af6))
- Instance Rules ([a4efbb75](https://github.com/pixelfed/pixelfed/commit/a4efbb75))
- New Home Timeline ([56215be7](https://github.com/pixelfed/pixelfed/commit/56215be7))
### Updated
- Updated AdminController, fix variable name in updateSpam method. ([6edaf940](https://github.com/pixelfed/pixelfed/commit/6edaf940))
- Updated RemoteAvatarFetch, only dispatch jobs if cloud storage is enabled. ([4f40f6f5](https://github.com/pixelfed/pixelfed/commit/4f40f6f5))
- Updated StatusService, add ttl of 7 days. ([6e44ae0b](https://github.com/pixelfed/pixelfed/commit/6e44ae0b))
- Updated StatusHashtagService, use StatusService for statuses. ([0355b567](https://github.com/pixelfed/pixelfed/commit/0355b567))
- Updated StatusHashtagService, remove deprecated methods. ([aa4c718d](https://github.com/pixelfed/pixelfed/commit/aa4c718d))
- Updated ApiV1Controller, add StatusService del calls to update likes_count, reblogs_count and reply_count. ([05b9445c](https://github.com/pixelfed/pixelfed/commit/05b9445c))
- Updated Like, Status and Comment controllers to add StatusService del() method to update counts. ([eab4370c](https://github.com/pixelfed/pixelfed/commit/eab4370c))
- Updated ComposeController, use placeholder image for video media. Fixes #2595. ([789ed4b4](https://github.com/pixelfed/pixelfed/commit/789ed4b4))
- Updated DiscoverController, change api schema. ([2eea0409](https://github.com/pixelfed/pixelfed/commit/2eea0409))
- Updated StatusDelete pipeline, call StatusService::del() to remove status from cache. ([3f772ff8](https://github.com/pixelfed/pixelfed/commit/3f772ff8))
- Updated StatusHashtagTransformer, add blurhash attribute. ([899bbeba](https://github.com/pixelfed/pixelfed/commit/899bbeba))
- Updated status square previews, add blurhash and improved content warnings. ([39e389dd](https://github.com/pixelfed/pixelfed/commit/39e389dd))
- Updated Blurhash util, add default hash for invalid media. ([38a37c15](https://github.com/pixelfed/pixelfed/commit/38a37c15))
- Updated VideoThumbnail job, generate blurhash for videos. ([896452c7](https://github.com/pixelfed/pixelfed/commit/896452c7))
- Updated MediaTransformers, add default blurhash attribute. ([3f14a4c4](https://github.com/pixelfed/pixelfed/commit/3f14a4c4))
- Updated Timeline.vue, fix hashtag status previews. ([7768e844](https://github.com/pixelfed/pixelfed/commit/7768e844))
- Updated AP helpers, fix statusFetch 404s. ([3419379a](https://github.com/pixelfed/pixelfed/commit/3419379a))
- Updated InternalApiController, update discoverPosts method to improve performance. ([9862a855](https://github.com/pixelfed/pixelfed/commit/9862a855))
- Updated DiscoverComponent, add blurhash and like/comment counts. ([a8ebdd2e](https://github.com/pixelfed/pixelfed/commit/a8ebdd2e))
- Updated DiscoverComponent, add spinner loaders and remove deprecated sections. ([34869247](https://github.com/pixelfed/pixelfed/commit/34869247))
- Updated AccountController, add mutes and blocks endpoint to pixelfed api. ([1fb7e2b2](https://github.com/pixelfed/pixelfed/commit/1fb7e2b2))
- Updated AccountService, cache object and observe changes. ([b299da93](https://github.com/pixelfed/pixelfed/commit/b299da93))
- Updated webfinger util, fail on invalid webfinger url. Fixes ([#2613](https://github.com/pixelfed/pixelfed/issues/2613)) ([2d11317c](https://github.com/pixelfed/pixelfed/commit/2d11317c))
- Updated MediaStorageService, dispatch deletes to MediaDeletePipeline. ([37dbb3de](https://github.com/pixelfed/pixelfed/commit/37dbb3de))
- Updated ComposeController, use MediaStorageService for media deletes. ([ab5469ff](https://github.com/pixelfed/pixelfed/commit/ab5469ff))
- Updated StatusDeletePipeline, use MediaStorageService for media deletes. ([9fd90e17](https://github.com/pixelfed/pixelfed/commit/9fd90e17))
- Updated Discover, allow public discover access. ([1404ac6e](https://github.com/pixelfed/pixelfed/commit/1404ac6e))
- Updated pixelfed config, add media_fast_process setting. ([6bee5072](https://github.com/pixelfed/pixelfed/commit/6bee5072))
- Updated ComposeController, add mediaProcessingCheck method. ([33b625f5](https://github.com/pixelfed/pixelfed/commit/33b625f5))
- Updated ComposeModal, add processing step disabled by default. ([e6e76e80](https://github.com/pixelfed/pixelfed/commit/e6e76e80))
- Updated DiscoverComponent, allow unauthenticated if enabled. ([a1059a6e](https://github.com/pixelfed/pixelfed/commit/a1059a6e))
- Updated components, improve content warnings. ([a9e98965](https://github.com/pixelfed/pixelfed/commit/a9e98965))
- Updated ComposeModal, prevent tagging empty users. Fixes #2633. ([ceae664c](https://github.com/pixelfed/pixelfed/commit/ceae664c))
- Updated ComposeModal, show filter warning for unsupported browsers. ([12ce7602](https://github.com/pixelfed/pixelfed/commit/12ce7602))
- Updated Hashtag component, fix null infinite loading bug. Fixes #2637. ([55136518](https://github.com/pixelfed/pixelfed/commit/55136518))
- Updated filesystems config, add backup driver to store backups on other filesystems. ([ae90eef9](https://github.com/pixelfed/pixelfed/commit/ae90eef9))
- Updated Embeds. Fix Profile + Status embeds, remove following count and improve cache invalidation and hidden follower counts. ([5ac9d0e8](https://github.com/pixelfed/pixelfed/commit/5ac9d0e8))
- Updated FederationController, return 404 for invalid webfinger addresses. Fixes ([#2647](https://github.com/pixelfed/pixelfed/issues/2647)). ([deb6f115](https://github.com/pixelfed/pixelfed/commit/deb6f115))
- Updated InboxPipeline, fail earlier for invalid public keys. Fixes ([#2648](https://github.com/pixelfed/pixelfed/issues/2648)). ([d1c5e9b8](https://github.com/pixelfed/pixelfed/commit/d1c5e9b8))
- Updated Status model, refactor liked and shared methods to fix cache invalidation bug. ([f05c3b66](https://github.com/pixelfed/pixelfed/commit/f05c3b66))
- Updated Timeline component, add inline reports modal. ([e64b4bd3](https://github.com/pixelfed/pixelfed/commit/e64b4bd3))
- Updated federation pipeline, add locks. ([ddc76887](https://github.com/pixelfed/pixelfed/commit/ddc76887))
- Updated MediaStorageService, improve head checks to fix failed jobs. ([1769cdfd](https://github.com/pixelfed/pixelfed/commit/1769cdfd))
- Updated user admin, remove expensive db query and add search. ([8feeadbf](https://github.com/pixelfed/pixelfed/commit/8feeadbf))
- Updated Compose apis, prevent private accounts from posting public or unlisted scopes. ([f53bfa6f](https://github.com/pixelfed/pixelfed/commit/f53bfa6f))
- Updated font icons, use font-display:swap. ([77d4353a](https://github.com/pixelfed/pixelfed/commit/77d4353a))
- Updated ComposeModal, limit visibility scope for private accounts. ([001d4105](https://github.com/pixelfed/pixelfed/commit/001d4105))
- Updated ComposeController, add autocomplete apis for hashtags and mentions. ([f0e48a09](https://github.com/pixelfed/pixelfed/commit/f0e48a09))
- Updated StatusController, invalidate profile embed cache on status delete. ([9c8a87c3](https://github.com/pixelfed/pixelfed/commit/9c8a87c3))
- Updated moderation api, invalidate profile embed. ([b2501bfc](https://github.com/pixelfed/pixelfed/commit/b2501bfc))
- Updated Nodeinfo util, use last_active_at for monthly active user count. ([d200c12c](https://github.com/pixelfed/pixelfed/commit/d200c12c))
- Updated PhotoPresenter, add width and height to images. ([3f8202e2](https://github.com/pixelfed/pixelfed/commit/3f8202e2))
- Updated Compose Apis, refactor rate limits. ([42375b3d](https://github.com/pixelfed/pixelfed/commit/42375b3d))
- Updated PublicApiController, show unlisted comments. ([e1c6297e](https://github.com/pixelfed/pixelfed/commit/e1c6297e))
- Updated ApiV1Controller, add missing variable. ([886ea617](https://github.com/pixelfed/pixelfed/commit/886ea617))
- Updated PublicApiController, limit network pagination to 3 months. ([10119bbb](https://github.com/pixelfed/pixelfed/commit/10119bbb))
- Updated admin instance page, add search and improve performance. ([f5829373](https://github.com/pixelfed/pixelfed/commit/f5829373))
- Updated AdminInstanceController, invalidate banned domain cache when updated. ([35393edf](https://github.com/pixelfed/pixelfed/commit/35393edf))
- Updated AP Helpers, use instance filtering. ([66b4f8c7](https://github.com/pixelfed/pixelfed/commit/66b4f8c7))
- Updated ApiV1Controller, add missing instance api attributes. ([64b86546](https://github.com/pixelfed/pixelfed/commit/64b86546))
- Updated story garbage collection, handle non active stories and new ephemeral story media directory. ([c43f8bcc](https://github.com/pixelfed/pixelfed/commit/c43f8bcc))
- Updated Stories, add crop and duration settings to composer. ([c8edca69](https://github.com/pixelfed/pixelfed/commit/c8edca69))
- Updated instance endpoint, add custom description. ([668e936e](https://github.com/pixelfed/pixelfed/commit/668e936e))
- Updated StoryCompose component, improve full screen preview. ([39a76103](https://github.com/pixelfed/pixelfed/commit/39a76103))
- Updated Helpers, fix broken tests. ([22dddaa0](https://github.com/pixelfed/pixelfed/commit/22dddaa0))
- Updated StoryController, fix cache crop bug. ([c2f8faae](https://github.com/pixelfed/pixelfed/commit/c2f8faae))
- Updated StoryController, optimize photo size by resizing to 9:16 aspect. ([e66ed9a2](https://github.com/pixelfed/pixelfed/commit/e66ed9a2))
- Updated StoryCompose crop logic. ([2ead622c](https://github.com/pixelfed/pixelfed/commit/2ead622c))
- Updated StatusController, allow license edits without 24 hour limit. ([c799a01a](https://github.com/pixelfed/pixelfed/commit/c799a01a))
- Updated Settings, remove reports page. ([9cf962ff](https://github.com/pixelfed/pixelfed/commit/9cf962ff))
- Updated ProfileService, use account transformer. ([391b1287](https://github.com/pixelfed/pixelfed/commit/391b1287))
- Updated LikeController, hide like counts. ([ea687240](https://github.com/pixelfed/pixelfed/commit/ea687240))
- Updated StatusTransformers, add liked_by attribute. ([372bacb0](https://github.com/pixelfed/pixelfed/commit/372bacb0))
- Updated PostComponent, change like logic. ([0a35f5d6](https://github.com/pixelfed/pixelfed/commit/0a35f5d6))
- Updated Timeline component, change like logic. ([7bcbf96b](https://github.com/pixelfed/pixelfed/commit/7bcbf96b))
- Updated LikeService, fix likedBy method. ([a5e64da6](https://github.com/pixelfed/pixelfed/commit/a5e64da6))
- Updated PublicApiController, increase public timeline to 6 months from 3. ([8a736432](https://github.com/pixelfed/pixelfed/commit/8a736432))
- Updated LikeService, show like count to status owner. ([4408e2ef](https://github.com/pixelfed/pixelfed/commit/4408e2ef))
- Updated admin settings, add rules. ([a4efbb75](https://github.com/pixelfed/pixelfed/commit/a4efbb75))
- Updated LikeService, fix authentication bug. ([c9abd70e](https://github.com/pixelfed/pixelfed/commit/c9abd70e))
- Updated StatusTransformer, fix missing tags attribute. ([dac326e9](https://github.com/pixelfed/pixelfed/commit/dac326e9))
- Updated ComposeController, bail on empty attachments. ([061b145b](https://github.com/pixelfed/pixelfed/commit/061b145b))
- Updated landing and about page. ([92dc7af6](https://github.com/pixelfed/pixelfed/commit/92dc7af6))
- Updated AdminStatsService, fix postgres bug. ([af719135](https://github.com/pixelfed/pixelfed/commit/af719135))
- Updated api, remove auth requirement for hashtag timeline. ([c8e43c60](https://github.com/pixelfed/pixelfed/commit/c8e43c60))
- Updated NotificationCard component, fix default value. ([78ad4e77](https://github.com/pixelfed/pixelfed/commit/78ad4e77))
- Updated Timeline component, show counts and make sidebar footer lighter. ([0788bffa](https://github.com/pixelfed/pixelfed/commit/0788bffa))
- Updated AuthServiceProvider, increase default token + refresh token lifetime. ([178ed63d](https://github.com/pixelfed/pixelfed/commit/178ed63d))
- Updated liked by, fix remote username urls. ([f767d99a](https://github.com/pixelfed/pixelfed/commit/f767d99a))
- Updated StatusController, add cache invalidation for timeline cursor. ([f3bf2fd4](https://github.com/pixelfed/pixelfed/commit/f3bf2fd4))
- Updated PublicApiController, add recent feed support to home timeline. ([1e230e80](https://github.com/pixelfed/pixelfed/commit/1e230e80))
- Updated Inbox, fix reply/comment bug by moving attachment validation to Note with attachments. ([28df9f7e](https://github.com/pixelfed/pixelfed/commit/28df9f7e))
- Updated PrettyNumber, add decimal option. ([84520fe1](https://github.com/pixelfed/pixelfed/commit/84520fe1))
- Updated app config, change default descriptions. ([7d24560d](https://github.com/pixelfed/pixelfed/commit/7d24560d))
- Updated NotificationCard, fix loading bug. ([69567e19](https://github.com/pixelfed/pixelfed/commit/69567e19))
- Updated DirectMessageController, disable exception logging for invalid urls. Fixes ([#2752](https://github.com/pixelfed/pixelfed/issues/2752)). ([2d0a253e](https://github.com/pixelfed/pixelfed/commit/2d0a253e))
## [v0.10.10 (2021-01-28)](https://github.com/pixelfed/pixelfed/compare/v0.10.9...v0.10.10)
### Added ### Added
- Direct Messages ([d63569c](https://github.com/pixelfed/pixelfed/commit/d63569c)) - Direct Messages ([d63569c](https://github.com/pixelfed/pixelfed/commit/d63569c))
- ActivityPubFetchService for signed GET requests ([8763bfc5](https://github.com/pixelfed/pixelfed/commit/8763bfc5)) ([3ee1215a](https://github.com/pixelfed/pixelfed/commit/3ee1215a)) - 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)) - Custom content warnings for remote posts ([6afc61a4](https://github.com/pixelfed/pixelfed/commit/6afc61a4))
- Thai translations ([74cd536](https://github.com/pixelfed/pixelfed/commit/74cd536)) - Thai translations ([74cd536](https://github.com/pixelfed/pixelfed/commit/74cd536))
- Added Bookmarks to v1 api ([99cb48c5](https://github.com/pixelfed/pixelfed/commit/99cb48c5)) - 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)) - 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 Instagram Import ([e2a6bdd0](https://github.com/pixelfed/pixelfed/commit/e2a6bdd0))
- Add notification preview to NotificationCard ([28445e27](https://github.com/pixelfed/pixelfed/commit/28445e27)) - 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 MediaPathService ([c54b29c5](https://github.com/pixelfed/pixelfed/commit/c54b29c5))
- Add Media Tags ([711fc020](https://github.com/pixelfed/pixelfed/commit/711fc020)) - Add Media Tags ([711fc020](https://github.com/pixelfed/pixelfed/commit/711fc020))
- Add MediaTagService ([524c6d45](https://github.com/pixelfed/pixelfed/commit/524c6d45)) - Add MediaTagService ([524c6d45](https://github.com/pixelfed/pixelfed/commit/524c6d45))
@ -373,7 +24,6 @@
- Add autospam feature ([b892bcf0](https://github.com/pixelfed/pixelfed/commit/b892bcf0)) - Add autospam feature ([b892bcf0](https://github.com/pixelfed/pixelfed/commit/b892bcf0))
- Add hCaptcha ([082c1ccb](https://github.com/pixelfed/pixelfed/commit/082c1ccb)) - Add hCaptcha ([082c1ccb](https://github.com/pixelfed/pixelfed/commit/082c1ccb))
- Add StatusView model to store views for discover algorithm ([7a68ee94](https://github.com/pixelfed/pixelfed/commit/7a68ee94)) - Add StatusView model to store views for discover algorithm ([7a68ee94](https://github.com/pixelfed/pixelfed/commit/7a68ee94))
- Add Year in Review feature (mysql only) ([f32072a3](https://github.com/pixelfed/pixelfed/commit/f32072a3))
### Updated ### Updated
- Updated PostComponent, fix remote urls ([42716ccc](https://github.com/pixelfed/pixelfed/commit/42716ccc)) - Updated PostComponent, fix remote urls ([42716ccc](https://github.com/pixelfed/pixelfed/commit/42716ccc))
@ -495,8 +145,8 @@
- Updated avatars, use jpeg default. ([f6528c84](https://github.com/pixelfed/pixelfed/commit/f6528c84)) - Updated avatars, use jpeg default. ([f6528c84](https://github.com/pixelfed/pixelfed/commit/f6528c84))
- Updated antispam bouncer, change recent from 1 week to 3 months. ([7d818197](https://github.com/pixelfed/pixelfed/commit/7d818197)) - Updated antispam bouncer, change recent from 1 week to 3 months. ([7d818197](https://github.com/pixelfed/pixelfed/commit/7d818197))
- Updated Post components, fix remote post and profile urls. ([cfcf17f3](https://github.com/pixelfed/pixelfed/commit/cfcf17f3)) - Updated Post components, fix remote post and profile urls. ([cfcf17f3](https://github.com/pixelfed/pixelfed/commit/cfcf17f3))
- Updated migrations, fix broken oauth change. ([4a885c88](https://github.com/pixelfed/pixelfed/commit/4a885c88)) - Update migrations, fix broken oauth change. ([4a885c88](https://github.com/pixelfed/pixelfed/commit/4a885c88))
- Updated LikeController, store status_profile_id and is_comment attributes. ([799a4cba](https://github.com/pixelfed/pixelfed/commit/799a4cba)) - Update LikeController, store status_profile_id and is_comment attributes. ([799a4cba](https://github.com/pixelfed/pixelfed/commit/799a4cba))
- Updated Profile, fix status count. ([6dcd472b](https://github.com/pixelfed/pixelfed/commit/6dcd472b)) - Updated Profile, fix status count. ([6dcd472b](https://github.com/pixelfed/pixelfed/commit/6dcd472b))
- Updated StatusService, cast response to array. ([0fbde91e](https://github.com/pixelfed/pixelfed/commit/0fbde91e)) - Updated StatusService, cast response to array. ([0fbde91e](https://github.com/pixelfed/pixelfed/commit/0fbde91e))
- Updated status model, use scope over deprecated visibility attribute. ([f70826e1](https://github.com/pixelfed/pixelfed/commit/f70826e1)) - Updated status model, use scope over deprecated visibility attribute. ([f70826e1](https://github.com/pixelfed/pixelfed/commit/f70826e1))
@ -505,44 +155,7 @@
- Updated AP helpers, fixed federation bug. ([a52564f3](https://github.com/pixelfed/pixelfed/commit/a52564f3)) - Updated AP helpers, fixed federation bug. ([a52564f3](https://github.com/pixelfed/pixelfed/commit/a52564f3))
- Updated Helpers, cache profiles. ([1f672ecf](https://github.com/pixelfed/pixelfed/commit/1f672ecf)) - Updated Helpers, cache profiles. ([1f672ecf](https://github.com/pixelfed/pixelfed/commit/1f672ecf))
- Updated DiscoverController, improve trending api performance. ([d8d3331f](https://github.com/pixelfed/pixelfed/commit/d8d3331f)) - Updated DiscoverController, improve trending api performance. ([d8d3331f](https://github.com/pixelfed/pixelfed/commit/d8d3331f))
- Updated InboxWorker, fix race condition in account deletes. ([4a4d8f00](https://github.com/pixelfed/pixelfed/commit/4a4d8f00)) - Update InboxWorker, fix race condition in account deletes. ([4a4d8f00](https://github.com/pixelfed/pixelfed/commit/4a4d8f00))
- Updated StoryItemTransformer, increase story duration from 5 seconds to 10 seconds. ([5b0b14fc](https://github.com/pixelfed/pixelfed/commit/5b0b14fc))
- Updated StatusController, add view method. ([0cfc12c5](https://github.com/pixelfed/pixelfed/commit/0cfc12c5))
- Updated MediaPathService, add story method. ([aac44309](https://github.com/pixelfed/pixelfed/commit/aac44309))
- Updated StatusDelete job, handle cloud storage media deletes. ([4b1a0fd7](https://github.com/pixelfed/pixelfed/commit/4b1a0fd7))
- Updated ImageOptimizePipeline, add skip_optimize and MediaStorageService support. ([234f72f3](https://github.com/pixelfed/pixelfed/commit/234f72f3))
- Updated Media model, add cdn support to url and thumbnailUrl methods. ([57fa889d](https://github.com/pixelfed/pixelfed/commit/57fa889d))
- Updated MediaController, remove deprecated endpoint. ([8132db74](https://github.com/pixelfed/pixelfed/commit/8132db74))
- Updated api controllers, deprecate old endpoints. ([4415af1b](https://github.com/pixelfed/pixelfed/commit/4415af1b))
- Updated mobile apis, add blurhash. ([cf40526e](https://github.com/pixelfed/pixelfed/commit/cf40526e))
- Updated Image media util, store dimensions of media not thumbnail. ([40bd64aa](https://github.com/pixelfed/pixelfed/commit/40bd64aa))
- Updated MediaTransformers, include meta attribute with focus and dimensions. ([f8cbe1e4](https://github.com/pixelfed/pixelfed/commit/f8cbe1e4))
- Updated storage, add remote media cache directory. ([0eabbfdd](https://github.com/pixelfed/pixelfed/commit/0eabbfdd))
- Updated backup config, prevents gateway timeouts for large databases using mysql. ([9cd4bd74](https://github.com/pixelfed/pixelfed/commit/9cd4bd74))
- Updated MediaPipeline, handle cloud object storage. ([be6d12fc](https://github.com/pixelfed/pixelfed/commit/be6d12fc))
- Updated AP Helpers, use MediaStoragePipeline. ([01a1ffd6](https://github.com/pixelfed/pixelfed/commit/01a1ffd6))
- Updated RemoteProfile component, change thumbnail url. ([c1118956](https://github.com/pixelfed/pixelfed/commit/c1118956))
- Updated blade views. ([9683e846](https://github.com/pixelfed/pixelfed/commit/9683e846))
- Updated cache config, use phpredis by default. ([ed6877df](https://github.com/pixelfed/pixelfed/commit/ed6877df))
- Updated components, fix url rewriter. Closes #2538. ([e8cc66dc](https://github.com/pixelfed/pixelfed/commit/e8cc66dc))
- Updated UserCreate command, closes #2581. ([b2b8c9f9](https://github.com/pixelfed/pixelfed/commit/b2b8c9f9))
- Updated AvatarController, remove deprecated thumb_path. ([889c3d87](https://github.com/pixelfed/pixelfed/commit/889c3d87))
- Updated VideoThumbnail, add MediaStoragePipeline. ([98c44f7b](https://github.com/pixelfed/pixelfed/commit/98c44f7b))
- Updated StatusDelete pipeline, fix object storage thumbnail deletion. ([f930c4bd](https://github.com/pixelfed/pixelfed/commit/f930c4bd))
- Updated MediaStorageService, clear transformer cache after storing media. ([ce6ab80d](https://github.com/pixelfed/pixelfed/commit/ce6ab80d))
- Updated MediaTransformer, remove cache busting. ([258b2729](https://github.com/pixelfed/pixelfed/commit/258b2729))
- Updated AP helpers, only run MediaStoragePipeline if using cloud storage. ([77f21b4b](https://github.com/pixelfed/pixelfed/commit/77f21b4b))
- Updated AvatarObserver, add logic to delete avatars stored in S3. ([9eafc31e](https://github.com/pixelfed/pixelfed/commit/9eafc31e))
- Updated Profile model, use cdn_url for avatars. ([ea8e4261](https://github.com/pixelfed/pixelfed/commit/ea8e4261))
- Updated ActivityPubFetchService, add url validation. ([654b08d3](https://github.com/pixelfed/pixelfed/commit/654b08d3))
- Updated MediaStorageService, add avatar method. ([94a9f685](https://github.com/pixelfed/pixelfed/commit/94a9f685))
- Updated AvatarPipeline, add remote avatar fetch. ([4c148055](https://github.com/pixelfed/pixelfed/commit/4c148055))
- Updated ComposeController, update media version. ([cc2d4bf8](https://github.com/pixelfed/pixelfed/commit/cc2d4bf8))
- Updated AP Helpers, add blurhash and RemoteAvatarFetch. ([de8828e8](https://github.com/pixelfed/pixelfed/commit/de8828e8))
- Updated Timeline, prevent nextTick() when reloading same comment modal. Fixes #2584. ([cc84125b](https://github.com/pixelfed/pixelfed/commit/cc84125b))
- Updated site config, add labels to config. ([abe9cb3d](https://github.com/pixelfed/pixelfed/commit/abe9cb3d))
- Update StatusLabelService, change config key. ([4abfe76a](https://github.com/pixelfed/pixelfed/commit/4abfe76a))
## [v0.10.9 (2020-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.10.8...v0.10.9) ## [v0.10.9 (2020-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.10.8...v0.10.9)
### Added ### Added
@ -600,7 +213,7 @@
- Updated StatusTransformer, fixes #[2113](https://github.com/pixelfed/pixelfed/issues/2113) ([eefa6e0d](https://github.com/pixelfed/pixelfed/commit/eefa6e0d)) - 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 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)) - 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) ## [v0.10.8 (2020-01-29)](https://github.com/pixelfed/pixelfed/compare/v0.10.7...v0.10.8)
### Added ### Added

View file

@ -11,19 +11,13 @@
A free and ethical photo sharing platform, powered by ActivityPub federation. A free and ethical photo sharing platform, powered by ActivityPub federation.
<p align="center"> <p align="center">
<img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/media/pixelfed-screenshot.jpg"> <img src="https://pixelfed.nyc3.cdn.digitaloceanspaces.com/media/Screen%20Shot%202019-09-08%20at%2010.40.54%20PM.png">
</p> </p>
## Official Documentation ## Official Documentation
Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://docs.pixelfed.org/). Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://docs.pixelfed.org/).
## Run on YunoHost
[![Install on YunoHost](https://user-images.githubusercontent.com/42862428/139559471-9495f1e9-e7a4-49f1-9a4b-675ddcc510a2.png 'Install on YunoHost')](https://install-app.yunohost.org/?app=pixelfed)
Pixelfed app for [YunoHost](https://yunohost.org 'YunoHost'). See [the package source code](https://github.com/YunoHost-Apps/pixelfed_ynh 'pixelfed_ynh repository on GitHub')
## License ## License
Pixelfed is open-sourced software licensed under the AGPL license. Pixelfed is open-sourced software licensed under the AGPL license.
@ -33,6 +27,7 @@ Pixelfed is open-sourced software licensed under the AGPL license.
The ways you can communicate on the project are below. Before interacting, please The ways you can communicate on the project are below. Before interacting, please
read through the [Code Of Conduct](CODE_OF_CONDUCT.md). 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) * Mastodon: [@pixelfed@mastodon.social](https://mastodon.social/@pixelfed)
* E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org) * E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org)

View file

@ -1,3 +0,0 @@
## Reporting a Vulnerability
If you discover any security related issues, please email hello@pixelfed.org instead of using the issue tracker.

View file

@ -6,9 +6,6 @@ use Illuminate\Database\Eloquent\Model;
class AccountLog extends Model class AccountLog extends Model
{ {
protected $fillable = ['*'];
public function user() public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View file

@ -14,21 +14,9 @@ class Avatar extends Model
* *
* @var array * @var array
*/ */
protected $dates = [ protected $dates = ['deleted_at'];
'deleted_at',
'last_fetched_at',
'last_processed_at'
];
protected $fillable = ['profile_id']; protected $fillable = ['profile_id'];
protected $visible = [
'id',
'profile_id',
'media_path',
'size',
];
public function profile() public function profile()
{ {
return $this->belongsTo(Profile::class); return $this->belongsTo(Profile::class);

View file

@ -4,7 +4,7 @@ namespace App;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\HasSnowflakePrimary; use Pixelfed\Snowflake\HasSnowflakePrimary;
class Collection extends Model class Collection extends Model
{ {

View file

@ -3,7 +3,7 @@
namespace App; namespace App;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\HasSnowflakePrimary; use Pixelfed\Snowflake\HasSnowflakePrimary;
class CollectionItem extends Model class CollectionItem extends Model
{ {

View file

@ -1,219 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Avatar;
use App\Profile;
use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class AvatarSync extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:sync';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform actions on avatars';
public $found = 0;
public $notFetched = 0;
public $fixed = 0;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Welcome to the avatar sync manager');
$actions = [
'Analyze',
'Full Analyze',
'Fetch - Fetch missing remote avatars',
'Fix - Fix remote accounts without avatar record',
'Sync - Store latest remote avatars',
];
$name = $this->choice(
'Select an action',
$actions,
0,
1,
false
);
$this->info('Selected: ' . $name);
switch($name) {
case $actions[0]:
$this->analyze();
break;
case $actions[1]:
$this->fullAnalyze();
break;
case $actions[2]:
$this->fetch();
break;
case $actions[3]:
$this->fix();
break;
case $actions[4]:
$this->sync();
break;
}
return Command::SUCCESS;
}
protected function incr($name)
{
switch($name) {
case 'found':
$this->found = $this->found + 1;
break;
case 'notFetched':
$this->notFetched = $this->notFetched + 1;
break;
case 'fixed':
$this->fixed++;
break;
}
}
protected function analyze()
{
$count = Avatar::whereIsRemote(true)->whereNull('cdn_url')->count();
$this->info('Found ' . $count . ' profiles with blank avatars.');
$this->line(' ');
$this->comment('We suggest running php artisan avatars:sync again and selecting the sync option');
$this->line(' ');
}
protected function fullAnalyze()
{
$count = Profile::count();
$bar = $this->output->createProgressBar($count);
$bar->start();
Profile::chunk(5000, function($profiles) use ($bar) {
foreach($profiles as $profile) {
if($profile->domain == null) {
$bar->advance();
continue;
}
$avatar = Avatar::whereProfileId($profile->id)->first();
if(!$avatar || $avatar->cdn_url == null) {
$this->incr('notFetched');
}
$this->incr('found');
$bar->advance();
}
});
$this->line(' ');
$this->line(' ');
$this->info('Found ' . $this->found . ' remote accounts');
$this->info('Found ' . $this->notFetched . ' remote avatars to fetch');
}
protected function fetch()
{
$this->info('Fetching ....');
Avatar::whereIsRemote(true)
->whereNull('cdn_url')
// ->with('profile')
->chunk(10, function($avatars) {
foreach($avatars as $avatar) {
if(!$avatar || !$avatar->profile) {
continue;
}
$url = $avatar->profile->remote_url;
if(!$url || !Helpers::validateUrl($url)) {
continue;
}
try {
$res = Helpers::fetchFromUrl($url);
if(
!is_array($res) ||
!isset($res['@context']) ||
!isset($res['icon']) ||
!isset($res['icon']['type']) ||
!isset($res['icon']['url']) ||
!Str::endsWith($res['icon']['url'], ['.png', '.jpg', '.jpeg'])
) {
continue;
}
} catch (\GuzzleHttp\Exception\RequestException $e) {
continue;
} catch(\Illuminate\Http\Client\ConnectionException $e) {
continue;
}
$avatar->remote_url = $res['icon']['url'];
$avatar->save();
RemoteAvatarFetch::dispatch($avatar->profile);
}
});
}
protected function fix()
{
Profile::chunk(5000, function($profiles) {
foreach($profiles as $profile) {
if($profile->domain == null || $profile->private_key) {
continue;
}
$avatar = Avatar::whereProfileId($profile->id)->first();
if($avatar) {
continue;
}
$avatar = new Avatar;
$avatar->is_remote = true;
$avatar->profile_id = $profile->id;
$avatar->save();
$this->incr('fixed');
}
});
$this->line(' ');
$this->line(' ');
$this->info('Fixed ' . $this->fixed . ' accounts with a blank avatar');
}
protected function sync()
{
Avatar::whereIsRemote(true)
->with('profile')
->chunk(10, function($avatars) {
foreach($avatars as $avatar) {
RemoteAvatarFetch::dispatch($avatar->profile);
}
});
}
}

View file

@ -1,76 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Http\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Spatie\Backup\BackupDestination\BackupDestination;
class BackupToCloud extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backup:cloud';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send backups to cloud storage';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$localDisk = Storage::disk('local');
$cloudDisk = Storage::disk('backup');
$backupDestination = new BackupDestination($localDisk, '', 'local');
if(
empty(config('filesystems.disks.backup.key')) ||
empty(config('filesystems.disks.backup.secret')) ||
empty(config('filesystems.disks.backup.endpoint')) ||
empty(config('filesystems.disks.backup.region')) ||
empty(config('filesystems.disks.backup.bucket'))
) {
$this->error('Backup disk not configured.');
$this->error('See https://docs.pixelfed.org/technical-documentation/env.html#filesystem for more information.');
return Command::FAILURE;
}
$newest = $backupDestination->newestBackup();
$name = $newest->path();
$parts = explode('/', $name);
$fileName = array_pop($parts);
$storagePath = 'backups';
$path = storage_path('app/'. $name);
$file = $cloudDisk->putFileAs($storagePath, new File($path), $fileName, 'private');
$this->info("Backup file successfully saved!");
$url = $cloudDisk->url($file);
$this->table(
['Name', 'URL'],
[
[$fileName, $url]
],
);
return Command::SUCCESS;
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class DatabaseSessionGarbageCollector extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gc:sessions';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Database sessions garbage collector';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if(config('session.driver') !== 'database') {
return Command::SUCCESS;
}
DB::transaction(function() {
DB::table('sessions')->whereNull('user_id')->delete();
});
DB::transaction(function() {
$ts = now()->subMonths(3)->timestamp;
DB::table('sessions')->where('last_activity', '<', $ts)->delete();
});
return Command::SUCCESS;
}
}

View file

@ -1,74 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class ExportLanguages extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'i18n:export';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Build and export js localization files.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if(config('app.env') !== 'local') {
$this->error('This command is meant for development purposes and should only be run in a local environment');
return Command::FAILURE;
}
$path = base_path('resources/lang');
$langs = [];
foreach (new \DirectoryIterator($path) as $io) {
$name = $io->getFilename();
$skip = ['vendor'];
if($io->isDot() || in_array($name, $skip)) {
continue;
}
if($io->isDir()) {
array_push($langs, $name);
}
}
$exportDir = resource_path('assets/js/i18n/');
$exportDirAlt = public_path('_lang/');
foreach($langs as $lang) {
$strings = \Lang::get('web', [], $lang);
$json = json_encode($strings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
$path = "{$exportDir}{$lang}.json";
file_put_contents($path, $json);
$pathAlt = "{$exportDirAlt}{$lang}.json";
file_put_contents($pathAlt, $json);
}
return Command::SUCCESS;
}
}

View file

@ -40,7 +40,7 @@ class FailedJobGC extends Command
{ {
FailedJob::chunk(50, function($jobs) { FailedJob::chunk(50, function($jobs) {
foreach($jobs as $job) { foreach($jobs as $job) {
if($job->failed_at->lt(now()->subHours(48))) { if($job->failed_at->lt(now()->subMonth())) {
$job->delete(); $job->delete();
} }
} }

View file

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

View file

@ -3,82 +3,103 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\{
use Illuminate\Support\Facades\Storage; DB,
use App\Story; Storage
use App\StoryView; };
use App\Jobs\StoryPipeline\StoryExpire; use App\{
use App\Jobs\StoryPipeline\StoryRotateMedia; Story,
use App\Services\StoryService; StoryView
};
class StoryGC extends Command class StoryGC extends Command
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
* @var string * @var string
*/ */
protected $signature = 'story:gc'; protected $signature = 'story:gc';
/** /**
* The console command description. * The console command description.
* *
* @var string * @var string
*/ */
protected $description = 'Clear expired Stories'; protected $description = 'Clear expired Stories';
/** /**
* Create a new command instance. * Create a new command instance.
* *
* @return void * @return void
*/ */
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
} }
/** /**
* Execute the console command. * Execute the console command.
* *
* @return mixed * @return mixed
*/ */
public function handle() public function handle()
{ {
$this->archiveExpiredStories(); $this->directoryScan();
$this->rotateMedia(); $this->deleteViews();
} $this->deleteStories();
}
protected function archiveExpiredStories() protected function directoryScan()
{ {
$stories = Story::whereActive(true) $day = now()->day;
->where('created_at', '<', now()->subHours(24))
->get();
foreach($stories as $story) { if($day != 3) {
StoryExpire::dispatch($story)->onQueue('story'); return;
} }
}
protected function rotateMedia() $monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
{
$queue = StoryService::rotateQueue();
if(!$queue || count($queue) == 0) { $t1 = Storage::directories('public/_esm.t1');
return; $t2 = Storage::directories('public/_esm.t2');
}
collect($queue) $dirs = array_merge($t1, $t2);
->each(function($id) {
$story = StoryService::getById($id); foreach($dirs as $dir) {
if(!$story) { $hash = last(explode('/', $dir));
StoryService::removeRotateQueue($id); if($hash != $monthHash) {
return; $this->info('Found directory to delete: ' . $dir);
} $this->deleteDirectory($dir);
if($story->created_at->gt(now()->subMinutes(20))) { }
return; }
} }
StoryRotateMedia::dispatch($story)->onQueue('story');
StoryService::removeRotateQueue($id); 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

@ -12,7 +12,7 @@ class UserCreate extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'user:create {--name=} {--username=} {--email=} {--password=} {--is_admin=0} {--confirm_email=0}'; protected $signature = 'user:create';
/** /**
* The console command description. * The console command description.
@ -40,26 +40,6 @@ class UserCreate extends Command
{ {
$this->info('Creating a new user...'); $this->info('Creating a new user...');
$o = $this->options();
if( $o['name'] &&
$o['username'] &&
$o['email'] &&
$o['password']
) {
$user = new User;
$user->username = $o['username'];
$user->name = $o['name'];
$user->email = $o['email'];
$user->password = bcrypt($o['password']);
$user->is_admin = (bool) $o['is_admin'];
$user->email_verified_at = (bool) $o['confirm_email'] ? now() : null;
$user->save();
$this->info('Successfully created user!');
return;
}
$name = $this->ask('Name'); $name = $this->ask('Name');
$username = $this->ask('Username'); $username = $this->ask('Username');

View file

@ -31,7 +31,6 @@ class Kernel extends ConsoleKernel
$schedule->command('story:gc')->everyFiveMinutes(); $schedule->command('story:gc')->everyFiveMinutes();
$schedule->command('gc:failedjobs')->dailyAt(3); $schedule->command('gc:failedjobs')->dailyAt(3);
$schedule->command('gc:passwordreset')->dailyAt('09:41'); $schedule->command('gc:passwordreset')->dailyAt('09:41');
$schedule->command('gc:sessions')->twiceDaily(13, 23);
} }
/** /**

View file

@ -4,75 +4,50 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable; use Throwable;
use League\OAuth2\Server\Exception\OAuthServerException;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
/** /**
* A list of the exception types that are not reported. * A list of the exception types that are not reported.
* *
* @var array * @var array
*/ */
protected $dontReport = [ protected $dontReport = [
OAuthServerException::class, //
\Zttp\ConnectionException::class, ];
\GuzzleHttp\Exception\ConnectException::class,
\Illuminate\Http\Client\ConnectionException::class
];
/** /**
* A list of the inputs that are never flashed for validation exceptions. * A list of the inputs that are never flashed for validation exceptions.
* *
* @var array * @var array
*/ */
protected $dontFlash = [ protected $dontFlash = [
'password', 'password',
'password_confirmation', 'password_confirmation',
]; ];
/** /**
* Report or log an exception. * Report or log an exception.
* *
* @param \Exception $exception * @param \Exception $exception
* *
* @return void * @return void
*/ */
public function report(Throwable $exception) public function report(Throwable $exception)
{ {
parent::report($exception); parent::report($exception);
} }
/** /**
* Register the exception handling callbacks for the application. * Render an exception into an HTTP response.
* *
* @return void * @param \Illuminate\Http\Request $request
*/ * @param \Exception $exception
public function register() *
{ * @return \Illuminate\Http\Response
$this->reportable(function (\BadMethodCallException $e) { */
return app()->environment() !== 'production'; public function render($request, Throwable $exception)
}); {
return parent::render($request, $exception);
$this->reportable(function (\Illuminate\Http\Client\ConnectionException $e) { }
return app()->environment() !== 'production';
});
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Exception $exception
*
* @return \Illuminate\Http\Response
*/
public function render($request, Throwable $exception)
{
if ($request->wantsJson())
return response()->json(
['error' => $exception->getMessage()],
method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500
);
return parent::render($request, $exception);
}
} }

View file

@ -1,19 +0,0 @@
<?php
namespace App;
use App\Services\SnowflakeService;
trait HasSnowflakePrimary
{
public static function bootHasSnowflakePrimary()
{
static::saving(function ($model) {
if (is_null($model->getKey())) {
$keyName = $model->getKeyName();
$id = SnowflakeService::next();
$model->setAttribute($keyName, $id);
}
});
}
}

View file

@ -22,13 +22,6 @@ use App\{
User, User,
UserFilter UserFilter
}; };
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\Mastodon\v1\AccountTransformer;
use App\Services\AccountService;
use App\Services\UserFilterService;
use App\Services\RelationshipService;
class AccountController extends Controller class AccountController extends Controller
{ {
@ -37,8 +30,6 @@ class AccountController extends Controller
'user.block', 'user.block',
]; ];
const FILTER_LIMIT = 'You cannot block or mute more than 100 accounts';
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
@ -76,10 +67,7 @@ class AccountController extends Controller
public function verifyEmail(Request $request) public function verifyEmail(Request $request)
{ {
$recentSent = EmailVerification::whereUserId(Auth::id()) return view('account.verify_email');
->whereDate('created_at', '>', now()->subHours(12))->count();
return view('account.verify_email', compact('recentSent'));
} }
public function sendVerifyEmail(Request $request) public function sendVerifyEmail(Request $request)
@ -148,12 +136,6 @@ class AccountController extends Controller
]); ]);
$user = Auth::user()->profile; $user = Auth::user()->profile;
$count = UserFilterService::muteCount($user->id);
abort_if($count >= 100, 422, self::FILTER_LIMIT);
if($count == 0) {
$filterCount = UserFilter::whereUserId($user->id)->count();
abort_if($filterCount >= 100, 422, self::FILTER_LIMIT);
}
$type = $request->input('type'); $type = $request->input('type');
$item = $request->input('item'); $item = $request->input('item');
$action = $type . '.mute'; $action = $type . '.mute';
@ -185,7 +167,6 @@ class AccountController extends Controller
Cache::forget("user:filter:list:$pid"); Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid"); Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid"); Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
return redirect()->back(); return redirect()->back();
} }
@ -236,7 +217,6 @@ class AccountController extends Controller
Cache::forget("user:filter:list:$pid"); Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid"); Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid"); Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
if($request->wantsJson()) { if($request->wantsJson()) {
return response()->json([200]); return response()->json([200]);
@ -253,12 +233,6 @@ class AccountController extends Controller
]); ]);
$user = Auth::user()->profile; $user = Auth::user()->profile;
$count = UserFilterService::blockCount($user->id);
abort_if($count >= 100, 422, self::FILTER_LIMIT);
if($count == 0) {
$filterCount = UserFilter::whereUserId($user->id)->count();
abort_if($filterCount >= 100, 422, self::FILTER_LIMIT);
}
$type = $request->input('type'); $type = $request->input('type');
$item = $request->input('item'); $item = $request->input('item');
$action = $type.'.block'; $action = $type.'.block';
@ -269,7 +243,7 @@ class AccountController extends Controller
switch ($type) { switch ($type) {
case 'user': case 'user':
$profile = Profile::findOrFail($item); $profile = Profile::findOrFail($item);
if ($profile->id == $user->id || ($profile->user && $profile->user->is_admin == true)) { if ($profile->id == $user->id || $profile->user->is_admin == true) {
return abort(403); return abort(403);
} }
$class = get_class($profile); $class = get_class($profile);
@ -291,7 +265,6 @@ class AccountController extends Controller
$pid = $user->id; $pid = $user->id;
Cache::forget("user:filter:list:$pid"); Cache::forget("user:filter:list:$pid");
Cache::forget("api:local:exp:rec:$pid"); Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
return redirect()->back(); return redirect()->back();
} }
@ -342,7 +315,6 @@ class AccountController extends Controller
Cache::forget("user:filter:list:$pid"); Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid"); Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid"); Cache::forget("api:local:exp:rec:$pid");
RelationshipService::refresh($pid, $profile->id);
return redirect()->back(); return redirect()->back();
} }
@ -515,82 +487,4 @@ class AccountController extends Controller
public function accountRestored(Request $request) public function accountRestored(Request $request)
{ {
} }
public function accountMutes(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'limit' => 'nullable|integer|min:1|max:40'
]);
$user = $request->user();
$limit = $request->input('limit') ?? 40;
$mutes = UserFilter::whereUserId($user->profile_id)
->whereFilterableType('App\Profile')
->whereFilterType('mute')
->simplePaginate($limit)
->pluck('filterable_id');
$accounts = Profile::find($mutes);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($accounts, new AccountTransformer());
$res = $fractal->createData($resource)->toArray();
$url = $request->url();
$page = $request->input('page', 1);
$next = $page < 40 ? $page + 1 : 40;
$prev = $page > 1 ? $page - 1 : 1;
$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
return response()->json($res, 200, ['Link' => $links]);
}
public function accountBlocks(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'limit' => 'nullable|integer|min:1|max:40',
'page' => 'nullable|integer|min:1|max:10'
]);
$user = $request->user();
$limit = $request->input('limit') ?? 40;
$blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id')
->whereUserId($user->profile_id)
->whereFilterableType('App\Profile')
->whereFilterType('block')
->simplePaginate($limit)
->pluck('filterable_id');
$profiles = Profile::findOrFail($blocked);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($profiles, new AccountTransformer());
$res = $fractal->createData($resource)->toArray();
$url = $request->url();
$page = $request->input('page', 1);
$next = $page < 40 ? $page + 1 : 40;
$prev = $page > 1 ? $page - 1 : 1;
$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
return response()->json($res, 200, ['Link' => $links]);
}
public function accountBlocksV2(Request $request)
{
return response()->json(UserFilterService::blocks($request->user()->profile_id), 200, [], JSON_UNESCAPED_SLASHES);
}
public function accountMutesV2(Request $request)
{
return response()->json(UserFilterService::mutes($request->user()->profile_id), 200, [], JSON_UNESCAPED_SLASHES);
}
public function accountFiltersV2(Request $request)
{
return response()->json(UserFilterService::filters($request->user()->profile_id), 200, [], JSON_UNESCAPED_SLASHES);
}
} }

View file

@ -14,58 +14,29 @@ trait AdminInstanceController
public function instances(Request $request) public function instances(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'filter' => [ 'filter' => [
'nullable', 'nullable',
'string', 'string',
'min:1', 'min:1',
'max:20', 'max:20',
Rule::in([ Rule::in(['autocw', 'unlisted', 'banned'])
'cw',
'unlisted',
'banned',
// 'popular',
'new',
'all'
])
], ],
]); ]);
if($request->has('q') && $request->filled('q')) { if($request->has('filter') && $request->filled('filter')) {
$instances = Instance::where('domain', 'like', '%' . $request->input('q') . '%')->simplePaginate(10);
} else if($request->has('filter') && $request->filled('filter')) {
switch ($request->filter) { switch ($request->filter) {
case 'cw': case 'autocw':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereAutoCw(true)->orderByDesc('id')->simplePaginate(10); $instances = Instance::whereAutoCw(true)->orderByDesc('id')->paginate(5);
break; break;
case 'unlisted': case 'unlisted':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereUnlisted(true)->orderByDesc('id')->simplePaginate(10); $instances = Instance::whereUnlisted(true)->orderByDesc('id')->paginate(5);
break; break;
case 'banned': case 'banned':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereBanned(true)->orderByDesc('id')->simplePaginate(10); $instances = Instance::whereBanned(true)->orderByDesc('id')->paginate(5);
break; break;
case 'new':
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->latest()->simplePaginate(10);
break;
// case 'popular':
// $popular = Profile::selectRaw('*, count(domain) as count')
// ->whereNotNull('domain')
// ->groupBy('domain')
// ->orderByDesc('count')
// ->take(10)
// ->get()
// ->pluck('domain')
// ->toArray();
// $instances = Instance::whereIn('domain', $popular)->simplePaginate(10);
// break;
default:
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
break;
} }
} else { } else {
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10); $instances = Instance::orderByDesc('id')->paginate(5);
} }
return view('admin.instances.home', compact('instances')); return view('admin.instances.home', compact('instances'));
} }
@ -126,10 +97,6 @@ trait AdminInstanceController
break; break;
} }
Cache::forget('instances:banned:domains');
Cache::forget('instances:unlisted:domains');
Cache::forget('instances:auto_cw:domains');
return response()->json([]); return response()->json([]);
} }
} }

View file

@ -27,7 +27,6 @@ trait AdminMediaController
], ],
'search' => 'nullable|string|min:1|max:20' 'search' => 'nullable|string|min:1|max:20'
]); ]);
if($request->filled('search')) { if($request->filled('search')) {
$profiles = Profile::where('username', 'like', '%'.$request->input('search').'%')->pluck('id')->toArray(); $profiles = Profile::where('username', 'like', '%'.$request->input('search').'%')->pluck('id')->toArray();
$media = Media::whereHas('status') $media = Media::whereHas('status')
@ -43,8 +42,7 @@ trait AdminMediaController
$media = MediaBlocklist::latest()->paginate(12); $media = MediaBlocklist::latest()->paginate(12);
return view('admin.media.home', compact('media')); return view('admin.media.home', compact('media'));
} }
$media = Media::whereHas('status')->with('status')->orderby('id', 'desc')->paginate(12);
$media = Media::whereNull('remote_url')->orderby('id', 'desc')->simplePaginate(12);
return view('admin.media.home', compact('media')); return view('admin.media.home', compact('media'));
} }

View file

@ -3,372 +3,12 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use Cache; use Cache;
use App\Report;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use App\Services\AccountService;
use App\Services\StatusService;
use App\{
AccountInterstitial,
Contact,
Hashtag,
Newsroom,
OauthClient,
Profile,
Report,
Status,
Story,
User
};
use Illuminate\Validation\Rule;
use App\Services\StoryService;
trait AdminReportController trait AdminReportController
{ {
public function reports(Request $request)
{
$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
$page = $request->input('page') ?? 1;
$ai = Cache::remember('admin-dash:reports:ai-count', 3600, function() {
return AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count();
});
$spam = Cache::remember('admin-dash:reports:spam-count', 3600, function() {
return AccountInterstitial::whereType('post.autospam')->whereNull('appeal_handled_at')->count();
});
$mailVerifications = Redis::scard('email:manual');
if($filter == 'open' && $page == 1) {
$reports = Cache::remember('admin-dash:reports:list-cache', 300, function() use($page, $filter) {
return Report::whereHas('status')
->whereHas('reportedUser')
->whereHas('reporter')
->orderBy('created_at','desc')
->when($filter, function($q, $filter) {
return $filter == 'open' ?
$q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen');
})
->paginate(6);
});
} else {
$reports = Report::whereHas('status')
->whereHas('reportedUser')
->whereHas('reporter')
->orderBy('created_at','desc')
->when($filter, function($q, $filter) {
return $filter == 'open' ?
$q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen');
})
->paginate(6);
}
return view('admin.reports.home', compact('reports', 'ai', 'spam', 'mailVerifications'));
}
public function showReport(Request $request, $id)
{
$report = Report::findOrFail($id);
return view('admin.reports.show', compact('report'));
}
public function appeals(Request $request)
{
$appeals = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->latest()
->paginate(6);
return view('admin.reports.appeals', compact('appeals'));
}
public function showAppeal(Request $request, $id)
{
$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->findOrFail($id);
$meta = json_decode($appeal->meta);
return view('admin.reports.show_appeal', compact('appeal', 'meta'));
}
public function spam(Request $request)
{
$this->validate($request, [
'tab' => 'sometimes|in:home,not-spam,spam,settings,custom,exemptions'
]);
$tab = $request->input('tab', 'home');
$openCount = Cache::remember('admin-dash:reports:spam-count', 3600, function() {
return AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->count();
});
$monthlyCount = Cache::remember('admin-dash:reports:spam-count:30d', 43200, function() {
return AccountInterstitial::whereType('post.autospam')
->where('created_at', '>', now()->subMonth())
->count();
});
$totalCount = Cache::remember('admin-dash:reports:spam-count:total', 43200, function() {
return AccountInterstitial::whereType('post.autospam')->count();
});
$uncategorized = Cache::remember('admin-dash:reports:spam-sync', 3600, function() {
return AccountInterstitial::whereType('post.autospam')
->whereIsSpam(null)
->whereNotNull('appeal_handled_at')
->exists();
});
$avg = Cache::remember('admin-dash:reports:spam-count:avg', 43200, function() {
if(config('database.default') != 'mysql') {
return 0;
}
return AccountInterstitial::selectRaw('*, count(id) as counter')
->whereType('post.autospam')
->groupBy('user_id')
->get()
->avg('counter');
});
$avgOpen = Cache::remember('admin-dash:reports:spam-count:avgopen', 43200, function() {
if(config('database.default') != 'mysql') {
return "0";
}
$seconds = AccountInterstitial::selectRaw('DATE(created_at) AS start_date, AVG(TIME_TO_SEC(TIMEDIFF(appeal_handled_at, created_at))) AS timediff')->whereType('post.autospam')->whereNotNull('appeal_handled_at')->where('created_at', '>', now()->subMonth())->get();
if(!$seconds) {
return "0";
}
$mins = floor($seconds->avg('timediff') / 60);
if($mins < 60) {
return $mins . ' min(s)';
}
if($mins < 2880) {
return floor($mins / 60) . ' hour(s)';
}
return floor($mins / 60 / 24) . ' day(s)';
});
$avgCount = $totalCount && $avg ? floor($totalCount / $avg) : "0";
if(in_array($tab, ['home', 'spam', 'not-spam'])) {
$appeals = AccountInterstitial::whereType('post.autospam')
->when($tab, function($q, $tab) {
switch($tab) {
case 'home':
return $q->whereNull('appeal_handled_at');
break;
case 'spam':
return $q->whereIsSpam(true);
break;
case 'not-spam':
return $q->whereIsSpam(false);
break;
}
})
->latest()
->paginate(6);
if($tab !== 'home') {
$appeals = $appeals->appends(['tab' => $tab]);
}
} else {
$appeals = new class {
public function count() {
return 0;
}
public function render() {
return;
}
};
}
return view('admin.reports.spam', compact('tab', 'appeals', 'openCount', 'monthlyCount', 'totalCount', 'avgCount', 'avgOpen', 'uncategorized'));
}
public function showSpam(Request $request, $id)
{
$appeal = AccountInterstitial::whereType('post.autospam')
->findOrFail($id);
$meta = json_decode($appeal->meta);
return view('admin.reports.show_spam', compact('appeal', 'meta'));
}
public function fixUncategorizedSpam(Request $request)
{
if(Cache::get('admin-dash:reports:spam-sync-active')) {
return redirect('/i/admin/reports/autospam');
}
Cache::put('admin-dash:reports:spam-sync-active', 1, 900);
AccountInterstitial::chunk(500, function($reports) {
foreach($reports as $report) {
if($report->item_type != 'App\Status') {
continue;
}
if($report->type != 'post.autospam') {
continue;
}
if($report->is_spam != null) {
continue;
}
$status = StatusService::get($report->item_id, false);
if(!$status) {
return;
}
$scope = $status['visibility'];
$report->is_spam = $scope == 'unlisted';
$report->in_violation = $report->is_spam;
$report->severity_index = 1;
$report->save();
}
});
Cache::forget('admin-dash:reports:spam-sync');
return redirect('/i/admin/reports/autospam');
}
public function updateSpam(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|in:dismiss,approve,dismiss-all,approve-all'
]);
$action = $request->input('action');
$appeal = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($id);
$meta = json_decode($appeal->meta);
$res = ['status' => 'success'];
$now = now();
Cache::forget('admin-dash:reports:spam-count:total');
Cache::forget('admin-dash:reports:spam-count:30d');
if($action == 'dismiss') {
$appeal->is_spam = true;
$appeal->appeal_handled_at = $now;
$appeal->save();
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'dismiss-all') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->update(['appeal_handled_at' => $now, 'is_spam' => true]);
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'approve-all') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->get()
->each(function($report) use($meta) {
$report->is_spam = false;
$report->appeal_handled_at = now();
$report->save();
$status = Status::find($report->item_id);
if($status) {
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
StatusService::del($status->id, true);
}
});
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
$status = $appeal->status;
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
$appeal->is_spam = false;
$appeal->appeal_handled_at = now();
$appeal->save();
StatusService::del($status->id);
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
public function updateAppeal(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|in:dismiss,approve'
]);
$action = $request->input('action');
$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->findOrFail($id);
if($action == 'dismiss') {
$appeal->appeal_handled_at = now();
$appeal->save();
Cache::forget('admin-dash:reports:ai-count');
return redirect('/i/admin/reports/appeals');
}
switch ($appeal->type) {
case 'post.cw':
$status = $appeal->status;
$status->is_nsfw = false;
$status->save();
break;
case 'post.unlist':
$status = $appeal->status;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
break;
default:
# code...
break;
}
$appeal->appeal_handled_at = now();
$appeal->save();
StatusService::del($status->id, true);
Cache::forget('admin-dash:reports:ai-count');
return redirect('/i/admin/reports/appeals');
}
public function updateReport(Request $request, $id) public function updateReport(Request $request, $id)
{ {
$this->validate($request, [ $this->validate($request, [
@ -393,7 +33,6 @@ trait AdminReportController
$report = Report::findOrFail($id); $report = Report::findOrFail($id);
$this->handleReportAction($report, $action); $this->handleReportAction($report, $action);
Cache::forget('admin-dash:reports:list-cache');
return response()->json(['msg'=> 'Success']); return response()->json(['msg'=> 'Success']);
} }
@ -413,20 +52,17 @@ trait AdminReportController
$item->is_nsfw = true; $item->is_nsfw = true;
$item->save(); $item->save();
$report->nsfw = true; $report->nsfw = true;
StatusService::del($item->id, true);
break; break;
case 'unlist': case 'unlist':
$item->visibility = 'unlisted'; $item->visibility = 'unlisted';
$item->save(); $item->save();
Cache::forget('profiles:private'); Cache::forget('profiles:private');
StatusService::del($item->id, true);
break; break;
case 'delete': case 'delete':
// Todo: fire delete job // Todo: fire delete job
$report->admin_seen = null; $report->admin_seen = null;
StatusService::del($item->id, true);
break; break;
case 'shadowban': case 'shadowban':
@ -479,55 +115,4 @@ trait AdminReportController
]; ];
return response()->json($res); return response()->json($res);
} }
public function reportMailVerifications(Request $request)
{
$ids = Redis::smembers('email:manual');
$ignored = Redis::smembers('email:manual-ignored');
$reports = [];
if($ids) {
$reports = collect($ids)
->filter(function($id) use($ignored) {
return !in_array($id, $ignored);
})
->map(function($id) {
$account = AccountService::get($id);
$user = User::whereProfileId($id)->first();
if(!$user) {
return [];
}
$account['email'] = $user->email;
return $account;
})
->filter(function($res) {
return $res && isset($res['id']);
})
->values();
}
return view('admin.reports.mail_verification', compact('reports', 'ignored'));
}
public function reportMailVerifyIgnore(Request $request)
{
$id = $request->input('id');
Redis::sadd('email:manual-ignored', $id);
return redirect('/i/admin/reports');
}
public function reportMailVerifyApprove(Request $request)
{
$id = $request->input('id');
$user = User::whereProfileId($id)->firstOrFail();
Redis::srem('email:manual', $id);
Redis::srem('email:manual-ignored', $id);
$user->email_verified_at = now();
$user->save();
return redirect('/i/admin/reports');
}
public function reportMailVerifyClearIgnored(Request $request)
{
Redis::del('email:manual-ignored');
return [200];
}
} }

View file

@ -6,192 +6,14 @@ use Artisan, Cache, DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Carbon\Carbon; use Carbon\Carbon;
use App\{Comment, Like, Media, Page, Profile, Report, Status, User}; use App\{Comment, Like, Media, Page, Profile, Report, Status, User};
use App\Models\InstanceActor;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Util\Lexer\PrettyNumber; use App\Util\Lexer\PrettyNumber;
use App\Models\ConfigCache;
use App\Services\ConfigCacheService;
use App\Util\Site\Config;
trait AdminSettingsController trait AdminSettingsController
{ {
public function settings(Request $request) public function settings(Request $request)
{ {
$cloud_storage = ConfigCacheService::get('pixelfed.cloud_storage'); return view('admin.settings.home');
$cloud_disk = config('filesystems.cloud');
$cloud_ready = !empty(config('filesystems.disks.' . $cloud_disk . '.key')) && !empty(config('filesystems.disks.' . $cloud_disk . '.secret'));
$types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
$rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : null;
$jpeg = in_array('image/jpg', $types) ? true : in_array('image/jpeg', $types);
$png = in_array('image/png', $types);
$gif = in_array('image/gif', $types);
$mp4 = in_array('video/mp4', $types);
$webp = in_array('image/webp', $types);
// $system = [
// 'permissions' => is_writable(base_path('storage')) && is_writable(base_path('bootstrap')),
// 'max_upload_size' => ini_get('post_max_size'),
// 'image_driver' => config('image.driver'),
// 'image_driver_loaded' => extension_loaded(config('image.driver'))
// ];
return view('admin.settings.home', compact(
'jpeg',
'png',
'gif',
'mp4',
'webp',
'rules',
'cloud_storage',
'cloud_disk',
'cloud_ready',
// 'system'
));
}
public function settingsHomeStore(Request $request)
{
$this->validate($request, [
'name' => 'nullable|string',
'short_description' => 'nullable',
'long_description' => 'nullable',
'max_photo_size' => 'nullable|integer|min:1',
'max_album_length' => 'nullable|integer|min:1|max:100',
'image_quality' => 'nullable|integer|min:1|max:100',
'type_jpeg' => 'nullable',
'type_png' => 'nullable',
'type_gif' => 'nullable',
'type_mp4' => 'nullable',
'type_webp' => 'nullable',
]);
if($request->filled('rule_delete')) {
$index = (int) $request->input('rule_delete');
$rules = ConfigCacheService::get('app.rules');
$json = json_decode($rules, true);
if(!$rules || empty($json)) {
return;
}
unset($json[$index]);
$json = json_encode(array_values($json));
ConfigCacheService::put('app.rules', $json);
return 200;
}
$media_types = explode(',', config_cache('pixelfed.media_types'));
$media_types_original = $media_types;
$mimes = [
'type_jpeg' => 'image/jpeg',
'type_png' => 'image/png',
'type_gif' => 'image/gif',
'type_mp4' => 'video/mp4',
'type_webp' => 'image/webp',
];
foreach ($mimes as $key => $value) {
if($request->input($key) == 'on') {
if(!in_array($value, $media_types)) {
array_push($media_types, $value);
}
} else {
$media_types = array_diff($media_types, [$value]);
}
}
if($media_types !== $media_types_original) {
ConfigCacheService::put('pixelfed.media_types', implode(',', array_unique($media_types)));
}
$keys = [
'name' => 'app.name',
'short_description' => 'app.short_description',
'long_description' => 'app.description',
'max_photo_size' => 'pixelfed.max_photo_size',
'max_album_length' => 'pixelfed.max_album_length',
'image_quality' => 'pixelfed.image_quality',
'account_limit' => 'pixelfed.max_account_size',
'custom_css' => 'uikit.custom.css',
'custom_js' => 'uikit.custom.js',
'about_title' => 'about.title'
];
foreach ($keys as $key => $value) {
$cc = ConfigCache::whereK($value)->first();
$val = $request->input($key);
if($cc && $cc->v != $val) {
ConfigCacheService::put($value, $val);
}
}
$bools = [
'activitypub' => 'federation.activitypub.enabled',
'open_registration' => 'pixelfed.open_registration',
'mobile_apis' => 'pixelfed.oauth_enabled',
'stories' => 'instance.stories.enabled',
'ig_import' => 'pixelfed.import.instagram.enabled',
'spam_detection' => 'pixelfed.bouncer.enabled',
'require_email_verification' => 'pixelfed.enforce_email_verification',
'enforce_account_limit' => 'pixelfed.enforce_account_limit',
'show_custom_css' => 'uikit.show_custom.css',
'show_custom_js' => 'uikit.show_custom.js',
'cloud_storage' => 'pixelfed.cloud_storage',
'account_autofollow' => 'account.autofollow'
];
foreach ($bools as $key => $value) {
$active = $request->input($key) == 'on';
if($key == 'activitypub' && $active && !InstanceActor::exists()) {
Artisan::call('instance:actor');
}
if( $key == 'mobile_apis' &&
$active &&
!file_exists(storage_path('oauth-public.key')) &&
!file_exists(storage_path('oauth-private.key'))
) {
Artisan::call('passport:keys');
Artisan::call('route:cache');
}
if(config_cache($value) !== $active) {
ConfigCacheService::put($value, (bool) $active);
}
}
if($request->filled('new_rule')) {
$rules = ConfigCacheService::get('app.rules');
$val = $request->input('new_rule');
if(!$rules) {
ConfigCacheService::put('app.rules', json_encode([$val]));
} else {
$json = json_decode($rules, true);
$json[] = $val;
ConfigCacheService::put('app.rules', json_encode(array_values($json)));
}
Cache::forget('api:v1:instance-data:rules');
Cache::forget('api:v1:instance-data-response');
}
if($request->filled('account_autofollow_usernames')) {
$usernames = explode(',', $request->input('account_autofollow_usernames'));
$names = [];
foreach($usernames as $n) {
$p = Profile::whereUsername($n)->first();
if(!$p) {
continue;
}
array_push($names, $p->username);
}
ConfigCacheService::put('account.autofollow_usernames', implode(',', $names));
}
Cache::forget(Config::CACHE_KEY);
return redirect('/i/admin/settings')->with('status', 'Successfully updated settings!');
} }
public function settingsBackups(Request $request) public function settingsBackups(Request $request)
@ -201,6 +23,51 @@ trait AdminSettingsController
return view('admin.settings.backups', compact('files')); 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) public function settingsMaintenance(Request $request)
{ {
return view('admin.settings.maintenance'); return view('admin.settings.maintenance');
@ -217,6 +84,15 @@ trait AdminSettingsController
return view('admin.settings.features'); 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) public function settingsPages(Request $request)
{ {
$pages = Page::orderByDesc('updated_at')->paginate(10); $pages = Page::orderByDesc('updated_at')->paginate(10);

View file

@ -16,27 +16,14 @@ trait AdminUserController
{ {
public function users(Request $request) public function users(Request $request)
{ {
$search = $request->has('a') && $request->query('a') == 'search' ? $request->query('q') : null;
$col = $request->query('col') ?? 'id'; $col = $request->query('col') ?? 'id';
$dir = $request->query('dir') ?? 'desc'; $dir = $request->query('dir') ?? 'desc';
$offset = $request->has('page') ? $request->input('page') : 0; $users = User::select('id', 'username', 'status')
$pagination = [ ->withCount('statuses')
'prev' => $offset > 0 ? $offset - 1 : null,
'next' => $offset + 1,
'query' => $search ? '&a=search&q=' . $search : null
];
$users = User::select('id', 'username', 'status', 'profile_id')
->orderBy($col, $dir) ->orderBy($col, $dir)
->when($search, function($q, $search) { ->simplePaginate(10);
return $q->where('username', 'like', "%{$search}%");
})
->when($offset, function($q, $offset) {
return $q->offset(($offset * 10));
})
->limit(10)
->get();
return view('admin.users.home', compact('users', 'pagination')); return view('admin.users.home', compact('users'));
} }
public function userShow(Request $request, $id) public function userShow(Request $request, $id)

View file

@ -11,39 +11,30 @@ use App\{
Profile, Profile,
Report, Report,
Status, Status,
Story,
User User
}; };
use DB, Cache, Storage; use DB, Cache;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use App\Http\Controllers\Admin\{ use App\Http\Controllers\Admin\{
AdminDiscoverController, AdminDiscoverController,
AdminInstanceController, AdminInstanceController,
AdminReportController, AdminReportController,
// AdminGroupsController,
AdminMediaController, AdminMediaController,
AdminSettingsController, AdminSettingsController,
// AdminStorageController,
AdminSupportController, AdminSupportController,
AdminUserController AdminUserController
}; };
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Services\AdminStatsService; use App\Services\AdminStatsService;
use App\Services\StatusService;
use App\Services\StoryService;
use App\Models\CustomEmoji;
class AdminController extends Controller class AdminController extends Controller
{ {
use AdminReportController, use AdminReportController,
AdminDiscoverController, AdminDiscoverController,
// AdminGroupsController,
AdminMediaController, AdminMediaController,
AdminSettingsController, AdminSettingsController,
AdminInstanceController, AdminInstanceController,
// AdminStorageController,
AdminUserController; AdminUserController;
public function __construct() public function __construct()
@ -61,15 +52,9 @@ class AdminController extends Controller
public function statuses(Request $request) public function statuses(Request $request)
{ {
$statuses = Status::orderBy('id', 'desc')->cursorPaginate(10); $statuses = Status::orderBy('id', 'desc')->simplePaginate(10);
$data = $statuses->map(function($status) {
return StatusService::get($status->id, false); return view('admin.statuses.home', compact('statuses'));
})
->filter(function($s) {
return $s;
})
->toArray();
return view('admin.statuses.home', compact('statuses', 'data'));
} }
public function showStatus(Request $request, $id) public function showStatus(Request $request, $id)
@ -79,6 +64,139 @@ class AdminController extends Controller
return view('admin.statuses.show', compact('status')); 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 appeals(Request $request)
{
$appeals = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->latest()
->paginate(6);
return view('admin.reports.appeals', compact('appeals'));
}
public function showAppeal(Request $request, $id)
{
$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->findOrFail($id);
$meta = json_decode($appeal->meta);
return view('admin.reports.show_appeal', compact('appeal', 'meta'));
}
public function spam(Request $request)
{
$appeals = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->latest()
->paginate(6);
return view('admin.reports.spam', compact('appeals'));
}
public function showSpam(Request $request, $id)
{
$appeal = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($id);
$meta = json_decode($appeal->meta);
return view('admin.reports.show_spam', compact('appeal', 'meta'));
}
public function updateSpam(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|in:dismiss,approve'
]);
$action = $request->input('action');
$appeal = AccountInterstitial::whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($id);
$meta = json_decode($appeal->meta);
if($action == 'dismiss') {
$appeal->appeal_handled_at = now();
$appeal->save();
return redirect('/i/admin/reports/autospam');
}
$status = $appeal->status;
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
$appeal->appeal_handled_at = now();
$appeal->save();
return redirect('/i/admin/reports/autospam');
}
public function updateAppeal(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|in:dismiss,approve'
]);
$action = $request->input('action');
$appeal = AccountInterstitial::whereNotNull('appeal_requested_at')
->whereNull('appeal_handled_at')
->findOrFail($id);
if($action == 'dismiss') {
$appeal->appeal_handled_at = now();
$appeal->save();
return redirect('/i/admin/reports/appeals');
}
switch ($appeal->type) {
case 'post.cw':
$status = $appeal->status;
$status->is_nsfw = false;
$status->save();
break;
case 'post.unlist':
$status = $appeal->status;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
break;
default:
# code...
break;
}
$appeal->appeal_handled_at = now();
$appeal->save();
return redirect('/i/admin/reports/appeals');
}
public function profiles(Request $request) public function profiles(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
@ -316,173 +434,4 @@ class AdminController extends Controller
$redirect = $news->published_at ? $news->permalink() : $news->editUrl(); $redirect = $news->published_at ? $news->permalink() : $news->editUrl();
return redirect($redirect); return redirect($redirect);
} }
public function diagnosticsHome(Request $request)
{
return view('admin.diagnostics.home');
}
public function diagnosticsDecrypt(Request $request)
{
$this->validate($request, [
'payload' => 'required'
]);
$key = 'exception_report:';
$decrypted = decrypt($request->input('payload'));
if(!starts_with($decrypted, $key)) {
abort(403, 'Can only decrypt error diagnostics');
}
$res = [
'decrypted' => substr($decrypted, strlen($key))
];
return response()->json($res);
}
public function stories(Request $request)
{
$stories = Story::with('profile')->latest()->paginate(10);
$stats = StoryService::adminStats();
return view('admin.stories.home', compact('stories', 'stats'));
}
public function customEmojiHome(Request $request)
{
if(!config('federation.custom_emoji.enabled')) {
return view('admin.custom-emoji.not-enabled');
}
$this->validate($request, [
'sort' => 'sometimes|in:all,local,remote,duplicates,disabled,search'
]);
if($request->has('cc')) {
Cache::forget('pf:admin:custom_emoji:stats');
Cache::forget('pf:custom_emoji');
return redirect(route('admin.custom-emoji'));
}
$sort = $request->input('sort') ?? 'all';
if($sort == 'search' && empty($request->input('q'))) {
return redirect(route('admin.custom-emoji'));
}
$pg = config('database.default') == 'pgsql';
$emojis = CustomEmoji::when($sort, function($query, $sort) use($request, $pg) {
if($sort == 'all') {
if($pg) {
return $query->latest();
} else {
return $query->groupBy('shortcode')->latest();
}
} else if($sort == 'local') {
return $query->latest()->where('domain', '=', config('pixelfed.domain.app'));
} else if($sort == 'remote') {
return $query->latest()->where('domain', '!=', config('pixelfed.domain.app'));
} else if($sort == 'duplicates') {
return $query->latest()->groupBy('shortcode')->havingRaw('count(*) > 1');
} else if($sort == 'disabled') {
return $query->latest()->whereDisabled(true);
} else if($sort == 'search') {
$q = $query
->latest()
->where('shortcode', 'like', '%' . $request->input('q') . '%')
->orWhere('domain', 'like', '%' . $request->input('q') . '%');
if(!$request->has('dups')) {
$q = $q->groupBy('shortcode');
}
return $q;
}
})
->simplePaginate(10)
->withQueryString();
$stats = Cache::remember('pf:admin:custom_emoji:stats', 43200, function() use($pg) {
$res = [
'total' => CustomEmoji::count(),
'active' => CustomEmoji::whereDisabled(false)->count(),
'remote' => CustomEmoji::where('domain', '!=', config('pixelfed.domain.app'))->count(),
];
if($pg) {
$res['duplicate'] = CustomEmoji::select('shortcode')->groupBy('shortcode')->havingRaw('count(*) > 1')->count();
} else {
$res['duplicate'] = CustomEmoji::groupBy('shortcode')->havingRaw('count(*) > 1')->count();
}
return $res;
});
return view('admin.custom-emoji.home', compact('emojis', 'sort', 'stats'));
}
public function customEmojiToggleActive(Request $request, $id)
{
abort_unless(config('federation.custom_emoji.enabled'), 404);
$emoji = CustomEmoji::findOrFail($id);
$emoji->disabled = !$emoji->disabled;
$emoji->save();
$key = CustomEmoji::CACHE_KEY . str_replace(':', '', $emoji->shortcode);
Cache::forget($key);
return redirect()->back();
}
public function customEmojiAdd(Request $request)
{
abort_unless(config('federation.custom_emoji.enabled'), 404);
return view('admin.custom-emoji.add');
}
public function customEmojiStore(Request $request)
{
abort_unless(config('federation.custom_emoji.enabled'), 404);
$this->validate($request, [
'shortcode' => [
'required',
'min:3',
'max:80',
'starts_with::',
'ends_with::',
Rule::unique('custom_emoji')->where(function ($query) use($request) {
return $query->whereDomain(config('pixelfed.domain.app'))
->whereShortcode($request->input('shortcode'));
})
],
'emoji' => 'required|file|mimes:jpg,png|max:' . (config('federation.custom_emoji.max_size') / 1000)
]);
$emoji = new CustomEmoji;
$emoji->shortcode = $request->input('shortcode');
$emoji->domain = config('pixelfed.domain.app');
$emoji->save();
$fileName = $emoji->id . '.' . $request->emoji->extension();
$request->emoji->storeAs('public/emoji', $fileName);
$emoji->media_path = 'emoji/' . $fileName;
$emoji->save();
Cache::forget('pf:custom_emoji');
return redirect(route('admin.custom-emoji'));
}
public function customEmojiDelete(Request $request, $id)
{
abort_unless(config('federation.custom_emoji.enabled'), 404);
$emoji = CustomEmoji::findOrFail($id);
Storage::delete("public/{$emoji->media_path}");
Cache::forget('pf:custom_emoji');
$emoji->delete();
return redirect(route('admin.custom-emoji'));
}
public function customEmojiShowDuplicates(Request $request, $id)
{
abort_unless(config('federation.custom_emoji.enabled'), 404);
$emoji = CustomEmoji::orderBy('id')->whereDisabled(false)->whereShortcode($id)->firstOrFail();
$emojis = CustomEmoji::whereShortcode($id)->where('id', '!=', $emoji->id)->cursorPaginate(10);
return view('admin.custom-emoji.duplicates', compact('emoji', 'emojis'));
}
} }

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,7 @@ use App\{
Media, Media,
Notification, Notification,
Profile, Profile,
Status, Status
StatusArchived
}; };
use App\Transformer\Api\{ use App\Transformer\Api\{
AccountTransformer, AccountTransformer,
@ -37,11 +36,9 @@ use App\Jobs\VideoPipeline\{
VideoPostProcess, VideoPostProcess,
VideoThumbnail VideoThumbnail
}; };
use App\Services\AccountService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\MediaPathService; use App\Services\MediaPathService;
use App\Services\MediaBlocklistService; use App\Services\MediaBlocklistService;
use App\Services\StatusService;
class BaseApiController extends Controller class BaseApiController extends Controller
{ {
@ -57,40 +54,26 @@ class BaseApiController extends Controller
public function notifications(Request $request) public function notifications(Request $request)
{ {
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$pid = $request->user()->profile_id; $pg = $request->input('pg');
$limit = $request->input('limit', 20); if($pg == true) {
$timeago = Carbon::now()->subMonths(6);
$since = $request->input('since_id'); $notifications = Notification::whereProfileId($pid)
$min = $request->input('min_id'); ->whereDate('created_at', '>', $timeago)
$max = $request->input('max_id'); ->latest()
->simplePaginate(10);
if(!$since && !$min && !$max) { $resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer());
$min = 1; $res = $this->fractal->createData($resource)->toArray();
} } else {
$this->validate($request, [
$maxId = null; 'page' => 'nullable|integer|min:1|max:10',
$minId = null; 'limit' => 'nullable|integer|min:1|max:40'
]);
if($max) { $limit = $request->input('limit') ?? 10;
$res = NotificationService::getMax($pid, $max, $limit); $page = $request->input('page') ?? 1;
$ids = NotificationService::getRankedMaxId($pid, $max, $limit); $end = (int) $page * $limit;
if(!empty($ids)) { $start = (int) $end - $limit;
$maxId = max($ids); $res = NotificationService::get($pid, $start, $end);
$minId = min($ids);
}
} else {
$res = NotificationService::getMin($pid, $min ?? $since, $limit);
$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
if(!empty($ids)) {
$maxId = max($ids);
$minId = min($ids);
}
}
if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
NotificationService::warmCache($pid, 400, true);
} }
return response()->json($res); return response()->json($res);
@ -200,6 +183,7 @@ class BaseApiController extends Controller
$avatar = Avatar::whereProfileId($profile->id)->firstOrFail(); $avatar = Avatar::whereProfileId($profile->id)->firstOrFail();
$opath = $avatar->media_path; $opath = $avatar->media_path;
$avatar->media_path = "$public/$name"; $avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count; $avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null; $avatar->last_processed_at = null;
$avatar->save(); $avatar->save();
@ -217,17 +201,117 @@ class BaseApiController extends Controller
public function showTempMedia(Request $request, $profileId, $mediaId, $timestamp) public function showTempMedia(Request $request, $profileId, $mediaId, $timestamp)
{ {
abort(400, 'Endpoint deprecated'); 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) public function uploadMedia(Request $request)
{ {
abort(400, 'Endpoint deprecated'); 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) public function deleteMedia(Request $request)
{ {
abort(400, 'Endpoint deprecated'); 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) public function verifyCredentials(Request $request)
@ -236,9 +320,17 @@ class BaseApiController extends Controller
abort_if(!$user, 403); abort_if(!$user, 403);
if($user->status != null) { if($user->status != null) {
Auth::logout(); Auth::logout();
abort(403); return redirect('/login');
} }
$res = AccountService::get($user->profile_id); $key = 'user:last_active_at:id:'.$user->id;
$ttl = now()->addMinutes(5);
Cache::remember($key, $ttl, function() use($user) {
$user->last_active_at = now();
$user->save();
return;
});
$resource = new Fractal\Resource\Item($user->profile, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res); return response()->json($res);
} }
@ -259,98 +351,26 @@ class BaseApiController extends Controller
public function accountLikes(Request $request) public function accountLikes(Request $request)
{ {
abort_if(!$request->user(), 403);
$this->validate($request, [
'page' => 'sometimes|int|min:1|max:20',
'limit' => 'sometimes|int|min:1|max:10'
]);
$user = $request->user(); $user = $request->user();
$limit = $request->input('limit', 10);
$res = \DB::table('likes')
->whereProfileId($user->profile_id)
->latest()
->simplePaginate($limit)
->map(function($id) {
$status = StatusService::get($id->status_id, false);
$status['favourited'] = true;
return $status;
})
->filter(function($post) {
return $post && isset($post['account']);
})
->values();
return response()->json($res);
}
public function archive(Request $request, $id)
{
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$status = Status::whereNull('in_reply_to_id') $limit = 10;
->whereNull('reblog_of_id') $page = (int) $request->input('page', 1);
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
if($status->scope === 'archived') { if($page > 20) {
return [200]; return [];
} }
$archive = new StatusArchived; $favourites = $user->profile->likes()
$archive->status_id = $status->id; ->latest()
$archive->profile_id = $status->profile_id; ->simplePaginate($limit)
$archive->original_scope = $status->scope; ->pluck('status_id');
$archive->save();
$status->scope = 'archived'; $statuses = Status::find($favourites)->reverse();
$status->visibility = 'draft';
$status->save();
StatusService::del($status->id, true);
AccountService::syncPostCount($status->profile_id);
return [200];
}
public function unarchive(Request $request, $id)
{
abort_if(!$request->user(), 403);
$status = Status::whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
if($status->scope !== 'archived') {
return [200];
}
$archive = StatusArchived::whereStatusId($status->id)
->whereProfileId($status->profile_id)
->firstOrFail();
$status->scope = $archive->original_scope;
$status->visibility = $archive->original_scope;
$status->save();
$archive->delete();
StatusService::del($status->id, true);
AccountService::syncPostCount($status->profile_id);
return [200];
}
public function archivedPosts(Request $request)
{
abort_if(!$request->user(), 403);
$statuses = Status::whereProfileId($request->user()->profile_id)
->whereScope('archived')
->orderByDesc('id')
->simplePaginate(10);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer()); $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
return $fractal->createData($resource)->toArray(); $res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
} }

View file

@ -34,7 +34,7 @@ class InstanceApiController extends Controller {
$res = [ $res = [
'uri' => config('pixelfed.domain.app'), 'uri' => config('pixelfed.domain.app'),
'title' => config_cache('app.name'), 'title' => config('app.name'),
'description' => '', 'description' => '',
'version' => config('pixelfed.version'), 'version' => config('pixelfed.version'),
'urls' => [], 'urls' => [],

View file

@ -27,10 +27,7 @@ class LoginController extends Controller
* *
* @var string * @var string
*/ */
protected $redirectTo = '/i/web'; protected $redirectTo = '/';
protected $maxAttempts = 5;
protected $decayMinutes = 60;
/** /**
* Create a new controller instance. * Create a new controller instance.

View file

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

View file

@ -35,6 +35,7 @@ class AvatarController extends Controller
$avatar = Avatar::firstOrNew(['profile_id' => $profile->id]); $avatar = Avatar::firstOrNew(['profile_id' => $profile->id]);
$currentAvatar = $avatar->recentlyCreated ? null : storage_path('app/'.$profile->avatar->media_path); $currentAvatar = $avatar->recentlyCreated ? null : storage_path('app/'.$profile->avatar->media_path);
$avatar->media_path = "$public/$name"; $avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count; $avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null; $avatar->last_processed_at = null;
$avatar->save(); $avatar->save();
@ -120,7 +121,10 @@ class AvatarController extends Controller
$avatar = $profile->avatar; $avatar = $profile->avatar;
if( $avatar->media_path == 'public/avatars/default.png' || if( $avatar->media_path == 'public/avatars/default.png' ||
$avatar->media_path == 'public/avatars/default.jpg' $avatar->thumb_path == 'public/avatars/default.png' ||
$avatar->media_path == 'public/avatars/default.jpg' ||
$avatar->thumb_path == 'public/avatars/default.jpg'
) { ) {
return; return;
} }
@ -129,7 +133,12 @@ class AvatarController extends Controller
@unlink(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.jpg'; $avatar->media_path = 'public/avatars/default.jpg';
$avatar->thumb_path = 'public/avatars/default.jpg';
$avatar->change_count = $avatar->change_count + 1; $avatar->change_count = $avatar->change_count + 1;
$avatar->save(); $avatar->save();

View file

@ -6,7 +6,6 @@ use App\Bookmark;
use App\Status; use App\Status;
use Auth; use Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\BookmarkService;
class BookmarkController extends Controller class BookmarkController extends Controller
{ {
@ -29,10 +28,7 @@ class BookmarkController extends Controller
); );
if (!$bookmark->wasRecentlyCreated) { if (!$bookmark->wasRecentlyCreated) {
BookmarkService::del($profile->id, $status->id);
$bookmark->delete(); $bookmark->delete();
} else {
BookmarkService::add($profile->id, $status->id);
} }
if ($request->ajax()) { if ($request->ajax()) {

View file

@ -18,7 +18,6 @@ use League\Fractal;
use App\Transformer\Api\StatusTransformer; use App\Transformer\Api\StatusTransformer;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Services\StatusService;
class CommentController extends Controller class CommentController extends Controller
{ {
@ -73,11 +72,13 @@ class CommentController extends Controller
$reply->visibility = $scope; $reply->visibility = $scope;
$reply->save(); $reply->save();
$status->reply_count++;
$status->save();
return $reply; return $reply;
}); });
StatusService::del($status->id); NewStatusPipeline::dispatch($reply, false);
NewStatusPipeline::dispatch($reply);
CommentPipeline::dispatch($status, $reply); CommentPipeline::dispatch($status, $reply);
if ($request->ajax()) { if ($request->ajax()) {

View file

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

View file

@ -390,6 +390,7 @@ class DirectMessageController extends Controller
$min_id = $request->input('min_id'); $min_id = $request->input('min_id');
$r = Profile::findOrFail($pid); $r = Profile::findOrFail($pid);
// $r = Profile::whereNull('domain')->findOrFail($pid);
if($min_id) { if($min_id) {
$res = DirectMessage::select('*') $res = DirectMessage::select('*')
@ -499,8 +500,8 @@ class DirectMessageController extends Controller
'file' => function() { 'file' => function() {
return [ return [
'required', 'required',
'mimes:' . config_cache('pixelfed.media_types'), 'mimes:' . config('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'), 'max:' . config('pixelfed.max_photo_size'),
]; ];
}, },
'to_id' => 'required' 'to_id' => 'required'
@ -521,18 +522,18 @@ class DirectMessageController extends Controller
$hidden = false; $hidden = false;
} }
if(config_cache('pixelfed.enforce_account_limit') == true) { if(config('pixelfed.enforce_account_limit') == true) {
$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
return Media::whereUserId($user->id)->sum('size') / 1000; return Media::whereUserId($user->id)->sum('size') / 1000;
}); });
$limit = (int) config_cache('pixelfed.max_account_size'); $limit = (int) config('pixelfed.max_account_size');
if ($size >= $limit) { if ($size >= $limit) {
abort(403, 'Account size limit reached.'); abort(403, 'Account size limit reached.');
} }
} }
$photo = $request->file('file'); $photo = $request->file('file');
$mimes = explode(',', config_cache('pixelfed.media_types')); $mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), $mimes) == false) { if(in_array($photo->getMimeType(), $mimes) == false) {
abort(403, 'Invalid or unsupported mime type.'); abort(403, 'Invalid or unsupported mime type.');
} }
@ -589,15 +590,11 @@ class DirectMessageController extends Controller
{ {
$this->validate($request, [ $this->validate($request, [
'q' => 'required|string|min:2|max:50', 'q' => 'required|string|min:2|max:50',
'remote' => 'nullable', 'remote' => 'nullable|boolean',
]); ]);
$q = $request->input('q'); $q = $request->input('q');
$r = $request->input('remote', false); $r = $request->input('remote');
if($r && !Str::of($q)->contains('.')) {
return [];
}
if($r && Helpers::validateUrl($q)) { if($r && Helpers::validateUrl($q)) {
Helpers::profileFetch($q); Helpers::profileFetch($q);

View file

@ -3,14 +3,14 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\{ use App\{
DiscoverCategory, DiscoverCategory,
Follower, Follower,
Hashtag, Hashtag,
HashtagFollow, HashtagFollow,
Profile, Profile,
Status, Status,
StatusHashtag, StatusHashtag,
UserFilter UserFilter
}; };
use Auth, DB, Cache; use Auth, DB, Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -24,172 +24,232 @@ use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Services\StatusHashtagService; use App\Services\StatusHashtagService;
use App\Services\SnowflakeService; use App\Services\SnowflakeService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Services\UserFilterService;
class DiscoverController extends Controller class DiscoverController extends Controller
{ {
protected $fractal; protected $fractal;
public function __construct() public function __construct()
{ {
$this->fractal = new Fractal\Manager(); $this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer()); $this->fractal->setSerializer(new ArraySerializer());
} }
public function home(Request $request) public function home(Request $request)
{ {
abort_if(!Auth::check() && config('instance.discover.public') == false, 403); abort_if(!Auth::check(), 403);
return view('discover.home'); return view('discover.home');
} }
public function showTags(Request $request, $hashtag) public function showTags(Request $request, $hashtag)
{ {
abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403); abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
$tag = Hashtag::whereName($hashtag) $tag = Hashtag::whereName($hashtag)
->orWhere('slug', $hashtag) ->orWhere('slug', $hashtag)
->firstOrFail(); ->firstOrFail();
$tagCount = StatusHashtagService::count($tag->id); $tagCount = StatusHashtagService::count($tag->id);
return view('discover.tags.show', compact('tag', 'tagCount')); return view('discover.tags.show', compact('tag', 'tagCount'));
} }
public function showCategory(Request $request, $slug) public function showCategory(Request $request, $slug)
{ {
abort(404); abort_if(!Auth::check(), 403);
}
public function showLoops(Request $request) $tag = DiscoverCategory::whereActive(true)
{ ->whereSlug($slug)
abort(404); ->firstOrFail();
}
public function loopsApi(Request $request) $posts = Cache::remember('discover:category-'.$tag->id.':posts', now()->addMinutes(15), function() use ($tag) {
{ $tagids = $tag->hashtags->pluck('id')->toArray();
abort(404); $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 loopWatch(Request $request) public function showLoops(Request $request)
{ {
return response()->json(200); if(config('exp.loops') != true) {
} return redirect('/');
}
return view('discover.loops.home');
}
public function getHashtags(Request $request) public function loopsApi(Request $request)
{ {
$auth = Auth::check(); abort_if(!config('exp.loops'), 403);
abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
$this->validate($request, [ // todo proper pagination, maybe LoopService
'hashtag' => 'required|string|min:1|max:124', $res = Cache::remember('discover:loops:recent', now()->addHours(6), function() {
'page' => 'nullable|integer|min:1|max:' . ($auth ? 29 : 10) $loops = Status::whereType('video')
]); ->whereNull('uri')
->whereScope('public')
->latest()
->take(18)
->get();
$page = $request->input('page') ?? '1'; $resource = new Fractal\Resource\Collection($loops, new StatusStatelessTransformer());
$end = $page > 1 ? $page * 9 : 0; return $this->fractal->createData($resource)->toArray();
$tag = $request->input('hashtag'); });
return $res;
}
$hashtag = Hashtag::whereName($tag)->firstOrFail(); public function loopWatch(Request $request)
if($page == 1) { {
$res['follows'] = HashtagFollow::whereUserId(Auth::id()) abort_if(!Auth::check(), 403);
->whereHashtagId($hashtag->id) abort_if(!config('exp.loops'), 403);
->exists();
}
$res['hashtag'] = [
'name' => $hashtag->name,
'url' => $hashtag->url()
];
$res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end);
return $res;
}
public function profilesDirectory(Request $request) $this->validate($request, [
{ 'id' => 'integer|min:1'
return redirect('/') ]);
->with('statusRedirect', 'The Profile Directory is unavailable at this time.'); $id = $request->input('id');
}
public function profilesDirectoryApi(Request $request) // todo log loops
{
return ['error' => 'Temporarily unavailable.'];
}
public function trendingApi(Request $request) return response()->json(200);
{ }
abort_if(config('instance.discover.public') == false && !Auth::check(), 403);
$this->validate($request, [ public function getHashtags(Request $request)
'range' => 'nullable|string|in:daily,monthly,yearly', {
]); $auth = Auth::check();
abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
$range = $request->input('range'); $this->validate($request, [
$days = $range == 'monthly' ? 31 : ($range == 'daily' ? 1 : 365); 'hashtag' => 'required|string|min:1|max:124',
$ttls = [ 'page' => 'nullable|integer|min:1|max:' . ($auth ? 29 : 10)
1 => 1500, ]);
31 => 14400,
365 => 86400
];
$key = ':api:discover:trending:v2.12:range:' . $days;
$ids = Cache::remember($key, $ttls[$days], function() use($days) { $page = $request->input('page') ?? '1';
$min_id = SnowflakeService::byDate(now()->subDays($days)); $end = $page > 1 ? $page * 9 : 0;
return DB::table('statuses') $tag = $request->input('hashtag');
->select(
'id',
'scope',
'type',
'is_nsfw',
'likes_count',
'created_at'
)
->where('id', '>', $min_id)
->whereNull('uri')
->whereScope('public')
->whereIn('type', [
'photo',
'photo:album',
'video'
])
->whereIsNsfw(false)
->orderBy('likes_count','desc')
->take(30)
->pluck('id');
});
$filtered = Auth::check() ? UserFilterService::filters(Auth::user()->profile_id) : []; $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;
}
$res = $ids->map(function($s) { public function profilesDirectory(Request $request)
return StatusService::get($s); {
})->filter(function($s) use($filtered) { return redirect('/')->with('statusRedirect', 'The Profile Directory is unavailable at this time.');
return return view('discover.profiles.home');
$s && }
!in_array($s['account']['id'], $filtered) &&
isset($s['account']);
})->values();
return response()->json($res); public function profilesDirectoryApi(Request $request)
} {
$this->validate($request, [
'page' => 'integer|max:10'
]);
public function trendingHashtags(Request $request) return ['error' => 'Temporarily unavailable.'];
{
$res = StatusHashtag::select('hashtag_id', \DB::raw('count(*) as total'))
->groupBy('hashtag_id')
->orderBy('total','desc')
->where('created_at', '>', now()->subDays(90))
->take(9)
->get()
->map(function($h) {
$hashtag = $h->hashtag;
return [
'id' => $hashtag->id,
'total' => $h->total,
'name' => '#'.$hashtag->name,
'url' => $hashtag->url('?src=dsh1')
];
});
return $res;
}
public function trendingPlaces(Request $request) $page = $request->input('page') ?? 1;
{ $key = 'discover:profiles:page:' . $page;
return []; $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'
]);
$range = $request->input('range') == 'monthly' ? 31 : 1;
$key = ':api:discover:trending:v2.8:range:' . $range;
$ttl = now()->addMinutes(15);
$ids = Cache::remember($key, $ttl, function() use($range) {
$days = $range == 1 ? 2 : 31;
$min_id = SnowflakeService::byDate(now()->subDays($days));
return Status::select(
'id',
'scope',
'type',
'is_nsfw',
'likes_count',
'created_at'
)
->where('id', '>', $min_id)
->whereNull('uri')
->whereScope('public')
->whereIn('type', [
'photo',
'photo:album',
'video'
])
->whereIsNsfw(false)
->orderBy('likes_count','desc')
->take(15)
->pluck('id');
});
$res = $ids->map(function($s) {
return StatusService::get($s);
});
return response()->json($res);
}
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

@ -3,17 +3,16 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\InboxPipeline\{ use App\Jobs\InboxPipeline\{
DeleteWorker, InboxWorker,
InboxWorker, InboxValidator
InboxValidator
}; };
use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline; use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
use App\{ use App\{
AccountLog, AccountLog,
Like, Like,
Profile, Profile,
Status, Status,
User User
}; };
use App\Util\Lexer\Nickname; use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger; use App\Util\Webfinger\Webfinger;
@ -24,164 +23,146 @@ use Illuminate\Http\Request;
use League\Fractal; use League\Fractal;
use App\Util\Site\Nodeinfo; use App\Util\Site\Nodeinfo;
use App\Util\ActivityPub\{ use App\Util\ActivityPub\{
Helpers, Helpers,
HttpSignature, HttpSignature,
Outbox Outbox
}; };
use Zttp\Zttp; use Zttp\Zttp;
class FederationController extends Controller class FederationController extends Controller
{ {
public function nodeinfoWellKnown() public function nodeinfoWellKnown()
{ {
abort_if(!config('federation.nodeinfo.enabled'), 404); abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES) return response()->json(Nodeinfo::wellKnown())
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function nodeinfo() public function nodeinfo()
{ {
abort_if(!config('federation.nodeinfo.enabled'), 404); abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES) return response()->json(Nodeinfo::get())
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function webfinger(Request $request) public function webfinger(Request $request)
{ {
abort_if(!config('federation.webfinger.enabled'), 400); abort_if(!config('federation.webfinger.enabled'), 400);
abort_if(!$request->has('resource') || !$request->filled('resource'), 400); abort_if(!$request->filled('resource'), 400);
$resource = $request->input('resource'); $resource = $request->input('resource');
$hash = hash('sha256', $resource); $parsed = Nickname::normalizeProfileUrl($resource);
$key = 'federation:webfinger:sha256:' . $hash; if($parsed['domain'] !== config('pixelfed.domain.app')) {
if($cached = Cache::get($key)) { abort(400);
return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES); }
} $username = $parsed['username'];
$domain = config('pixelfed.domain.app'); $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
abort_if(strpos($resource, $domain) == false, 400); if($profile->status != null) {
$parsed = Nickname::normalizeProfileUrl($resource); return ProfileController::accountCheck($profile);
if(empty($parsed) || $parsed['domain'] !== $domain) { }
abort(400); $webfinger = (new Webfinger($profile))->generate();
}
$username = $parsed['username'];
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
abort_if($profile->status != null, 400);
$webfinger = (new Webfinger($profile))->generate();
Cache::put($key, $webfinger, 1209600);
return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES) return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT)
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function hostMeta(Request $request) public function hostMeta(Request $request)
{ {
abort_if(!config('federation.webfinger.enabled'), 404); abort_if(!config('federation.webfinger.enabled'), 404);
$path = route('well-known.webfinger'); $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>'; $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'); return response($xml)->header('Content-Type', 'application/xrd+xml');
} }
public function userOutbox(Request $request, $username) public function userOutbox(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.outbox'), 404); abort_if(!config('federation.activitypub.outbox'), 404);
$profile = Profile::whereNull('domain') $profile = Profile::whereNull('domain')
->whereNull('status') ->whereNull('status')
->whereIsPrivate(false) ->whereIsPrivate(false)
->whereUsername($username) ->whereUsername($username)
->firstOrFail(); ->firstOrFail();
$key = 'ap:outbox:latest_10:pid:' . $profile->id; $key = 'ap:outbox:latest_10:pid:' . $profile->id;
$ttl = now()->addMinutes(15); $ttl = now()->addMinutes(15);
$res = Cache::remember($key, $ttl, function() use($profile) { $res = Cache::remember($key, $ttl, function() use($profile) {
return Outbox::get($profile); return Outbox::get($profile);
}); });
return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json'); return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
} }
public function userInbox(Request $request, $username) public function userInbox(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.inbox'), 404); abort_if(!config('federation.activitypub.inbox'), 404);
$headers = $request->headers->all(); $headers = $request->headers->all();
$payload = $request->getContent(); $payload = $request->getContent();
$obj = json_decode($payload, true, 8); dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
return;
}
if(isset($obj['type']) && $obj['type'] === 'Delete') { public function sharedInbox(Request $request)
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete'); {
} else { abort_if(!config('federation.activitypub.enabled'), 404);
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high'); abort_if(!config('federation.activitypub.sharedInbox'), 404);
}
return;
}
public function sharedInbox(Request $request) $headers = $request->headers->all();
{ $payload = $request->getContent();
abort_if(!config_cache('federation.activitypub.enabled'), 404); dispatch(new InboxWorker($headers, $payload))->onQueue('high');
abort_if(!config('federation.activitypub.sharedInbox'), 404); return;
}
$headers = $request->headers->all(); public function userFollowing(Request $request, $username)
$payload = $request->getContent(); {
$obj = json_decode($payload, true, 8); abort_if(!config('federation.activitypub.enabled'), 404);
if(isset($obj['type']) && $obj['type'] === 'Delete') { $profile = Profile::whereNull('remote_url')
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete'); ->whereUsername($username)
} else { ->whereIsPrivate(false)
dispatch(new InboxWorker($headers, $payload))->onQueue('high'); ->firstOrFail();
}
return;
}
public function userFollowing(Request $request, $username) if($profile->status != null) {
{ abort(404);
abort_if(!config_cache('federation.activitypub.enabled'), 404); }
$profile = Profile::whereNull('remote_url') $obj = [
->whereUsername($username) '@context' => 'https://www.w3.org/ns/activitystreams',
->whereIsPrivate(false) 'id' => $request->getUri(),
->firstOrFail(); 'type' => 'OrderedCollectionPage',
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
if($profile->status != null) { public function userFollowers(Request $request, $username)
abort(404); {
} abort_if(!config('federation.activitypub.enabled'), 404);
$obj = [ $profile = Profile::whereNull('remote_url')
'@context' => 'https://www.w3.org/ns/activitystreams', ->whereUsername($username)
'id' => $request->getUri(), ->whereIsPrivate(false)
'type' => 'OrderedCollectionPage', ->firstOrFail();
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
public function userFollowers(Request $request, $username) if($profile->status != null) {
{ abort(404);
abort_if(!config_cache('federation.activitypub.enabled'), 404); }
$profile = Profile::whereNull('remote_url') $obj = [
->whereUsername($username) '@context' => 'https://www.w3.org/ns/activitystreams',
->whereIsPrivate(false) 'id' => $request->getUri(),
->firstOrFail(); 'type' => 'OrderedCollectionPage',
'totalItems' => 0,
'orderedItems' => []
];
if($profile->status != null) { return response()->json($obj);
abort(404); }
}
$obj = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $request->getUri(),
'type' => 'OrderedCollectionPage',
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
} }

View file

@ -12,7 +12,6 @@ use Auth, Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\FollowPipeline\FollowPipeline;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use App\Services\FollowerService;
class FollowerController extends Controller class FollowerController extends Controller
{ {
@ -72,8 +71,6 @@ class FollowerController extends Controller
if($remote == true && config('federation.activitypub.remoteFollow') == true) { if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target); $this->sendFollow($user, $target);
} }
FollowerService::add($user->id, $target->id);
} elseif ($private == false && $isFollowing == 0) { } elseif ($private == false && $isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) { if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts'); abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
@ -90,7 +87,6 @@ class FollowerController extends Controller
if($remote == true && config('federation.activitypub.remoteFollow') == true) { if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target); $this->sendFollow($user, $target);
} }
FollowerService::add($user->id, $target->id);
FollowPipeline::dispatch($follower); FollowPipeline::dispatch($follower);
} else { } else {
if($force == true) { if($force == true) {
@ -105,7 +101,6 @@ class FollowerController extends Controller
Follower::whereProfileId($user->id) Follower::whereProfileId($user->id)
->whereFollowingId($target->id) ->whereFollowingId($target->id)
->delete(); ->delete();
FollowerService::remove($user->id, $target->id);
} }
} }

View file

@ -15,13 +15,10 @@ use App\Jobs\ImportPipeline\ImportInstagram;
trait Instagram trait Instagram
{ {
public function instagram() public function instagram()
{ {
if(config_cache('pixelfed.import.instagram.enabled') != true) { return view('settings.import.instagram.home');
abort(404, 'Feature not enabled'); }
}
return view('settings.import.instagram.home');
}
public function instagramStart(Request $request) public function instagramStart(Request $request)
{ {

View file

@ -6,11 +6,8 @@ use Illuminate\Http\Request;
trait Mastodon trait Mastodon
{ {
public function mastodon() public function mastodon()
{ {
if(config_cache('pixelfed.import.instagram.enabled') != true) { return view('settings.import.mastodon.home');
abort(404, 'Feature not enabled'); }
}
return view('settings.import.mastodon.home');
}
} }

View file

@ -11,6 +11,10 @@ class ImportController extends Controller
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
if(config('pixelfed.import.instagram.enabled') != true) {
abort(404, 'Feature not enabled');
}
} }
} }

View file

@ -12,7 +12,7 @@ class InstanceActorController extends Controller
{ {
$res = Cache::rememberForever(InstanceActor::PROFILE_KEY, function() { $res = Cache::rememberForever(InstanceActor::PROFILE_KEY, function() {
$res = (new InstanceActor())->first()->getActor(); $res = (new InstanceActor())->first()->getActor();
return json_encode($res, JSON_UNESCAPED_SLASHES); return json_encode($res);
}); });
return response($res)->header('Content-Type', 'application/json'); return response($res)->header('Content-Type', 'application/json');
} }

View file

@ -4,34 +4,30 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\{ use App\{
AccountInterstitial, AccountInterstitial,
Bookmark, DirectMessage,
DirectMessage, DiscoverCategory,
DiscoverCategory, Hashtag,
Hashtag, Follower,
Follower, Like,
Like, Media,
Media, MediaTag,
MediaTag, Notification,
Notification, Profile,
Profile, StatusHashtag,
StatusHashtag, Status,
Status, UserFilter,
User,
UserFilter,
}; };
use Auth,Cache; use Auth,Cache;
use Illuminate\Support\Facades\Redis;
use Carbon\Carbon; use Carbon\Carbon;
use League\Fractal; use League\Fractal;
use App\Transformer\Api\{ use App\Transformer\Api\{
AccountTransformer, AccountTransformer,
StatusTransformer, StatusTransformer,
// StatusMediaContainerTransformer, // StatusMediaContainerTransformer,
}; };
use App\Util\Media\Filter; use App\Util\Media\Filter;
use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\Jobs\ModPipeline\HandleSpammerPipeline;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -39,405 +35,518 @@ use Illuminate\Support\Str;
use App\Services\MediaTagService; use App\Services\MediaTagService;
use App\Services\ModLogService; use App\Services\ModLogService;
use App\Services\PublicTimelineService; use App\Services\PublicTimelineService;
use App\Services\SnowflakeService;
use App\Services\StatusService;
use App\Services\UserFilterService;
use App\Services\DiscoverService;
use App\Services\BookmarkService;
class InternalApiController extends Controller class InternalApiController extends Controller
{ {
protected $fractal; protected $fractal;
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
$this->fractal = new Fractal\Manager(); $this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer()); $this->fractal->setSerializer(new ArraySerializer());
} }
// deprecated v2 compose api // deprecated v2 compose api
public function compose(Request $request) public function compose(Request $request)
{ {
return redirect('/'); return redirect('/');
} }
// deprecated // deprecated
public function discover(Request $request) public function discover(Request $request)
{ {
return; return;
} }
public function discoverPosts(Request $request) public function discoverPosts(Request $request)
{ {
$pid = $request->user()->profile_id; $profile = Auth::user()->profile;
$filters = UserFilterService::filters($pid); $pid = $profile->id;
$forYou = DiscoverService::getForYou(); $following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(15), function() use ($pid) {
$posts = $forYou->take(50)->map(function($post) { return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
return StatusService::get($post); });
}) $filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(15), function() use($pid) {
->filter(function($post) use($filters) { $private = Profile::whereIsPrivate(true)
return $post && ->orWhere('unlisted', true)
isset($post['account']) && ->orWhere('status', '!=', null)
isset($post['account']['id']) && ->pluck('id')
!in_array($post['account']['id'], $filters); ->toArray();
}) $filters = UserFilter::whereUserId($pid)
->take(12) ->whereFilterableType('App\Profile')
->values(); ->whereIn('filter_type', ['mute', 'block'])
return response()->json(compact('posts')); ->pluck('filterable_id')
} ->toArray();
return array_merge($private, $filters);
});
$following = array_merge($following, $filters);
public function directMessage(Request $request, $profileId, $threadId) $sql = config('database.default') !== 'pgsql';
{
$profile = Auth::user()->profile;
if($profileId != $profile->id) { $posts = Status::select(
abort(403); 'id',
} 'caption',
'is_nsfw',
'profile_id',
'type',
'uri',
'created_at'
)
->whereNull('uri')
->whereIn('type', ['photo','photo:album', 'video'])
->whereIsNsfw(false)
->whereVisibility('public')
->whereNotIn('profile_id', $following)
->when($sql, function($q, $s) {
return $q->where('created_at', '>', now()->subMonths(3));
})
->with('media')
->inRandomOrder()
->latest()
->take(39)
->get();
$msg = DirectMessage::whereToId($profile->id) $res = [
->orWhere('from_id',$profile->id) 'posts' => $posts->map(function($post) {
->findOrFail($threadId); return [
'type' => $post->type,
'url' => $post->url(),
'thumb' => $post->thumb(),
];
})
];
return response()->json($res);
}
$thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id]) public function directMessage(Request $request, $profileId, $threadId)
->whereIn('from_id', [$profile->id,$msg->from_id]) {
->orderBy('created_at', 'asc') $profile = Auth::user()->profile;
->paginate(30);
return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT); if($profileId != $profile->id) {
} abort(403);
}
public function statusReplies(Request $request, int $id) $msg = DirectMessage::whereToId($profile->id)
{ ->orWhere('from_id',$profile->id)
$this->validate($request, [ ->findOrFail($threadId);
'limit' => 'nullable|int|min:1|max:6'
]);
$parent = Status::whereScope('public')->findOrFail($id);
$limit = $request->input('limit') ?? 3;
$children = Status::whereInReplyToId($parent->id)
->orderBy('created_at', 'desc')
->take($limit)
->get();
$resource = new Fractal\Resource\Collection($children, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res); $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);
public function stories(Request $request) return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
{ }
} public function statusReplies(Request $request, int $id)
{
$parent = Status::whereScope('public')->findOrFail($id);
public function discoverCategories(Request $request) $children = Status::whereInReplyToId($parent->id)
{ ->orderBy('created_at', 'desc')
$categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get(); ->take(3)
$res = $categories->map(function($item) { ->get();
return [
'name' => $item->name,
'url' => $item->url(),
'thumb' => $item->thumb()
];
});
return response()->json($res);
}
public function modAction(Request $request) $resource = new Fractal\Resource\Collection($children, new StatusTransformer());
{ $res = $this->fractal->createData($resource)->toArray();
abort_unless(Auth::user()->is_admin, 400);
$this->validate($request, [
'action' => [
'required',
'string',
Rule::in([
'addcw',
'remcw',
'unlist',
'spammer'
])
],
'item_id' => 'required|integer|min:1',
'item_type' => [
'required',
'string',
Rule::in(['profile', 'status'])
]
]);
$action = $request->input('action'); return response()->json($res);
$item_id = $request->input('item_id'); }
$item_type = $request->input('item_type');
$status = Status::findOrFail($item_id); public function stories(Request $request)
$author = User::whereProfileId($status->profile_id)->first(); {
abort_if($author && $author->is_admin, 422, 'Cannot moderate administrator accounts');
switch($action) { }
case 'addcw':
$status->is_nsfw = true;
$status->save();
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
if($status->uri == null) { public function discoverCategories(Request $request)
$media = $status->media; {
$ai = new AccountInterstitial; $categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
$ai->user_id = $status->profile->user_id; $res = $categories->map(function($item) {
$ai->type = 'post.cw'; return [
$ai->view = 'account.moderation.post.cw'; 'name' => $item->name,
$ai->item_type = 'App\Status'; 'url' => $item->url(),
$ai->item_id = $status->id; 'thumb' => $item->thumb()
$ai->has_media = (bool) $media->count(); ];
$ai->blurhash = $media->count() ? $media->first()->blurhash : null; });
$ai->meta = json_encode([ return response()->json($res);
'caption' => $status->caption, }
'created_at' => $status->created_at,
'type' => $status->type,
'url' => $status->url(),
'is_nsfw' => $status->is_nsfw,
'scope' => $status->scope,
'reblog' => $status->reblog_of_id,
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
]);
$ai->save();
$u = $status->profile->user; public function modAction(Request $request)
$u->has_interstitial = true; {
$u->save(); abort_unless(Auth::user()->is_admin, 400);
} $this->validate($request, [
break; 'action' => [
'required',
'string',
Rule::in([
'addcw',
'remcw',
'unlist'
case 'remcw': ])
$status->is_nsfw = false; ],
$status->save(); 'item_id' => 'required|integer|min:1',
ModLogService::boot() 'item_type' => [
->user(Auth::user()) 'required',
->objectUid($status->profile->user_id) 'string',
->objectId($status->id) Rule::in(['profile', 'status'])
->objectType('App\Status::class') ]
->action('admin.status.moderate') ]);
->metadata([
'action' => 'remove_cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
if($status->uri == null) {
$ai = AccountInterstitial::whereUserId($status->profile->user_id)
->whereType('post.cw')
->whereItemId($status->id)
->whereItemType('App\Status')
->first();
$ai->delete();
}
break;
case 'unlist': $action = $request->input('action');
$status->scope = $status->visibility = 'unlisted'; $item_id = $request->input('item_id');
$status->save(); $item_type = $request->input('item_type');
PublicTimelineService::del($status->id);
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'unlist',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
if($status->uri == null) { switch($action) {
$media = $status->media; case 'addcw':
$ai = new AccountInterstitial; $status = Status::findOrFail($item_id);
$ai->user_id = $status->profile->user_id; $status->is_nsfw = true;
$ai->type = 'post.unlist'; $status->save();
$ai->view = 'account.moderation.post.unlist'; ModLogService::boot()
$ai->item_type = 'App\Status'; ->user(Auth::user())
$ai->item_id = $status->id; ->objectUid($status->profile->user_id)
$ai->has_media = (bool) $media->count(); ->objectId($status->id)
$ai->blurhash = $media->count() ? $media->first()->blurhash : null; ->objectType('App\Status::class')
$ai->meta = json_encode([ ->action('admin.status.moderate')
'caption' => $status->caption, ->metadata([
'created_at' => $status->created_at, 'action' => 'cw',
'type' => $status->type, 'message' => 'Success!'
'url' => $status->url(), ])
'is_nsfw' => $status->is_nsfw, ->accessLevel('admin')
'scope' => $status->scope, ->save();
'reblog' => $status->reblog_of_id,
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
]);
$ai->save();
$u = $status->profile->user;
$u->has_interstitial = true;
$u->save();
}
break;
case 'spammer': if($status->uri == null) {
HandleSpammerPipeline::dispatch($status->profile); $media = $status->media;
ModLogService::boot() $ai = new AccountInterstitial;
->user(Auth::user()) $ai->user_id = $status->profile->user_id;
->objectUid($status->profile->user_id) $ai->type = 'post.cw';
->objectId($status->id) $ai->view = 'account.moderation.post.cw';
->objectType('App\User::class') $ai->item_type = 'App\Status';
->action('admin.status.moderate') $ai->item_id = $status->id;
->metadata([ $ai->has_media = (bool) $media->count();
'action' => 'spammer', $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
'message' => 'Success!' $ai->meta = json_encode([
]) 'caption' => $status->caption,
->accessLevel('admin') 'created_at' => $status->created_at,
->save(); 'type' => $status->type,
break; 'url' => $status->url(),
} 'is_nsfw' => $status->is_nsfw,
'scope' => $status->scope,
'reblog' => $status->reblog_of_id,
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
]);
$ai->save();
StatusService::del($status->id, true); $u = $status->profile->user;
return ['msg' => 200]; $u->has_interstitial = true;
} $u->save();
}
break;
public function composePost(Request $request) case 'remcw':
{ $status = Status::findOrFail($item_id);
abort(400, 'Endpoint deprecated'); $status->is_nsfw = false;
} $status->save();
ModLogService::boot()
->user(Auth::user())
->objectUid($status->profile->user_id)
->objectId($status->id)
->objectType('App\Status::class')
->action('admin.status.moderate')
->metadata([
'action' => 'remove_cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
if($status->uri == null) {
$ai = AccountInterstitial::whereUserId($status->profile->user_id)
->whereType('post.cw')
->whereItemId($status->id)
->whereItemType('App\Status')
->first();
$ai->delete();
}
break;
public function bookmarks(Request $request) case 'unlist':
{ $status = Status::whereScope('public')->findOrFail($item_id);
$pid = $request->user()->profile_id; $status->scope = $status->visibility = 'unlisted';
$res = Bookmark::whereProfileId($pid) $status->save();
->orderByDesc('created_at') PublicTimelineService::del($status->id);
->simplePaginate(10) ModLogService::boot()
->map(function($bookmark) use($pid) { ->user(Auth::user())
$status = StatusService::get($bookmark->status_id, false); ->objectUid($status->profile->user_id)
if(!$status) { ->objectId($status->id)
return false; ->objectType('App\Status::class')
} ->action('admin.status.moderate')
$status['bookmarked_at'] = $bookmark->created_at->format('c'); ->metadata([
'action' => 'unlist',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
if($status) { if($status->uri == null) {
BookmarkService::add($pid, $status['id']); $media = $status->media;
} $ai = new AccountInterstitial;
return $status; $ai->user_id = $status->profile->user_id;
}) $ai->type = 'post.unlist';
->filter(function($bookmark) { $ai->view = 'account.moderation.post.unlist';
return $bookmark && isset($bookmark['id']); $ai->item_type = 'App\Status';
}) $ai->item_id = $status->id;
->values(); $ai->has_media = (bool) $media->count();
$ai->blurhash = $media->count() ? $media->first()->blurhash : null;
$ai->meta = json_encode([
'caption' => $status->caption,
'created_at' => $status->created_at,
'type' => $status->type,
'url' => $status->url(),
'is_nsfw' => $status->is_nsfw,
'scope' => $status->scope,
'reblog' => $status->reblog_of_id,
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
]);
$ai->save();
return response()->json($res); $u = $status->profile->user;
} $u->has_interstitial = true;
$u->save();
}
break;
}
return ['msg' => 200];
}
public function accountStatuses(Request $request, $id) public function composePost(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'only_media' => 'nullable', 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
'pinned' => 'nullable', 'media.*' => 'required',
'exclude_replies' => 'nullable', 'media.*.id' => 'required|integer|min:1',
'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'media.*.filter_class' => 'nullable|alpha_dash|max:30',
'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'media.*.license' => 'nullable|string|max:140',
'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'media.*.alt' => 'nullable|string|max:140',
'limit' => 'nullable|integer|min:1|max:24' 'cw' => 'nullable|boolean',
]); 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable',
'comments_disabled' => 'nullable',
'tagged' => 'nullable'
]);
$profile = Profile::whereNull('status')->findOrFail($id); 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');
}
}
}
}
$limit = $request->limit ?? 9; $user = Auth::user();
$max_id = $request->max_id; $profile = $user->profile;
$min_id = $request->min_id; $visibility = $request->input('visibility');
$scope = $request->only_media == true ? $medias = $request->input('media');
['photo', 'photo:album', 'video', 'video:album'] : $attachments = [];
['photo', 'photo:album', 'video', 'video:album', 'share', 'reply']; $status = new Status;
$mimes = [];
$place = $request->input('place');
$cw = $request->input('cw');
$tagged = $request->input('tagged');
if($profile->is_private) { foreach($medias as $k => $media) {
if(!Auth::check()) { if($k + 1 > config('pixelfed.max_album_length')) {
return response()->json([]); continue;
} }
$pid = Auth::user()->profile->id; $m = Media::findOrFail($media['id']);
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { if($m->profile_id !== $profile->id || $m->status_id) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); abort(403, 'Invalid media id');
return $following->push($pid)->toArray(); }
}); $m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null;
$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : []; $m->license = $media['license'];
} else { $m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null;
if(Auth::check()) { $m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
$pid = Auth::user()->profile->id; if($cw == true || $profile->cw == true) {
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $m->is_nsfw = $cw;
$following = Follower::whereProfileId($pid)->pluck('following_id'); $status->is_nsfw = $cw;
return $following->push($pid)->toArray(); }
}); $m->save();
$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; $attachments[] = $m;
} else { array_push($mimes, $m->mime);
$visibility = ['public', 'unlisted']; }
}
}
$dir = $min_id ? '>' : '<'; $mediaType = StatusController::mimeTypeCheck($mimes);
$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()); if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
$res = $this->fractal->createData($resource)->toArray(); abort(400, __('exception.compose.invalid.album'));
}
return response()->json($res); if($place && is_array($place)) {
} $status->place_id = $place['id'];
}
public function remoteProfile(Request $request, $id) if($request->filled('comments_disabled')) {
{ $status->comments_disabled = (bool) $request->input('comments_disabled');
return redirect('/i/web/profile/' . $id); }
}
public function remoteStatus(Request $request, $profileId, $statusId) $status->caption = strip_tags($request->caption);
{ $status->scope = 'draft';
return redirect('/i/web/post/' . $statusId); $status->profile_id = $profile->id;
} $status->save();
public function requestEmailVerification(Request $request) foreach($attachments as $media) {
{ $media->status_id = $status->id;
$pid = $request->user()->profile_id; $media->save();
$exists = Redis::sismember('email:manual', $pid); }
return view('account.email.request_verification', compact('exists'));
}
public function requestEmailVerificationStore(Request $request) $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
{ $cw = $profile->cw == true ? true : $cw;
$pid = $request->user()->profile_id; $status->is_nsfw = $cw;
Redis::sadd('email:manual', $pid); $status->visibility = $visibility;
return redirect('/i/verify-email')->with(['status' => 'Successfully sent manual verification request!']); $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('_api:statuses:recent_9:'.$profile->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'));
}
} }

View file

@ -3,67 +3,68 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\LikePipeline\UnlikePipeline;
use App\Like; use App\Like;
use App\Status; use App\Status;
use App\User; use App\User;
use Auth; use Auth;
use Cache; use Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\StatusService;
class LikeController extends Controller class LikeController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'item' => 'required|integer|min:1', 'item' => 'required|integer|min:1',
]); ]);
$user = Auth::user(); $user = Auth::user();
$profile = $user->profile; $profile = $user->profile;
$status = Status::findOrFail($request->input('item')); $status = Status::findOrFail($request->input('item'));
if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) { $count = $status->likes()->count();
$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
UnlikePipeline::dispatch($like);
} else {
$count = $status->likes_count > 4 ? $status->likes_count : $status->likes()->count();
$like = Like::firstOrCreate([
'profile_id' => $user->profile_id,
'status_id' => $status->id
]);
if($like->wasRecentlyCreated == true) {
$count++;
$status->likes_count = $count;
$like->status_profile_id = $status->profile_id;
$like->is_comment = in_array($status->type, [
'photo',
'photo:album',
'video',
'video:album',
'photo:video:album'
]) == false;
$like->save();
$status->save();
LikePipeline::dispatch($like);
}
}
Cache::forget('status:'.$status->id.':likedby:userid:'.$user->id); if ($status->likes()->whereProfileId($profile->id)->count() !== 0) {
StatusService::refresh($status->id); $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
$like->forceDelete();
$count--;
$status->likes_count = $count;
$status->save();
} else {
$like = Like::firstOrCreate([
'profile_id' => $user->profile_id,
'status_id' => $status->id
]);
if($like->wasRecentlyCreated == true) {
$count++;
$status->likes_count = $count;
$like->status_profile_id = $status->profile_id;
$like->is_comment = in_array($status->type, [
'photo',
'photo:album',
'video',
'video:album',
'photo:video:album'
]) == false;
$like->save();
$status->save();
LikePipeline::dispatch($like);
}
}
if ($request->ajax()) { Cache::forget('status:'.$status->id.':likedby:userid:'.$user->id);
$response = ['code' => 200, 'msg' => 'Like saved', 'count' => 0];
} else {
$response = redirect($status->url());
}
return $response; if ($request->ajax()) {
} $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
} else {
$response = redirect($status->url());
}
return $response;
}
} }

View file

@ -22,6 +22,39 @@ class MediaController extends Controller
public function composeUpdate(Request $request, $id) public function composeUpdate(Request $request, $id)
{ {
abort(400, 'Endpoint deprecated'); $this->validate($request, [
'file' => function() {
return [
'required',
'mimes:' . config('pixelfed.media_types'),
'max:' . config('pixelfed.max_photo_size'),
];
},
]);
$user = Auth::user();
$photo = $request->file('file');
$media = Media::whereUserId($user->id)
->whereProfileId($user->profile_id)
->whereNull('status_id')
->findOrFail($id);
$media->version = 2;
$media->save();
$fragments = explode('/', $media->media_path);
$name = last($fragments);
array_pop($fragments);
$dir = implode('/', $fragments);
$path = $photo->storeAs($dir, $name);
$res = [];
$res['url'] = URL::temporarySignedRoute(
'temp-media', now()->addHours(1), ['profileId' => $media->profile_id, 'mediaId' => $media->id, 'timestamp' => time()]
);
ImageOptimize::dispatch($media);
return $res;
} }
} }

View file

@ -20,7 +20,44 @@ class MediaTagController extends Controller
public function usernameLookup(Request $request) public function usernameLookup(Request $request)
{ {
abort(404); abort_if(!$request->user(), 403);
$this->validate($request, [
'q' => 'required|string|min:1|max:50'
]);
$q = $request->input('q');
if(Str::of($q)->startsWith('@')) {
if(strlen($q) < 3) {
return [];
}
$q = mb_substr($q, 1);
}
$blocked = UserFilter::whereFilterableType('App\Profile')
->whereFilterType('block')
->whereFilterableId($request->user()->profile_id)
->pluck('user_id');
$blocked->push($request->user()->profile_id);
$results = Profile::select('id','domain','username')
->whereNotIn('id', $blocked)
->whereNull('domain')
->where('username','like','%'.$q.'%')
->limit(15)
->get()
->map(function($r) {
return [
'id' => (string) $r->id,
'name' => $r->username,
'privacy' => true,
'avatar' => $r->avatarUrl()
];
});
return $results;
} }
public function untagProfile(Request $request) public function untagProfile(Request $request)

View file

@ -1,71 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Status;
use App\Models\Poll;
use App\Models\PollVote;
use App\Services\PollService;
use App\Services\FollowerService;
class PollController extends Controller
{
public function getPoll(Request $request, $id)
{
abort_if(!config_cache('instance.polls.enabled'), 404);
$poll = Poll::findOrFail($id);
$status = Status::findOrFail($poll->status_id);
if($status->scope != 'public') {
abort_if(!$request->user(), 403);
if($request->user()->profile_id != $status->profile_id) {
abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404);
}
}
$pid = $request->user() ? $request->user()->profile_id : false;
$poll = PollService::getById($id, $pid);
return $poll;
}
public function vote(Request $request, $id)
{
abort_if(!config_cache('instance.polls.enabled'), 404);
abort_unless($request->user(), 403);
$this->validate($request, [
'choices' => 'required|array'
]);
$pid = $request->user()->profile_id;
$poll_id = $id;
$choices = $request->input('choices');
// todo: implement multiple choice
$choice = $choices[0];
$poll = Poll::findOrFail($poll_id);
abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.');
abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.');
$vote = new PollVote;
$vote->status_id = $poll->status_id;
$vote->profile_id = $pid;
$vote->poll_id = $poll->id;
$vote->choice = $choice;
$vote->save();
$poll->votes_count = $poll->votes_count + 1;
$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) {
return $choice == $key ? $tally + 1 : $tally;
})->toArray();
$poll->save();
PollService::del($poll->status_id);
$res = PollService::get($poll->status_id, $pid);
return $res;
}
}

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Auth; use Auth;
use Cache; use Cache;
use DB;
use View; use View;
use App\Follower; use App\Follower;
use App\FollowRequest; use App\FollowRequest;
@ -14,9 +13,6 @@ use App\Story;
use App\User; use App\User;
use App\UserFilter; use App\UserFilter;
use League\Fractal; use League\Fractal;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\StatusService;
use App\Util\Lexer\Nickname; use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger; use App\Util\Webfinger\Webfinger;
use App\Transformer\ActivityPub\ProfileOutbox; use App\Transformer\ActivityPub\ProfileOutbox;
@ -24,247 +20,229 @@ use App\Transformer\ActivityPub\ProfileTransformer;
class ProfileController extends Controller class ProfileController extends Controller
{ {
public function show(Request $request, $username) public function show(Request $request, $username)
{ {
$user = Profile::whereNull('domain') $user = Profile::whereNull('domain')
->whereNull('status') ->whereNull('status')
->whereUsername($username) ->whereUsername($username)
->firstOrFail(); ->firstOrFail();
if($request->wantsJson() && config_cache('federation.activitypub.enabled')) { if($request->wantsJson() && config('federation.activitypub.enabled')) {
return $this->showActivityPub($request, $user); return $this->showActivityPub($request, $user);
} }
return $this->buildProfile($request, $user); return $this->buildProfile($request, $user);
} }
protected function buildProfile(Request $request, $user) protected function buildProfile(Request $request, $user)
{ {
$username = $user->username; $username = $user->username;
$loggedIn = Auth::check(); $loggedIn = Auth::check();
$isPrivate = false; $isPrivate = false;
$isBlocked = false; $isBlocked = false;
if(!$loggedIn) { if(!$loggedIn) {
$key = 'profile:settings:' . $user->id; $key = 'profile:settings:' . $user->id;
$ttl = now()->addHours(6); $ttl = now()->addHours(6);
$settings = Cache::remember($key, $ttl, function() use($user) { $settings = Cache::remember($key, $ttl, function() use($user) {
return $user->user->settings; return $user->user->settings;
}); });
if ($user->is_private == true) { if ($user->is_private == true) {
$profile = null; abort(404);
return view('profile.private', compact('user')); }
}
$owner = false; $owner = false;
$is_following = false; $is_following = false;
$profile = $user; $is_admin = $user->user->is_admin;
$settings = [ $profile = $user;
'crawlable' => $settings->crawlable, $settings = [
'following' => [ 'crawlable' => $settings->crawlable,
'count' => $settings->show_profile_following_count, 'following' => [
'list' => $settings->show_profile_following 'count' => $settings->show_profile_following_count,
], 'list' => $settings->show_profile_following
'followers' => [ ],
'count' => $settings->show_profile_follower_count, 'followers' => [
'list' => $settings->show_profile_followers 'count' => $settings->show_profile_follower_count,
] 'list' => $settings->show_profile_followers
]; ]
$ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show'; ];
$ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show';
return view($ui, compact('profile', 'settings')); return view($ui, compact('profile', 'settings'));
} else { } else {
$key = 'profile:settings:' . $user->id; $key = 'profile:settings:' . $user->id;
$ttl = now()->addHours(6); $ttl = now()->addHours(6);
$settings = Cache::remember($key, $ttl, function() use($user) { $settings = Cache::remember($key, $ttl, function() use($user) {
return $user->user->settings; return $user->user->settings;
}); });
if ($user->is_private == true) { if ($user->is_private == true) {
$isPrivate = $this->privateProfileCheck($user, $loggedIn); $isPrivate = $this->privateProfileCheck($user, $loggedIn);
} }
$isBlocked = $this->blockedProfileCheck($user); $isBlocked = $this->blockedProfileCheck($user);
$owner = $loggedIn && Auth::id() === $user->user_id; $owner = $loggedIn && Auth::id() === $user->user_id;
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false; $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
if ($isPrivate == true || $isBlocked == true) { if ($isPrivate == true || $isBlocked == true) {
$requested = Auth::check() ? FollowRequest::whereFollowerId(Auth::user()->profile_id) $requested = Auth::check() ? FollowRequest::whereFollowerId(Auth::user()->profile_id)
->whereFollowingId($user->id) ->whereFollowingId($user->id)
->exists() : false; ->exists() : false;
return view('profile.private', compact('user', 'is_following', 'requested')); return view('profile.private', compact('user', 'is_following', 'requested'));
} }
$is_admin = is_null($user->domain) ? $user->user->is_admin : false; $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
$profile = $user; $profile = $user;
$settings = [ $settings = [
'crawlable' => $settings->crawlable, 'crawlable' => $settings->crawlable,
'following' => [ 'following' => [
'count' => $settings->show_profile_following_count, 'count' => $settings->show_profile_following_count,
'list' => $settings->show_profile_following 'list' => $settings->show_profile_following
], ],
'followers' => [ 'followers' => [
'count' => $settings->show_profile_follower_count, 'count' => $settings->show_profile_follower_count,
'list' => $settings->show_profile_followers 'list' => $settings->show_profile_followers
] ]
]; ];
$ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show'; $ui = $request->has('ui') && $request->input('ui') == 'memory' ? 'profile.memory' : 'profile.show';
return view($ui, compact('profile', 'settings')); return view($ui, compact('profile', 'settings'));
} }
} }
public function permalinkRedirect(Request $request, $username) public function permalinkRedirect(Request $request, $username)
{ {
$user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail(); $user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
if ($request->wantsJson() && config_cache('federation.activitypub.enabled')) { if ($request->wantsJson() && config('federation.activitypub.enabled')) {
return $this->showActivityPub($request, $user); return $this->showActivityPub($request, $user);
} }
return redirect($user->url()); return redirect($user->url());
} }
protected function privateProfileCheck(Profile $profile, $loggedIn) protected function privateProfileCheck(Profile $profile, $loggedIn)
{ {
if (!Auth::check()) { if (!Auth::check()) {
return true; return true;
} }
$user = Auth::user()->profile; $user = Auth::user()->profile;
if($user->id == $profile->id || !$profile->is_private) { if($user->id == $profile->id || !$profile->is_private) {
return false; return false;
} }
$follows = Follower::whereProfileId($user->id)->whereFollowingId($profile->id)->exists(); $follows = Follower::whereProfileId($user->id)->whereFollowingId($profile->id)->exists();
if ($follows == false) { if ($follows == false) {
return true; return true;
} }
return false; return false;
} }
public static function accountCheck(Profile $profile) public static function accountCheck(Profile $profile)
{ {
switch ($profile->status) { switch ($profile->status) {
case 'disabled': case 'disabled':
case 'suspended': case 'suspended':
case 'delete': case 'delete':
return view('profile.disabled'); return view('profile.disabled');
break; break;
default: default:
break; break;
} }
return abort(404); return abort(404);
} }
protected function blockedProfileCheck(Profile $profile) protected function blockedProfileCheck(Profile $profile)
{ {
$pid = Auth::user()->profile->id; $pid = Auth::user()->profile->id;
$blocks = UserFilter::whereUserId($profile->id) $blocks = UserFilter::whereUserId($profile->id)
->whereFilterType('block') ->whereFilterType('block')
->whereFilterableType('App\Profile') ->whereFilterableType('App\Profile')
->pluck('filterable_id') ->pluck('filterable_id')
->toArray(); ->toArray();
if (in_array($pid, $blocks)) { if (in_array($pid, $blocks)) {
return true; return true;
} }
return false; return false;
} }
public function showActivityPub(Request $request, $user) public function showActivityPub(Request $request, $user)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config('federation.activitypub.enabled'), 404);
abort_if($user->domain, 404); abort_if($user->domain, 404);
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($user, new ProfileTransformer); $resource = new Fractal\Resource\Item($user, new ProfileTransformer);
$res = $fractal->createData($resource)->toArray(); $res = $fractal->createData($resource)->toArray();
return response(json_encode($res['data']))->header('Content-Type', 'application/activity+json'); return response(json_encode($res['data']))->header('Content-Type', 'application/activity+json');
} }
public function showAtomFeed(Request $request, $user) public function showAtomFeed(Request $request, $user)
{ {
abort_if(!config('federation.atom.enabled'), 404); abort_if(!config('federation.atom.enabled'), 404);
$pid = AccountService::usernameToId($user); $profile = $user = Profile::whereNull('status')->whereNull('domain')->whereUsername($user)->whereIsPrivate(false)->firstOrFail();
if($profile->status != null) {
return $this->accountCheck($profile);
}
if($profile->is_private || Auth::check()) {
$blocked = $this->blockedProfileCheck($profile);
$check = $this->privateProfileCheck($profile, null);
if($check || $blocked) {
return redirect($profile->url());
}
}
$items = $profile->statuses()->whereHas('media')->whereIn('visibility',['public', 'unlisted'])->orderBy('created_at', 'desc')->take(10)->get();
return response()->view('atom.user', compact('profile', 'items'))
->header('Content-Type', 'application/atom+xml');
}
abort_if(!$pid, 404); public function meRedirect()
{
abort_if(!Auth::check(), 404);
return redirect(Auth::user()->url());
}
$profile = AccountService::get($pid); public function embed(Request $request, $username)
{
$res = view('profile.embed-removed');
abort_if(!$profile || $profile['locked'] || !$profile['local'], 404); if(strlen($username) > 15 || strlen($username) < 2) {
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
$items = DB::table('statuses') $profile = Profile::whereUsername($username)
->whereProfileId($pid) ->whereIsPrivate(false)
->whereVisibility('public') ->whereNull('status')
->whereType('photo') ->whereNull('domain')
->latest() ->first();
->take(10)
->get()
->map(function($status) {
return StatusService::get($status->id);
})
->filter(function($status) {
return $status &&
isset($status['account']) &&
isset($status['media_attachments']) &&
count($status['media_attachments']);
})
->values();
$permalink = config('app.url') . "/users/{$profile['username']}.atom";
return response() if(!$profile) {
->view('atom.user', compact('profile', 'items', 'permalink')) return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
->header('Content-Type', 'application/atom+xml'); }
}
public function meRedirect() $content = Cache::remember('profile:embed:'.$profile->id, now()->addHours(12), function() use($profile) {
{ return View::make('profile.embed')->with(compact('profile'))->render();
abort_if(!Auth::check(), 404); });
return redirect(Auth::user()->url());
}
public function embed(Request $request, $username) return response($content)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
{ }
$res = view('profile.embed-removed');
if(strlen($username) > 15 || strlen($username) < 2) { public function stories(Request $request, $username)
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']); {
} abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
$profile = Profile::whereUsername($username) $pid = $profile->id;
->whereIsPrivate(false) $authed = Auth::user()->profile;
->whereNull('status') abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404);
->whereNull('domain') $exists = Story::whereProfileId($pid)
->first(); ->where('expires_at', '>', now())
->count();
if(!$profile) { abort_unless($exists > 0, 404);
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']); return view('profile.story', compact('pid', 'profile'));
} }
if(AccountService::canEmbed($profile->user_id) == false) {
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
$profile = AccountService::get($profile->id);
$res = view('profile.embed', compact('profile'));
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
public function stories(Request $request, $username)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
$pid = $profile->id;
$authed = Auth::user()->profile_id;
abort_if($pid != $authed && !FollowerService::follows($authed, $pid), 404);
$exists = Story::whereProfileId($pid)
->whereActive(true)
->exists();
abort_unless($exists, 404);
return view('profile.story', compact('pid', 'profile'));
}
} }

View file

@ -12,11 +12,9 @@ use App\{
Profile, Profile,
StatusHashtag, StatusHashtag,
Status, Status,
StatusView,
UserFilter UserFilter
}; };
use Auth, Cache, DB; use Auth,Cache;
use Illuminate\Support\Facades\Redis;
use Carbon\Carbon; use Carbon\Carbon;
use League\Fractal; use League\Fractal;
use App\Transformer\Api\{ use App\Transformer\Api\{
@ -27,21 +25,14 @@ use App\Transformer\Api\{
}; };
use App\Services\{ use App\Services\{
AccountService, AccountService,
BookmarkService,
FollowerService,
LikeService,
PublicTimelineService, PublicTimelineService,
ProfileService,
ReblogService,
RelationshipService,
StatusService,
SnowflakeService,
UserFilterService UserFilterService
}; };
use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\NewStatusPipeline;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class PublicApiController extends Controller class PublicApiController extends Controller
{ {
protected $fractal; protected $fractal;
@ -54,11 +45,11 @@ class PublicApiController extends Controller
protected function getUserData($user) protected function getUserData($user)
{ {
if(!$user) { if(!$user) {
return []; return [];
} else { } else {
return AccountService::get($user->profile_id); return AccountService::get($user->profile_id);
} }
} }
protected function getLikes($status) protected function getLikes($status)
@ -91,44 +82,29 @@ class PublicApiController extends Controller
} }
} }
public function getStatus(Request $request, $id)
{
abort_if(!$request->user(), 403);
$status = StatusService::get($id, false);
abort_if(!$status, 404);
if(in_array($status['visibility'], ['public', 'unlisted'])) {
return $status;
}
$pid = $request->user()->profile_id;
if($status['account']['id'] == $pid) {
return $status;
}
if($status['visibility'] == 'private') {
if(FollowerService::follows($pid, $status['account']['id'])) {
return $status;
}
}
abort(404);
}
public function status(Request $request, $username, int $postid) public function status(Request $request, $username, int $postid)
{ {
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($postid); $status = Status::whereProfileId($profile->id)->findOrFail($postid);
$this->scopeCheck($profile, $status); $this->scopeCheck($profile, $status);
if(!$request->user()) { if(!Auth::check()) {
$res = ['status' => StatusService::get($status->id)]; $res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) {
} else { $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); $res = [
$res = [ 'status' => $this->fractal->createData($item)->toArray(),
'status' => $this->fractal->createData($item)->toArray(), ];
]; return $res;
});
return response()->json($res);
} }
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
$res = [
'status' => $this->fractal->createData($item)->toArray(),
];
return response()->json($res); return response()->json($res);
} }
public function statusState(Request $request, $username, $postid) public function statusState(Request $request, $username, int $postid)
{ {
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($postid); $status = Status::whereProfileId($profile->id)->findOrFail($postid);
@ -174,9 +150,14 @@ class PublicApiController extends Controller
if(Auth::check()) { if(Auth::check()) {
$p = Auth::user()->profile; $p = Auth::user()->profile;
$scope = $p->id == $status->profile_id || FollowerService::follows($p->id, $profile->id) ? ['public', 'private', 'unlisted'] : ['public','unlisted']; $filtered = UserFilter::whereUserId($p->id)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
$scope = $p->id == $status->profile_id ? ['public', 'private'] : ['public'];
} else { } else {
$scope = ['public', 'unlisted']; $filtered = [];
$scope = ['public'];
} }
if($request->filled('min_id') || $request->filled('max_id')) { if($request->filled('min_id') || $request->filled('max_id')) {
@ -184,7 +165,8 @@ class PublicApiController extends Controller
$replies = $status->comments() $replies = $status->comments()
->whereNull('reblog_of_id') ->whereNull('reblog_of_id')
->whereIn('scope', $scope) ->whereIn('scope', $scope)
->select('id', 'caption', 'local', 'visibility', 'scope', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at') ->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->where('id', '>=', $request->min_id) ->where('id', '>=', $request->min_id)
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($limit); ->paginate($limit);
@ -193,16 +175,18 @@ class PublicApiController extends Controller
$replies = $status->comments() $replies = $status->comments()
->whereNull('reblog_of_id') ->whereNull('reblog_of_id')
->whereIn('scope', $scope) ->whereIn('scope', $scope)
->select('id', 'caption', 'local', 'visibility', 'scope', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at') ->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->where('id', '<=', $request->max_id) ->where('id', '<=', $request->max_id)
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($limit); ->paginate($limit);
} }
} else { } else {
$replies = Status::whereInReplyToId($status->id) $replies = $status->comments()
->whereNull('reblog_of_id') ->whereNull('reblog_of_id')
->whereIn('scope', $scope) ->whereIn('scope', $scope)
->select('id', 'caption', 'local', 'visibility', 'scope', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at') ->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($limit); ->paginate($limit);
} }
@ -215,15 +199,9 @@ class PublicApiController extends Controller
public function statusLikes(Request $request, $username, $id) public function statusLikes(Request $request, $username, $id)
{ {
abort_if(!$request->user(), 404); $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::findOrFail($id); $status = Status::whereProfileId($profile->id)->findOrFail($id);
$this->scopeCheck($status->profile, $status); $this->scopeCheck($profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$likes = $this->getLikes($status); $likes = $this->getLikes($status);
return response()->json([ return response()->json([
'data' => $likes 'data' => $likes
@ -232,16 +210,9 @@ class PublicApiController extends Controller
public function statusShares(Request $request, $username, $id) public function statusShares(Request $request, $username, $id)
{ {
abort_if(!$request->user(), 404);
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($id); $status = Status::whereProfileId($profile->id)->findOrFail($id);
$this->scopeCheck($profile, $status); $this->scopeCheck($profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$shares = $this->getShares($status); $shares = $this->getShares($status);
return response()->json([ return response()->json([
'data' => $shares 'data' => $shares
@ -302,122 +273,85 @@ class PublicApiController extends Controller
$max = $request->input('max_id'); $max = $request->input('max_id');
$limit = $request->input('limit') ?? 3; $limit = $request->input('limit') ?? 3;
$user = $request->user(); $user = $request->user();
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
if(config('exp.cached_public_timeline') == false) { $key = 'user:last_active_at:id:'.$user->id;
if($min || $max) { $ttl = now()->addMinutes(5);
$dir = $min ? '>' : '<'; Cache::remember($key, $ttl, function() use($user) {
$id = $min ?? $max; $user->last_active_at = now();
$timeline = Status::select( $user->save();
'id', return;
'profile_id', });
'type',
'scope',
'local'
)
->where('id', $dir, $id)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereLocal(true)
->whereScope('public')
->orderBy('id', 'desc')
->limit($limit)
->get()
->map(function($s) use ($user) {
$status = StatusService::getFull($s->id, $user->profile_id);
if(!$status) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
return $status;
})
->filter(function($s) use($filtered) {
return $s && isset($s['account']) && in_array($s['account']['id'], $filtered) == false;
})
->values();
$res = $timeline->toArray();
} else {
$timeline = Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'local',
'reply_count',
'comments_disabled',
'created_at',
'place_id',
'likes_count',
'reblogs_count',
'updated_at'
)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->with('profile', 'hashtags', 'mentions')
->whereLocal(true)
->whereScope('public')
->orderBy('id', 'desc')
->limit($limit)
->get()
->map(function($s) use ($user) {
$status = StatusService::getFull($s->id, $user->profile_id);
if(!$status) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
return $status;
})
->filter(function($s) use($filtered) {
return $s && in_array($s['account']['id'], $filtered) == false;
})
->values();
$res = $timeline->toArray(); $filtered = UserFilter::whereUserId($user->profile_id)
} ->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
if($min || $max) {
$dir = $min ? '>' : '<';
$id = $min ?? $max;
$timeline = Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'local',
'reply_count',
'comments_disabled',
'place_id',
'likes_count',
'reblogs_count',
'created_at',
'updated_at'
)->where('id', $dir, $id)
->whereIn('type', ['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereNotIn('profile_id', $filtered)
->whereLocal(true)
->whereScope('public')
->where('created_at', '>', now()->subMonths(3))
->orderBy('created_at', 'desc')
->limit($limit)
->get();
} else { } else {
Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() { $timeline = Status::select(
if(PublicTimelineService::count() == 0) { 'id',
PublicTimelineService::warmCache(true, 400); 'uri',
} 'caption',
}); 'rendered',
'profile_id',
if ($max) { 'type',
$feed = PublicTimelineService::getRankedMaxId($max, $limit); 'in_reply_to_id',
} else if ($min) { 'reblog_of_id',
$feed = PublicTimelineService::getRankedMinId($min, $limit); 'is_nsfw',
} else { 'scope',
$feed = PublicTimelineService::get(0, $limit); 'local',
} 'reply_count',
'comments_disabled',
$res = collect($feed) 'created_at',
->map(function($k) use($user) { 'place_id',
$status = StatusService::get($k); 'likes_count',
if($user) { 'reblogs_count',
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $k); 'updated_at'
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $k); )->whereIn('type', ['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $k); ->whereNotIn('profile_id', $filtered)
$status['relationship'] = RelationshipService::get($user->profile_id, $status['account']['id']); ->with('profile', 'hashtags', 'mentions')
} ->whereLocal(true)
return $status; ->whereScope('public')
}) ->where('created_at', '>', now()->subMonths(3))
->filter(function($s) use($filtered) { ->orderBy('created_at', 'desc')
return isset($s['account']) && in_array($s['account']['id'], $filtered) == false; ->simplePaginate($limit);
})
->values()
->toArray();
} }
$fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer());
$res = $this->fractal->createData($fractal)->toArray();
return response()->json($res); return response()->json($res);
} }
public function homeTimelineApi(Request $request) public function homeTimelineApi(Request $request)
@ -430,13 +364,9 @@ class PublicApiController extends Controller
'page' => 'nullable|integer|max:40', 'page' => 'nullable|integer|max:40',
'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'limit' => 'nullable|integer|max:40', 'limit' => 'nullable|integer|max:40'
'recent_feed' => 'nullable',
'recent_min' => 'nullable|integer'
]); ]);
$recentFeed = $request->input('recent_feed') == 'true';
$recentFeedMin = $request->input('recent_min');
$page = $request->input('page'); $page = $request->input('page');
$min = $request->input('min_id'); $min = $request->input('min_id');
$max = $request->input('max_id'); $max = $request->input('max_id');
@ -444,166 +374,38 @@ class PublicApiController extends Controller
$user = $request->user(); $user = $request->user();
$key = 'user:last_active_at:id:'.$user->id; $key = 'user:last_active_at:id:'.$user->id;
$ttl = now()->addMinutes(20); $ttl = now()->addMinutes(5);
Cache::remember($key, $ttl, function() use($user) { Cache::remember($key, $ttl, function() use($user) {
$user->last_active_at = now(); $user->last_active_at = now();
$user->save(); $user->save();
return; return;
}); });
$pid = $user->profile_id; // TODO: Use redis for timelines
// $timeline = Timeline::build()->local();
$pid = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); $following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray(); return $following->push($pid)->toArray();
}); });
if($recentFeed == true) { // $private = Cache::remember('profiles:private', now()->addMinutes(1440), function() {
$key = 'profile:home-timeline-cursor:'.$user->id; // return Profile::whereIsPrivate(true)
$ttl = now()->addMinutes(30); // ->orWhere('unlisted', true)
$min = Cache::remember($key, $ttl, function() use($pid) { // ->orWhere('status', '!=', null)
$res = StatusView::whereProfileId($pid)->orderByDesc('status_id')->first(); // ->pluck('id');
return $res ? $res->status_id : null; // });
});
}
$filtered = $user ? UserFilterService::filters($user->profile_id) : []; // $private = $private->diff($following)->flatten();
$types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
// $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album', 'text'];
$textOnlyReplies = false; // $filters = UserFilter::whereUserId($pid)
// ->whereFilterableType('App\Profile')
// ->whereIn('filter_type', ['mute', 'block'])
// ->pluck('filterable_id')->toArray();
// $filtered = array_merge($private->toArray(), $filters);
if(config('exp.top')) { $filtered = Auth::check() ? UserFilterService::filters(Auth::user()->profile_id) : [];
$textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
$textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
if($textOnlyPosts) {
array_push($types, 'text');
}
}
if(config('exp.polls') == true) {
array_push($types, 'poll');
}
if($min || $max) {
$dir = $min ? '>' : '<';
$id = $min ?? $max;
return Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'local',
'reply_count',
'comments_disabled',
'place_id',
'likes_count',
'reblogs_count',
'created_at',
'updated_at'
)
->whereIn('type', $types)
->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
return $q->whereNull('in_reply_to_id');
})
->with('profile', 'hashtags', 'mentions')
->where('id', $dir, $id)
->whereIn('profile_id', $following)
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->map(function($s) use ($user) {
$status = StatusService::get($s->id);
if(!$status) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
return $status;
})
->filter(function($s) use($filtered) {
return $s && in_array($s['account']['id'], $filtered) == false;
})
->values()
->toArray();
} else {
return Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'local',
'reply_count',
'comments_disabled',
'place_id',
'likes_count',
'reblogs_count',
'created_at',
'updated_at'
)
->whereIn('type', $types)
->when(!$textOnlyReplies, function($q, $textOnlyReplies) {
return $q->whereNull('in_reply_to_id');
})
->with('profile', 'hashtags', 'mentions')
->whereIn('profile_id', $following)
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->map(function($s) use ($user) {
$status = StatusService::get($s->id);
if(!$status) {
return false;
}
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
return $status;
})
->filter(function($s) use($filtered) {
return $s && in_array($s['account']['id'], $filtered) == false;
})
->values()
->toArray();
}
}
public function networkTimelineApi(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(config('federation.network_timeline') == false, 404);
$this->validate($request,[
'page' => 'nullable|integer|max:40',
'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'limit' => 'nullable|integer|max:30'
]);
$page = $request->input('page');
$min = $request->input('min_id');
$max = $request->input('max_id');
$limit = $request->input('limit') ?? 3;
$user = $request->user();
$amin = SnowflakeService::byDate(now()->subDays(490));
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
if($min || $max) { if($min || $max) {
$dir = $min ? '>' : '<'; $dir = $min ? '>' : '<';
@ -611,56 +413,69 @@ class PublicApiController extends Controller
$timeline = Status::select( $timeline = Status::select(
'id', 'id',
'uri', 'uri',
'caption',
'rendered',
'profile_id',
'type', 'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope', 'scope',
'local',
'reply_count',
'comments_disabled',
'place_id',
'likes_count',
'reblogs_count',
'created_at', 'created_at',
) 'updated_at'
)->whereIn('type', ['text','photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->with('profile', 'hashtags', 'mentions')
->where('id', $dir, $id) ->where('id', $dir, $id)
->whereNull(['in_reply_to_id', 'reblog_of_id']) ->whereIn('profile_id', $following)
->whereNotIn('profile_id', $filtered) ->whereNotIn('profile_id', $filtered)
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) ->whereIn('visibility',['public', 'unlisted', 'private'])
->whereNotNull('uri')
->whereScope('public')
->where('id', '>', $amin)
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->limit($limit) ->limit($limit)
->get() ->get();
->map(function($s) use ($user) {
$status = StatusService::get($s->id);
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id);
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id);
return $status;
});
$res = $timeline->toArray();
} else { } else {
$timeline = Status::select( $timeline = Status::select(
'id', 'id',
'uri', 'uri',
'type', 'caption',
'scope', 'rendered',
'created_at', 'profile_id',
) 'type',
->whereNull(['in_reply_to_id', 'reblog_of_id']) 'in_reply_to_id',
->whereNotIn('profile_id', $filtered) 'reblog_of_id',
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) 'is_nsfw',
->whereNotNull('uri') 'scope',
->whereScope('public') 'local',
->where('id', '>', $amin) 'reply_count',
->orderBy('created_at', 'desc') 'comments_disabled',
->limit($limit) 'place_id',
->get() 'likes_count',
->map(function($s) use ($user) { 'reblogs_count',
$status = StatusService::get($s->id); 'created_at',
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); 'updated_at'
$status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id); )->whereIn('type', ['text','photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
$status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id); ->with('profile', 'hashtags', 'mentions')
return $status; ->whereIn('profile_id', $following)
}); ->whereNotIn('profile_id', $filtered)
$res = $timeline->toArray(); ->whereIn('visibility',['public', 'unlisted', 'private'])
->orderBy('created_at', 'desc')
->simplePaginate($limit);
} }
$fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer());
$res = $this->fractal->createData($fractal)->toArray();
return response()->json($res); return response()->json($res);
}
public function networkTimelineApi(Request $request)
{
return response()->json([]);
} }
public function relationships(Request $request) public function relationships(Request $request)
@ -669,20 +484,17 @@ class PublicApiController extends Controller
return response()->json([]); return response()->json([]);
} }
$pid = $request->user()->profile_id;
$this->validate($request, [ $this->validate($request, [
'id' => 'required|array|min:1|max:20', 'id' => 'required|array|min:1|max:20',
'id.*' => 'required|integer' 'id.*' => 'required|integer'
]); ]);
$ids = collect($request->input('id')); $ids = collect($request->input('id'));
$res = $ids->filter(function($v) use($pid) { $filtered = $ids->filter(function($v) {
return $v != $pid; return $v != Auth::user()->profile->id;
})
->map(function($id) use($pid) {
return RelationshipService::get($pid, $id);
}); });
$relations = Profile::whereNull('status')->findOrFail($filtered->all());
$fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer());
$res = $this->fractal->createData($fractal)->toArray();
return response()->json($res); return response()->json($res);
} }
@ -694,82 +506,50 @@ class PublicApiController extends Controller
public function accountFollowers(Request $request, $id) public function accountFollowers(Request $request, $id)
{ {
abort_if(!$request->user(), 403); abort_unless(Auth::check(), 403);
$account = AccountService::get($id); $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id);
abort_if(!$account, 404); if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) {
$pid = $request->user()->profile_id; return response()->json([]);
}
$followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10);
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
if($pid != $account['id']) { return response()->json($res);
if($account['locked']) {
if(FollowerService::follows($pid, $account['id'])) {
return [];
}
}
if(AccountService::hiddenFollowers($id)) {
return [];
}
if($request->has('page') && $request->page >= 5) {
return [];
}
}
$res = DB::table('followers')
->select('id', 'profile_id', 'following_id')
->whereFollowingId($account['id'])
->orderByDesc('id')
->simplePaginate(10)
->map(function($follower) {
return AccountService::get($follower->profile_id);
})
->filter(function($account) {
return $account && isset($account['id']);
})
->values()
->toArray();
return response()->json($res);
} }
public function accountFollowing(Request $request, $id) public function accountFollowing(Request $request, $id)
{ {
abort_if(!$request->user(), 403); abort_unless(Auth::check(), 403);
$account = AccountService::get($id);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
if($pid != $account['id']) { $profile = Profile::with('user')
if($account['locked']) { ->whereNull('status')
if(FollowerService::follows($pid, $account['id'])) { ->whereNull('domain')
return []; ->findOrFail($id);
}
}
if(AccountService::hiddenFollowing($id)) { // filter by username
return []; $search = $request->input('fbu');
} $owner = Auth::id() == $profile->user_id;
$filter = ($owner == true) && ($search != null);
if($request->has('page') && $request->page >= 5) { abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404);
return []; abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
}
}
$res = DB::table('followers') if($search) {
->select('id', 'profile_id', 'following_id') abort_if(!$owner, 404);
->whereProfileId($account['id']) $following = $profile->following()
->orderByDesc('id') ->where('profiles.username', 'like', '%'.$search.'%')
->simplePaginate(10) ->orderByDesc('followers.created_at')
->map(function($follower) { ->paginate(10);
return AccountService::get($follower->following_id); } else {
}) $following = $profile->following()
->filter(function($account) { ->orderByDesc('followers.created_at')
return $account && isset($account['id']); ->paginate(10);
}) }
->values() $resource = new Fractal\Resource\Collection($following, new AccountTransformer());
->toArray(); $res = $this->fractal->createData($resource)->toArray();
return response()->json($res); return response()->json($res);
} }
public function accountStatuses(Request $request, $id) public function accountStatuses(Request $request, $id)
@ -784,78 +564,116 @@ class PublicApiController extends Controller
'limit' => 'nullable|integer|min:1|max:24' 'limit' => 'nullable|integer|min:1|max:24'
]); ]);
$user = $request->user(); $profile = Profile::whereNull('status')->findOrFail($id);
$profile = AccountService::get($id);
abort_if(!$profile, 404);
$limit = $request->limit ?? 9; $limit = $request->limit ?? 9;
$max_id = $request->max_id; $max_id = $request->max_id;
$min_id = $request->min_id; $min_id = $request->min_id;
$scope = ['photo', 'photo:album', 'video', 'video:album']; $scope = $request->only_media == true ?
$onlyMedia = $request->input('only_media', true); ['photo', 'photo:album', 'video', 'video:album'] :
['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
if(!$min_id && !$max_id) { if($profile->is_private) {
$min_id = 1; if(!Auth::check()) {
}
if($profile['locked']) {
if(!$user) {
return response()->json([]); return response()->json([]);
} }
$pid = $user->profile_id; $pid = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); $following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray(); return $following->push($pid)->toArray();
}); });
$visibility = true == in_array($profile['id'], $following) ? ['public', 'unlisted', 'private'] : []; $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
} else { } else {
if($user) { if(Auth::check()) {
$pid = $user->profile_id; $pid = Auth::user()->profile->id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); $following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray(); return $following->push($pid)->toArray();
}); });
$visibility = true == in_array($profile['id'], $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
} else { } else {
$visibility = ['public', 'unlisted']; $visibility = ['public', 'unlisted'];
} }
} }
$tag = in_array('private', $visibility) ? 'private' : 'public';
if($min_id == 1 && $limit == 9 && $tag == 'public') {
$limit = 9;
$scope = ['photo', 'photo:album', 'video', 'video:album'];
$key = '_api:statuses:recent_9:'.$profile->id;
$res = Cache::remember($key, now()->addHours(24), function() use($profile, $scope, $visibility, $limit) {
$dir = '>';
$id = 1;
$timeline = Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'likes_count',
'reblogs_count',
'scope',
'visibility',
'local',
'place_id',
'comments_disabled',
'cw_summary',
'created_at',
'updated_at'
)->whereProfileId($profile->id)
->whereIn('type', $scope)
->where('id', $dir, $id)
->whereIn('visibility', $visibility)
->limit($limit)
->orderByDesc('id')
->get();
$resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
});
return $res;
}
$dir = $min_id ? '>' : '<'; $dir = $min_id ? '>' : '<';
$id = $min_id ?? $max_id; $id = $min_id ?? $max_id;
$res = Status::whereProfileId($profile['id']) $timeline = Status::select(
->whereNull('in_reply_to_id') 'id',
->whereNull('reblog_of_id') 'uri',
->whereIn('type', $scope) 'caption',
->where('id', $dir, $id) 'rendered',
->whereIn('scope', $visibility) 'profile_id',
->limit($limit) 'type',
->orderByDesc('id') 'in_reply_to_id',
->get() 'reblog_of_id',
->map(function($s) use($user) { 'is_nsfw',
try { 'likes_count',
$status = StatusService::get($s->id, false); 'reblogs_count',
} catch (\Exception $e) { 'scope',
$status = false; 'visibility',
} 'local',
if($user && $status) { 'place_id',
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); 'comments_disabled',
} 'cw_summary',
return $status; 'created_at',
}) 'updated_at'
->filter(function($s) use($onlyMedia) { )->whereProfileId($profile->id)
if($onlyMedia) { ->whereIn('type', $scope)
if( ->where('id', $dir, $id)
!isset($s['media_attachments']) || ->whereIn('visibility', $visibility)
!is_array($s['media_attachments']) || ->limit($limit)
empty($s['media_attachments']) ->orderByDesc('id')
) { ->get();
return false;
}
}
return $s;
})
->values();
return response()->json($res); $resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
} }

View file

@ -23,7 +23,7 @@ class ReportController extends Controller
$this->validate($request, [ $this->validate($request, [
'type' => 'required|alpha_dash', 'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1', 'id' => 'required|integer|min:1',
]); ]);
return view('report.form'); return view('report.form');
} }
@ -86,11 +86,11 @@ class ReportController extends Controller
public function formStore(Request $request) public function formStore(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'report' => 'required|alpha_dash', 'report' => 'required|alpha_dash',
'type' => 'required|alpha_dash', 'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1', 'id' => 'required|integer|min:1',
'msg' => 'nullable|string|max:150', 'msg' => 'nullable|string|max:150',
]); ]);
$profile = Auth::user()->profile; $profile = Auth::user()->profile;
$reportType = $request->input('report'); $reportType = $request->input('report');
@ -98,26 +98,10 @@ class ReportController extends Controller
$object_type = $request->input('type'); $object_type = $request->input('type');
$msg = $request->input('msg'); $msg = $request->input('msg');
$object = null; $object = null;
$types = [ $types = ['spam', 'sensitive', 'abusive'];
// original 3
'spam',
'sensitive',
'abusive',
// new
'underage',
'copyright',
'impersonation',
'scam',
'terrorism'
];
if (!in_array($reportType, $types)) { if (!in_array($reportType, $types)) {
if($request->wantsJson()) { return redirect('/timeline')->with('error', 'Invalid report type');
return abort(400, 'Invalid report type');
} else {
return redirect('/timeline')->with('error', 'Invalid report type');
}
} }
switch ($object_type) { switch ($object_type) {
@ -131,28 +115,16 @@ class ReportController extends Controller
break; break;
default: default:
if($request->wantsJson()) { return redirect('/timeline')->with('error', 'Invalid report type');
return abort(400, 'Invalid report type');
} else {
return redirect('/timeline')->with('error', 'Invalid report type');
}
break; break;
} }
if ($exists !== 0) { if ($exists !== 0) {
if($request->wantsJson()) { return redirect('/timeline')->with('error', 'You have already reported this!');
return response()->json(200);
} else {
return redirect('/timeline')->with('error', 'You have already reported this!');
}
} }
if ($object->profile_id == $profile->id) { if ($object->profile_id == $profile->id) {
if($request->wantsJson()) { return redirect('/timeline')->with('error', 'You cannot report your own content!');
return response()->json(200);
} else {
return redirect('/timeline')->with('error', 'You cannot report your own content!');
}
} }
$report = new Report(); $report = new Report();
@ -162,13 +134,9 @@ class ReportController extends Controller
$report->object_type = $object_type; $report->object_type = $object_type;
$report->reported_profile_id = $object->profile_id; $report->reported_profile_id = $object->profile_id;
$report->type = $request->input('report'); $report->type = $request->input('report');
$report->message = e($request->input('msg')); $report->message = $request->input('msg');
$report->save(); $report->save();
if($request->wantsJson()) { return redirect('/timeline')->with('status', 'Report successfully sent!');
return response()->json(200);
} else {
return redirect('/timeline')->with('status', 'Report successfully sent!');
}
} }
} }

View file

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

View file

@ -4,236 +4,17 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Auth; use Auth;
use App\AccountLog;
use App\Follower;
use App\Like;
use App\Status;
use App\StatusHashtag;
use Illuminate\Support\Facades\Cache;
class SeasonalController extends Controller class SeasonalController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
} }
public function yearInReview() public function yearInReview()
{ {
abort_if(now()->gt('2021-03-01 00:00:00'), 404); $profile = Auth::user()->profile;
abort_if(config('database.default') != 'mysql', 404); return view('account.yir', compact('profile'));
}
$profile = Auth::user()->profile;
return view('account.yir', compact('profile'));
}
public function getData(Request $request)
{
abort_if(now()->gt('2021-03-01 00:00:00'), 404);
abort_if(config('database.default') != 'mysql', 404);
$uid = $request->user()->id;
$pid = $request->user()->profile_id;
$epoch = '2020-01-01 00:00:00';
$epochStart = '2020-01-01 00:00:00';
$epochEnd = '2020-12-31 23:59:59';
$siteKey = 'seasonal:my2020:shared';
$siteTtl = now()->addMonths(3);
$userKey = 'seasonal:my2020:user:' . $uid;
$userTtl = now()->addMonths(3);
$shared = Cache::remember($siteKey, $siteTtl, function() use($epochStart, $epochEnd) {
return [
'average' => [
'posts' => round(Status::selectRaw('*, count(profile_id) as count')
->whereNull('uri')
->whereIn('type', ['photo','photo:album','video','video:album','photo:video:album'])
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('profile_id')
->pluck('count')
->avg()),
'likes' => round(Like::selectRaw('*, count(profile_id) as count')
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('profile_id')
->pluck('count')
->avg()),
],
'popular' => [
'hashtag' => StatusHashtag::selectRaw('*,count(hashtag_id) as count')
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('hashtag_id')
->orderByDesc('count')
->take(1)
->get()
->map(function($sh) {
return [
'name' => $sh->hashtag->name,
'count' => $sh->count
];
})
->first(),
'post' => Status::whereScope('public')
->where('likes_count', '>', 1)
->whereIsNsfw(false)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->orderByDesc('likes_count')
->take(1)
->get()
->map(function($status) {
return [
'id' => (string) $status->id,
'username' => (string) $status->profile->username,
'created_at' => $status->created_at->format('M d, Y'),
'type' => $status->type,
'url' => $status->url(),
'thumb' => $status->thumb(),
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
'reply_count' => $status->reply_count ?? 0,
];
})
->first(),
'places' => Status::selectRaw('*, count(place_id) as count')
->whereNotNull('place_id')
->having('count', '>', 1)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('place_id')
->orderByDesc('count')
->take(1)
->get()
->map(function($sh) {
return [
'name' => $sh->place->getName(),
'url' => $sh->place->url(),
'count' => $sh->count
];
})
->first()
],
];
});
$res = Cache::remember($userKey, $userTtl, function() use($uid, $pid, $epochStart, $epochEnd, $request) {
return [
'account' => [
'user_id' => $request->user()->id,
'created_at' => $request->user()->created_at->format('M d, Y'),
'created_this_year' => $request->user()->created_at->gt('2020-01-01 00:00:00'),
'created_months_ago' => $request->user()->created_at->diffInMonths(now()),
'followers_this_year' => Follower::whereFollowingId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->count(),
'followed_this_year' => Follower::whereProfileId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->count(),
'most_popular' => Status::whereProfileId($pid)
->where('likes_count', '>', 1)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->orderByDesc('likes_count')
->take(1)
->get()
->map(function($status) {
return [
'id' => (string) $status->id,
'username' => (string) $status->profile->username,
'created_at' => $status->created_at->format('M d, Y'),
'type' => $status->type,
'url' => $status->url(),
'thumb' => $status->thumb(),
'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count,
'reply_count' => $status->reply_count ?? 0,
];
})
->first(),
'posts_count' => Status::whereProfileId($pid)
->whereIn('type', ['photo','photo:album','video','video:album','photo:video:album'])
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->count(),
'likes_count' => Like::whereProfileId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->count(),
'hashtag' => StatusHashtag::selectRaw('*, count(hashtag_id) as count')
->whereProfileId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('profile_id')
->orderByDesc('count')
->take(1)
->get()
->map(function($sh) {
return [
'name' => $sh->hashtag->name,
'count' => $sh->count
];
})
->first(),
'places' => Status::selectRaw('*, count(place_id) as count')
->whereNotNull('place_id')
->having('count', '>', 1)
->whereProfileId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->groupBy('place_id')
->orderByDesc('count')
->take(1)
->get()
->map(function($sh) {
return [
'name' => $sh->place->getName(),
'url' => $sh->place->url(),
'count' => $sh->count
];
})
->first(),
'places_total' => Status::whereProfileId($pid)
->where('created_at', '>', $epochStart)
->where('created_at', '<', $epochEnd)
->whereNotNull('place_id')
->count()
]
];
});
return response()->json(array_merge($res, $shared));
}
public function store(Request $request)
{
abort_if(now()->gt('2021-03-01 00:00:00'), 404);
abort_if(config('database.default') != 'mysql', 404);
$user = $request->user();
$log = AccountLog::firstOrCreate([
[
'item_type' => 'App\User',
'item_id' => $user->id,
'user_id' => $user->id,
'action' => 'seasonal.my2020.view'
],
[
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent()
]
]);
return response()->json(200);
}
} }

View file

@ -8,7 +8,6 @@ use App\Media;
use App\Profile; use App\Profile;
use App\User; use App\User;
use App\UserFilter; use App\UserFilter;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\PrettyNumber; use App\Util\Lexer\PrettyNumber;
use Auth; use Auth;
use Cache; use Cache;
@ -17,194 +16,181 @@ use Mail;
use Purify; use Purify;
use App\Mail\PasswordChange; use App\Mail\PasswordChange;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\PronounService;
trait HomeSettings trait HomeSettings
{ {
public function home() public function home()
{ {
$id = Auth::user()->profile->id; $id = Auth::user()->profile->id;
$storage = []; $storage = [];
$used = Media::whereProfileId($id)->sum('size'); $used = Media::whereProfileId($id)->sum('size');
$storage['limit'] = config_cache('pixelfed.max_account_size') * 1024; $storage['limit'] = config('pixelfed.max_account_size') * 1024;
$storage['used'] = $used; $storage['used'] = $used;
$storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100); $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100);
$storage['limitPretty'] = PrettyNumber::size($storage['limit']); $storage['limitPretty'] = PrettyNumber::size($storage['limit']);
$storage['usedPretty'] = PrettyNumber::size($storage['used']); $storage['usedPretty'] = PrettyNumber::size($storage['used']);
$pronouns = PronounService::get($id);
return view('settings.home', compact('storage', 'pronouns')); return view('settings.home', compact('storage'));
} }
public function homeUpdate(Request $request) public function homeUpdate(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:'.config('pixelfed.max_name_length'), 'name' => 'required|string|max:'.config('pixelfed.max_name_length'),
'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'), 'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
'website' => 'nullable|url', 'website' => 'nullable|url',
'language' => 'nullable|string|min:2|max:5', 'language' => 'nullable|string|min:2|max:5'
'pronouns' => 'nullable|array|max:4' ]);
]);
$changes = false; $changes = false;
$name = strip_tags(Purify::clean($request->input('name'))); $name = strip_tags(Purify::clean($request->input('name')));
$bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null;
$website = $request->input('website'); $website = $request->input('website');
$language = $request->input('language'); $language = $request->input('language');
$user = Auth::user(); $user = Auth::user();
$profile = $user->profile; $profile = $user->profile;
$pronouns = $request->input('pronouns'); $layout = $request->input('profile_layout');
$existingPronouns = PronounService::get($profile->id); if($layout) {
$layout = $request->input('profile_layout'); $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout;
if($layout) { }
$layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout;
}
$enforceEmailVerification = config_cache('pixelfed.enforce_email_verification'); $enforceEmailVerification = config('pixelfed.enforce_email_verification');
// Only allow email to be updated if not yet verified // Only allow email to be updated if not yet verified
if (!$enforceEmailVerification || !$changes && $user->email_verified_at) { if (!$enforceEmailVerification || !$changes && $user->email_verified_at) {
if ($profile->name != $name) { if ($profile->name != $name) {
$changes = true; $changes = true;
$user->name = $name; $user->name = $name;
$profile->name = $name; $profile->name = $name;
} }
if ($profile->website != $website) { if ($profile->website != $website) {
$changes = true; $changes = true;
$profile->website = $website; $profile->website = $website;
} }
if (strip_tags($profile->bio) != $bio) { if ($profile->bio != $bio) {
$changes = true; $changes = true;
$profile->bio = Autolink::create()->autolink($bio); $profile->bio = $bio;
} }
if($user->language != $language && if($user->language != $language &&
in_array($language, \App\Util\Localization\Localization::languages()) in_array($language, \App\Util\Localization\Localization::languages())
) { ) {
$changes = true; $changes = true;
$user->language = $language; $user->language = $language;
session()->put('locale', $language); session()->put('locale', $language);
} }
}
if($existingPronouns != $pronouns) { if ($changes === true) {
if($pronouns && in_array('Select Pronoun(s)', $pronouns)) { Cache::forget('user:account:id:'.$user->id);
PronounService::clear($profile->id); $user->save();
} else { $profile->save();
PronounService::put($profile->id, $pronouns);
}
}
}
if ($changes === true) { return redirect('/settings/home')->with('status', 'Profile successfully updated!');
Cache::forget('user:account:id:'.$user->id); }
$user->save();
$profile->save();
return redirect('/settings/home')->with('status', 'Profile successfully updated!'); return redirect('/settings/home');
} }
return redirect('/settings/home'); public function password()
} {
return view('settings.password');
}
public function password() public function passwordUpdate(Request $request)
{ {
return view('settings.password'); $this->validate($request, [
} 'current' => 'required|string',
'password' => 'required|string',
'password_confirmation' => 'required|string',
]);
public function passwordUpdate(Request $request) $current = $request->input('current');
{ $new = $request->input('password');
$this->validate($request, [ $confirm = $request->input('password_confirmation');
'current' => 'required|string',
'password' => 'required|string',
'password_confirmation' => 'required|string',
]);
$current = $request->input('current'); $user = Auth::user();
$new = $request->input('password');
$confirm = $request->input('password_confirmation');
$user = Auth::user(); if (password_verify($current, $user->password) && $new === $confirm) {
$user->password = bcrypt($new);
$user->save();
if (password_verify($current, $user->password) && $new === $confirm) { $log = new AccountLog();
$user->password = bcrypt($new); $log->user_id = $user->id;
$user->save(); $log->item_id = $user->id;
$log->item_type = 'App\User';
$log->action = 'account.edit.password';
$log->message = 'Password changed';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
$log = new AccountLog(); Mail::to($request->user())->send(new PasswordChange($user));
$log->user_id = $user->id; return redirect('/settings/home')->with('status', 'Password successfully updated!');
$log->item_id = $user->id; } else {
$log->item_type = 'App\User'; return redirect()->back()->with('error', 'There was an error with your request! Please try again.');
$log->action = 'account.edit.password'; }
$log->message = 'Password changed';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
Mail::to($request->user())->send(new PasswordChange($user)); }
return redirect('/settings/home')->with('status', 'Password successfully updated!');
} else {
return redirect()->back()->with('error', 'There was an error with your request! Please try again.');
}
} public function email()
{
return view('settings.email');
}
public function email() public function emailUpdate(Request $request)
{ {
return view('settings.email'); $this->validate($request, [
} 'email' => 'required|email',
]);
$changes = false;
$email = $request->input('email');
$user = Auth::user();
$profile = $user->profile;
public function emailUpdate(Request $request) $validate = config('pixelfed.enforce_email_verification');
{
$this->validate($request, [
'email' => 'required|email',
]);
$changes = false;
$email = $request->input('email');
$user = Auth::user();
$profile = $user->profile;
$validate = config_cache('pixelfed.enforce_email_verification'); if ($user->email != $email) {
$changes = true;
$user->email = $email;
if ($user->email != $email) { if ($validate) {
$changes = true; $user->email_verified_at = null;
$user->email = $email; // Prevent old verifications from working
EmailVerification::whereUserId($user->id)->delete();
}
if ($validate) { $log = new AccountLog();
$user->email_verified_at = null; $log->user_id = $user->id;
// Prevent old verifications from working $log->item_id = $user->id;
EmailVerification::whereUserId($user->id)->delete(); $log->item_type = 'App\User';
} $log->action = 'account.edit.email';
$log->message = 'Email changed';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
}
$log = new AccountLog(); if ($changes === true) {
$log->user_id = $user->id; Cache::forget('user:account:id:'.$user->id);
$log->item_id = $user->id; $user->save();
$log->item_type = 'App\User'; $profile->save();
$log->action = 'account.edit.email';
$log->message = 'Email changed';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
}
if ($changes === true) { return redirect('/settings/home')->with('status', 'Email successfully updated!');
Cache::forget('user:account:id:'.$user->id); } else {
$user->save(); return redirect('/settings/email');
$profile->save(); }
return redirect('/settings/home')->with('status', 'Email successfully updated!'); }
} else {
return redirect('/settings/email');
}
} public function avatar()
{
public function avatar() return view('settings.avatar');
{ }
return view('settings.avatar');
}
} }

View file

@ -74,11 +74,6 @@ trait PrivacySettings
} }
Cache::forget('profile:settings:' . $profile->id); Cache::forget('profile:settings:' . $profile->id);
Cache::forget('user:account:id:' . $profile->user_id); Cache::forget('user:account:id:' . $profile->user_id);
Cache::forget('profile:follower_count:' . $profile->id);
Cache::forget('profile:following_count:' . $profile->id);
Cache::forget('profile:embed:' . $profile->id);
Cache::forget('pf:acct:settings:hidden-followers:' . $profile->id);
Cache::forget('pf:acct:settings:hidden-following:' . $profile->id);
return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!'); return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
} }

View file

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

View file

@ -9,149 +9,144 @@ use App\Util\Lexer\PrettyNumber;
use App\{Follower, Page, Profile, Status, User, UserFilter}; use App\{Follower, Page, Profile, Status, User, UserFilter};
use App\Util\Localization\Localization; use App\Util\Localization\Localization;
use App\Services\FollowerService; use App\Services\FollowerService;
use App\Util\ActivityPub\Helpers;
class SiteController extends Controller class SiteController extends Controller
{ {
public function home(Request $request) public function home(Request $request)
{ {
if (Auth::check()) { if (Auth::check()) {
return $this->homeTimeline($request); return $this->homeTimeline($request);
} else { } else {
return $this->homeGuest(); return $this->homeGuest();
} }
} }
public function homeGuest() public function homeGuest()
{ {
return view('site.index'); $data = Cache::remember('site:landing:data', now()->addHours(3), function() {
} return [
'stats' => [
'posts' => App\Util\Lexer\PrettyNumber::convert(App\Status::count()),
'likes' => App\Util\Lexer\PrettyNumber::convert(App\Like::count()),
'hashtags' => App\Util\Lexer\PrettyNumber::convert(App\StatusHashtag::count())
],
];
});
return view('site.index', compact('data'));
}
public function homeTimeline(Request $request) public function homeTimeline(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'layout' => 'nullable|string|in:grid,feed' 'layout' => 'nullable|string|in:grid,feed'
]); ]);
$layout = $request->input('layout', 'feed'); $layout = $request->input('layout', 'feed');
return view('timeline.home', compact('layout')); return view('timeline.home', compact('layout'));
} }
public function changeLocale(Request $request, $locale) public function changeLocale(Request $request, $locale)
{ {
// todo: add other locales after pushing new l10n strings // todo: add other locales after pushing new l10n strings
$locales = Localization::languages(); $locales = Localization::languages();
if(in_array($locale, $locales)) { if(in_array($locale, $locales)) {
if($request->user()) { if($request->user()) {
$user = $request->user(); $user = $request->user();
$user->language = $locale; $user->language = $locale;
$user->save(); $user->save();
} }
session()->put('locale', $locale); session()->put('locale', $locale);
} }
return redirect(route('site.language')); return redirect(route('site.language'));
} }
public function about() public function about()
{ {
return Cache::remember('site.about_v2', now()->addMinutes(15), function() { $page = Page::whereSlug('/site/about')->whereActive(true)->first();
$user_count = number_format(User::count()); $stats = Cache::remember('site:about:stats-v1', now()->addHours(12), function() {
$post_count = number_format(Status::count()); return [
$rules = config_cache('app.rules') ? json_decode(config_cache('app.rules'), true) : null; 'posts' => Status::count(),
return view('site.about', compact('rules', 'user_count', 'post_count'))->render(); 'users' => User::whereNull('status')->count(),
}); 'admin' => User::whereIsAdmin(true)->first()
} ];
});
$path = $page ? 'site.about-custom' : 'site.about';
return view($path, compact('page', 'stats'));
}
public function language() public function language()
{ {
return view('site.language'); return view('site.language');
} }
public function communityGuidelines(Request $request) public function communityGuidelines(Request $request)
{ {
return Cache::remember('site:help:community-guidelines', now()->addDays(120), function() { return Cache::remember('site:help:community-guidelines', now()->addDays(120), function() {
$slug = '/site/kb/community-guidelines'; $slug = '/site/kb/community-guidelines';
$page = Page::whereSlug($slug)->whereActive(true)->first(); $page = Page::whereSlug($slug)->whereActive(true)->first();
return View::make('site.help.community-guidelines')->with(compact('page'))->render(); return View::make('site.help.community-guidelines')->with(compact('page'))->render();
}); });
} }
public function privacy(Request $request) public function privacy(Request $request)
{ {
$page = Cache::remember('site:privacy', now()->addDays(120), function() { $page = Cache::remember('site:privacy', now()->addDays(120), function() {
$slug = '/site/privacy'; $slug = '/site/privacy';
$page = Page::whereSlug($slug)->whereActive(true)->first(); $page = Page::whereSlug($slug)->whereActive(true)->first();
}); });
return View::make('site.privacy')->with(compact('page'))->render(); return View::make('site.privacy')->with(compact('page'))->render();
} }
public function terms(Request $request) public function terms(Request $request)
{ {
$page = Cache::remember('site:terms', now()->addDays(120), function() { $page = Cache::remember('site:terms', now()->addDays(120), function() {
$slug = '/site/terms'; $slug = '/site/terms';
return Page::whereSlug($slug)->whereActive(true)->first(); return Page::whereSlug($slug)->whereActive(true)->first();
}); });
return View::make('site.terms')->with(compact('page'))->render(); return View::make('site.terms')->with(compact('page'))->render();
} }
public function redirectUrl(Request $request) public function redirectUrl(Request $request)
{ {
abort_if(!$request->user(), 404); $this->validate($request, [
$this->validate($request, [ 'url' => 'required|url'
'url' => 'required|url' ]);
]); $url = request()->input('url');
$url = request()->input('url'); return view('site.redirect', compact('url'));
abort_if(Helpers::validateUrl($url) == false, 404); }
return view('site.redirect', compact('url'));
}
public function followIntent(Request $request) public function followIntent(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'user' => 'string|min:1|max:15|exists:users,username', 'user' => 'string|min:1|max:15|exists:users,username',
]); ]);
$profile = Profile::whereUsername($request->input('user'))->firstOrFail(); $profile = Profile::whereUsername($request->input('user'))->firstOrFail();
$user = $request->user(); $user = $request->user();
abort_if($user && $profile->id == $user->profile_id, 404); abort_if($user && $profile->id == $user->profile_id, 404);
$following = $user != null ? FollowerService::follows($user->profile_id, $profile->id) : false; $following = $user != null ? FollowerService::follows($user->profile_id, $profile->id) : false;
return view('site.intents.follow', compact('profile', 'user', 'following')); return view('site.intents.follow', compact('profile', 'user', 'following'));
} }
public function legacyProfileRedirect(Request $request, $username) public function legacyProfileRedirect(Request $request, $username)
{ {
$username = Str::contains($username, '@') ? '@' . $username : $username; $username = Str::contains($username, '@') ? '@' . $username : $username;
if(str_contains($username, '@')) { if(str_contains($username, '@')) {
$profile = Profile::whereUsername($username) $profile = Profile::whereUsername($username)
->firstOrFail(); ->firstOrFail();
if($profile->domain == null) { if($profile->domain == null) {
$url = "/$profile->username"; $url = "/$profile->username";
} else { } else {
$url = "/i/web/profile/_/{$profile->id}"; $url = "/i/web/profile/_/{$profile->id}";
} }
} else { } else {
$profile = Profile::whereUsername($username) $profile = Profile::whereUsername($username)
->whereNull('domain') ->whereNull('domain')
->firstOrFail(); ->firstOrFail();
$url = "/$profile->username"; $url = "/$profile->username";
} }
return redirect($url); return redirect($url);
} }
public function legacyWebfingerRedirect(Request $request, $username, $domain)
{
$un = '@'.$username.'@'.$domain;
$profile = Profile::whereUsername($un)
->firstOrFail();
if($profile->domain == null) {
$url = "/$profile->username";
} else {
$url = $request->user() ? "/i/web/profile/_/{$profile->id}" : $profile->url();
}
return redirect($url);
}
} }

View file

@ -1,128 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Cache;
use DB;
use League\CommonMark\CommonMarkConverter;
use App\Services\AccountService;
use App\Services\StatusService;
use App\Services\SnowflakeService;
use App\Util\Localization\Localization;
class SpaController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
abort_unless(config('exp.spa'), 404);
return view('layouts.spa');
}
public function webPost(Request $request, $id)
{
abort_unless(config('exp.spa'), 404);
if($request->user()) {
return view('layouts.spa');
}
if(SnowflakeService::byDate(now()->subDays(30)) > $id) {
abort(404);
}
$post = StatusService::get($id);
if(
$post &&
isset($post['url']) &&
isset($post['local']) &&
$post['local'] === true
) {
return redirect($post['url']);
}
abort(404);
}
public function webProfile(Request $request, $id)
{
abort_unless(config('exp.spa'), 404);
if($request->user()) {
if(substr($id, 0, 1) == '@') {
$id = AccountService::usernameToId(substr($id, 1));
return redirect("/i/web/profile/{$id}");
}
return view('layouts.spa');
}
$account = AccountService::get($id);
if($account && isset($account['url'])) {
return redirect($account['url']);
}
return redirect('404');
}
public function updateLanguage(Request $request)
{
$this->validate($request, [
'v' => 'required|in:0.1,0.2',
'l' => 'required|alpha_dash|max:5'
]);
$lang = $request->input('l');
$user = $request->user();
abort_if(!in_array($lang, Localization::languages()), 400);
$user->language = $lang;
$user->save();
session()->put('locale', $lang);
return ['language' => $lang];
}
public function getPrivacy()
{
$body = $this->markdownToHtml('views/page/privacy.md');
return [
'body' => $body
];
}
public function getTerms()
{
$body = $this->markdownToHtml('views/page/terms.md');
return [
'body' => $body
];
}
protected function markdownToHtml($src, $ttl = 600)
{
return Cache::remember(
'pf:doc_cache:markdown:' . $src,
$ttl,
function() use($src) {
$path = resource_path($src);
$file = file_get_contents($path);
$converter = new CommonMarkConverter();
return (string) $converter->convertToHtml($file);
});
}
public function usernameRedirect(Request $request, $username)
{
$id = AccountService::usernameToId($username);
if(!$id) {
return redirect('/i/web/404');
}
return redirect('/i/web/profile/' . $id);
}
}

View file

@ -6,414 +6,406 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\Jobs\StatusPipeline\StatusDelete; use App\Jobs\StatusPipeline\StatusDelete;
use App\Jobs\SharePipeline\SharePipeline; use App\Jobs\SharePipeline\SharePipeline;
use App\Jobs\SharePipeline\UndoSharePipeline;
use App\AccountInterstitial; use App\AccountInterstitial;
use App\Media; use App\Media;
use App\Profile; use App\Profile;
use App\Status; use App\Status;
use App\StatusArchived;
use App\StatusView; use App\StatusView;
use App\Transformer\ActivityPub\StatusTransformer; use App\Transformer\ActivityPub\StatusTransformer;
use App\Transformer\ActivityPub\Verb\Note; use App\Transformer\ActivityPub\Verb\Note;
use App\Transformer\ActivityPub\Verb\Question;
use App\User; use App\User;
use Auth, DB, Cache; use Auth, Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use League\Fractal; use League\Fractal;
use App\Util\Media\Filter; use App\Util\Media\Filter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Services\HashidService; use App\Services\HashidService;
use App\Services\StatusService;
use App\Util\Media\License;
use App\Services\ReblogService;
class StatusController extends Controller class StatusController extends Controller
{ {
public function show(Request $request, $username, $id) public function show(Request $request, $username, int $id)
{ {
$user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail(); $user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
if($user->status != null) { if($user->status != null) {
return ProfileController::accountCheck($user); return ProfileController::accountCheck($user);
} }
$status = Status::whereProfileId($user->id) $status = Status::whereProfileId($user->id)
->whereNull('reblog_of_id') ->whereNull('reblog_of_id')
->whereIn('scope', ['public','unlisted', 'private']) ->whereIn('scope', ['public','unlisted', 'private'])
->findOrFail($id); ->findOrFail($id);
if($status->uri || $status->url) { if($status->uri || $status->url) {
$url = $status->uri ?? $status->url; $url = $status->uri ?? $status->url;
if(ends_with($url, '/activity')) { if(ends_with($url, '/activity')) {
$url = str_replace('/activity', '', $url); $url = str_replace('/activity', '', $url);
} }
return redirect($url); return redirect($url);
} }
if($status->visibility == 'private' || $user->is_private) { if($status->visibility == 'private' || $user->is_private) {
if(!Auth::check()) { if(!Auth::check()) {
abort(404); abort(404);
} }
$pid = Auth::user()->profile; $pid = Auth::user()->profile;
if($user->followedBy($pid) == false && $user->id !== $pid->id && Auth::user()->is_admin == false) { if($user->followedBy($pid) == false && $user->id !== $pid->id && Auth::user()->is_admin == false) {
abort(404); abort(404);
} }
} }
if($status->type == 'archived') { if($status->type == 'archived') {
if(Auth::user()->profile_id !== $status->profile_id) { if(Auth::user()->profile_id !== $status->profile_id) {
abort(404); abort(404);
} }
} }
if($request->user() && $request->user()->profile_id != $status->profile_id) { if($request->user() && $request->user()->profile_id != $status->profile_id) {
StatusView::firstOrCreate([ StatusView::firstOrCreate([
'status_id' => $status->id, 'status_id' => $status->id,
'status_profile_id' => $status->profile_id, 'status_profile_id' => $status->profile_id,
'profile_id' => $request->user()->profile_id 'profile_id' => $request->user()->profile_id
]); ]);
} }
if ($request->wantsJson() && config_cache('federation.activitypub.enabled')) { if ($request->wantsJson() && config('federation.activitypub.enabled')) {
return $this->showActivityPub($request, $status); return $this->showActivityPub($request, $status);
} }
$template = $status->in_reply_to_id ? 'status.reply' : 'status.show'; $template = $status->in_reply_to_id ? 'status.reply' : 'status.show';
return view($template, compact('user', 'status')); return view($template, compact('user', 'status'));
} }
public function shortcodeRedirect(Request $request, $id) public function shortcodeRedirect(Request $request, $id)
{ {
abort(404); abort_if(strlen($id) < 5, 404);
} if(!Auth::check()) {
return redirect('/login?next='.urlencode('/' . $request->path()));
}
$id = HashidService::decode($id);
$status = Status::find($id);
if(!$status) {
return redirect('/404');
}
return redirect($status->url());
}
public function showId(int $id) public function showId(int $id)
{ {
abort(404); abort(404);
$status = Status::whereNull('reblog_of_id') $status = Status::whereNull('reblog_of_id')
->whereIn('scope', ['public', 'unlisted']) ->whereIn('scope', ['public', 'unlisted'])
->findOrFail($id); ->findOrFail($id);
return redirect($status->url()); return redirect($status->url());
} }
public function showEmbed(Request $request, $username, int $id) public function showEmbed(Request $request, $username, int $id)
{ {
$profile = Profile::whereNull(['domain','status']) $profile = Profile::whereNull(['domain','status'])
->whereIsPrivate(false) ->whereIsPrivate(false)
->whereUsername($username) ->whereUsername($username)
->first(); ->first();
if(!$profile) { if(!$profile) {
$content = view('status.embed-removed'); $content = view('status.embed-removed');
return response($content)->header('X-Frame-Options', 'ALLOWALL'); return response($content)->header('X-Frame-Options', 'ALLOWALL');
} }
$status = Status::whereProfileId($profile->id) $status = Status::whereProfileId($profile->id)
->whereNull('uri') ->whereNull('uri')
->whereScope('public') ->whereScope('public')
->whereIsNsfw(false) ->whereIsNsfw(false)
->whereIn('type', ['photo', 'video','photo:album']) ->whereIn('type', ['photo', 'video','photo:album'])
->find($id); ->find($id);
if(!$status) { if(!$status) {
$content = view('status.embed-removed'); $content = view('status.embed-removed');
return response($content)->header('X-Frame-Options', 'ALLOWALL'); return response($content)->header('X-Frame-Options', 'ALLOWALL');
} }
$showLikes = $request->filled('likes') && $request->likes == true; $showLikes = $request->filled('likes') && $request->likes == true;
$showCaption = $request->filled('caption') && $request->caption !== false; $showCaption = $request->filled('caption') && $request->caption !== false;
$layout = $request->filled('layout') && $request->layout == 'compact' ? 'compact' : 'full'; $layout = $request->filled('layout') && $request->layout == 'compact' ? 'compact' : 'full';
$content = view('status.embed', compact('status', 'showLikes', 'showCaption', 'layout')); $content = view('status.embed', compact('status', 'showLikes', 'showCaption', 'layout'));
return response($content)->withHeaders(['X-Frame-Options' => 'ALLOWALL']); return response($content)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
} }
public function showObject(Request $request, $username, int $id) public function showObject(Request $request, $username, int $id)
{ {
$user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail(); $user = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
if($user->status != null) { if($user->status != null) {
return ProfileController::accountCheck($user); return ProfileController::accountCheck($user);
} }
$status = Status::whereProfileId($user->id) $status = Status::whereProfileId($user->id)
->whereNotIn('visibility',['draft','direct']) ->whereNotIn('visibility',['draft','direct'])
->findOrFail($id); ->findOrFail($id);
abort_if($status->uri, 404); abort_if($status->uri, 404);
if($status->visibility == 'private' || $user->is_private) { if($status->visibility == 'private' || $user->is_private) {
if(!Auth::check()) { if(!Auth::check()) {
abort(403); abort(403);
} }
$pid = Auth::user()->profile; $pid = Auth::user()->profile;
if($user->followedBy($pid) == false && $user->id !== $pid->id) { if($user->followedBy($pid) == false && $user->id !== $pid->id) {
abort(403); abort(403);
} }
} }
return $this->showActivityPub($request, $status); return $this->showActivityPub($request, $status);
} }
public function compose() public function compose()
{ {
$this->authCheck(); $this->authCheck();
return view('status.compose'); return view('status.compose');
} }
public function store(Request $request) public function store(Request $request)
{ {
return; return;
} }
public function delete(Request $request) public function delete(Request $request)
{ {
$this->authCheck(); $this->authCheck();
$this->validate($request, [ $this->validate($request, [
'item' => 'required|integer|min:1', 'item' => 'required|integer|min:1',
]); ]);
$status = Status::findOrFail($request->input('item')); $status = Status::findOrFail($request->input('item'));
$user = Auth::user(); $user = Auth::user();
if($status->profile_id != $user->profile->id && if($status->profile_id != $user->profile->id &&
$user->is_admin == true && $user->is_admin == true &&
$status->uri == null $status->uri == null
) { ) {
$media = $status->media; $media = $status->media;
$ai = new AccountInterstitial; $ai = new AccountInterstitial;
$ai->user_id = $status->profile->user_id; $ai->user_id = $status->profile->user_id;
$ai->type = 'post.removed'; $ai->type = 'post.removed';
$ai->view = 'account.moderation.post.removed'; $ai->view = 'account.moderation.post.removed';
$ai->item_type = 'App\Status'; $ai->item_type = 'App\Status';
$ai->item_id = $status->id; $ai->item_id = $status->id;
$ai->has_media = (bool) $media->count(); $ai->has_media = (bool) $media->count();
$ai->blurhash = $media->count() ? $media->first()->blurhash : null; $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
$ai->meta = json_encode([ $ai->meta = json_encode([
'caption' => $status->caption, 'caption' => $status->caption,
'created_at' => $status->created_at, 'created_at' => $status->created_at,
'type' => $status->type, 'type' => $status->type,
'url' => $status->url(), 'url' => $status->url(),
'is_nsfw' => $status->is_nsfw, 'is_nsfw' => $status->is_nsfw,
'scope' => $status->scope, 'scope' => $status->scope,
'reblog' => $status->reblog_of_id, 'reblog' => $status->reblog_of_id,
'likes_count' => $status->likes_count, 'likes_count' => $status->likes_count,
'reblogs_count' => $status->reblogs_count, 'reblogs_count' => $status->reblogs_count,
]); ]);
$ai->save(); $ai->save();
$u = $status->profile->user; $u = $status->profile->user;
$u->has_interstitial = true; $u->has_interstitial = true;
$u->save(); $u->save();
} }
Cache::forget('_api:statuses:recent_9:' . $status->profile_id); Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
Cache::forget('profile:status_count:' . $status->profile_id); Cache::forget('profile:status_count:' . $status->profile_id);
Cache::forget('profile:embed:' . $status->profile_id); if ($status->profile_id == $user->profile->id || $user->is_admin == true) {
StatusService::del($status->id, true); Cache::forget('profile:status_count:'.$status->profile_id);
if ($status->profile_id == $user->profile->id || $user->is_admin == true) { StatusDelete::dispatch($status);
Cache::forget('profile:status_count:'.$status->profile_id); }
StatusDelete::dispatch($status);
}
if($request->wantsJson()) { if($request->wantsJson()) {
return response()->json(['Status successfully deleted.']); return response()->json(['Status successfully deleted.']);
} else { } else {
return redirect($user->url()); return redirect($user->url());
} }
} }
public function storeShare(Request $request) public function storeShare(Request $request)
{ {
$this->authCheck(); $this->authCheck();
$this->validate($request, [ $this->validate($request, [
'item' => 'required|integer|min:1', 'item' => 'required|integer|min:1',
]); ]);
$user = Auth::user(); $user = Auth::user();
$profile = $user->profile; $profile = $user->profile;
$status = Status::whereScope('public') $status = Status::withCount('shares')
->findOrFail($request->input('item')); ->whereIn('scope', ['public', 'unlisted'])
->findOrFail($request->input('item'));
$count = $status->reblogs_count; $count = $status->shares()->count();
$exists = Status::whereProfileId(Auth::user()->profile->id) $exists = Status::whereProfileId(Auth::user()->profile->id)
->whereReblogOfId($status->id) ->whereReblogOfId($status->id)
->exists(); ->count();
if ($exists == true) { if ($exists !== 0) {
$shares = Status::whereProfileId(Auth::user()->profile->id) $shares = Status::whereProfileId(Auth::user()->profile->id)
->whereReblogOfId($status->id) ->whereReblogOfId($status->id)
->get(); ->get();
foreach ($shares as $share) { foreach ($shares as $share) {
UndoSharePipeline::dispatch($share); $share->delete();
ReblogService::del($profile->id, $status->id); $count--;
$count--; }
} } else {
} else { $share = new Status();
$share = new Status(); $share->profile_id = $profile->id;
$share->profile_id = $profile->id; $share->reblog_of_id = $status->id;
$share->reblog_of_id = $status->id; $share->in_reply_to_profile_id = $status->profile_id;
$share->in_reply_to_profile_id = $status->profile_id; $share->save();
$share->save(); $count++;
$count++; SharePipeline::dispatch($share);
SharePipeline::dispatch($share); }
ReblogService::add($profile->id, $status->id);
}
Cache::forget('status:'.$status->id.':sharedby:userid:'.$user->id); if($count >= 0) {
StatusService::del($status->id); $status->reblogs_count = $count;
$status->save();
}
if ($request->ajax()) { Cache::forget('status:'.$status->id.':sharedby:userid:'.$user->id);
$response = ['code' => 200, 'msg' => 'Share saved', 'count' => $count];
} else {
$response = redirect($status->url());
}
return $response; if ($request->ajax()) {
} $response = ['code' => 200, 'msg' => 'Share saved', 'count' => $count];
} else {
$response = redirect($status->url());
}
public function showActivityPub(Request $request, $status) return $response;
{ }
$object = $status->type == 'poll' ? new Question() : new Note();
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($status, $object);
$res = $fractal->createData($resource)->toArray();
return response()->json($res['data'], 200, ['Content-Type' => 'application/activity+json'], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); public function showActivityPub(Request $request, $status)
} {
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($status, new Note());
$res = $fractal->createData($resource)->toArray();
public function edit(Request $request, $username, $id) return response()->json($res['data'], 200, ['Content-Type' => 'application/activity+json'], JSON_PRETTY_PRINT);
{ }
$this->authCheck();
$user = Auth::user()->profile;
$status = Status::whereProfileId($user->id)
->with(['media'])
->findOrFail($id);
$licenses = License::get();
return view('status.edit', compact('user', 'status', 'licenses'));
}
public function editStore(Request $request, $username, $id) public function edit(Request $request, $username, $id)
{ {
$this->authCheck(); $this->authCheck();
$user = Auth::user()->profile; $user = Auth::user()->profile;
$status = Status::whereProfileId($user->id) $status = Status::whereProfileId($user->id)
->with(['media']) ->with(['media'])
->findOrFail($id); ->where('created_at', '>', now()->subHours(24))
->findOrFail($id);
return view('status.edit', compact('user', 'status'));
}
$this->validate($request, [ public function editStore(Request $request, $username, $id)
'license' => 'nullable|integer|min:1|max:16', {
]); $this->authCheck();
$user = Auth::user()->profile;
$status = Status::whereProfileId($user->id)
->with(['media'])
->where('created_at', '>', now()->subHours(24))
->findOrFail($id);
$licenseId = $request->input('license'); $this->validate($request, [
'id' => 'required|integer|min:1',
'caption' => 'nullable',
'filter' => 'nullable|alpha_dash|max:30',
]);
$status->media->each(function($media) use($licenseId) { $id = $request->input('id');
$media->license = $licenseId; $caption = $request->input('caption');
$media->save(); $filter = $request->input('filter');
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
});
return redirect($status->url()); $media = Media::whereProfileId($user->id)
} ->whereStatusId($status->id)
->findOrFail($id);
protected function authCheck() $changed = false;
{
if (Auth::check() == false) {
abort(403);
}
}
protected function validateVisibility($visibility) if ($media->caption != $caption) {
{ $media->caption = $caption;
$allowed = ['public', 'unlisted', 'private']; $changed = true;
return in_array($visibility, $allowed) ? $visibility : 'public'; }
}
public static function mimeTypeCheck($mimes) if ($media->filter_class != $filter && in_array($filter, Filter::classes())) {
{ $media->filter_class = $filter;
$allowed = explode(',', config_cache('pixelfed.media_types')); $changed = true;
$count = count($mimes); }
$photos = 0;
$videos = 0;
foreach($mimes as $mime) {
if(in_array($mime, $allowed) == false && $mime !== 'video/mp4') {
continue;
}
if(str_contains($mime, 'image/')) {
$photos++;
}
if(str_contains($mime, 'video/')) {
$videos++;
}
}
if($photos == 1 && $videos == 0) {
return 'photo';
}
if($videos == 1 && $photos == 0) {
return 'video';
}
if($photos > 1 && $videos == 0) {
return 'photo:album';
}
if($videos > 1 && $photos == 0) {
return 'video:album';
}
if($photos >= 1 && $videos >= 1) {
return 'photo:video:album';
}
return 'text'; if ($changed === true) {
} $media->save();
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
}
public function toggleVisibility(Request $request) { return response()->json([], 200);
$this->authCheck(); }
$this->validate($request, [
'item' => 'required|string|min:1|max:20',
'disableComments' => 'required|boolean'
]);
$user = Auth::user(); protected function authCheck()
$id = $request->input('item'); {
$state = $request->input('disableComments'); if (Auth::check() == false) {
abort(403);
}
}
$status = Status::findOrFail($id); protected function validateVisibility($visibility)
{
$allowed = ['public', 'unlisted', 'private'];
return in_array($visibility, $allowed) ? $visibility : 'public';
}
if($status->profile_id != $user->profile->id && $user->is_admin == false) { public static function mimeTypeCheck($mimes)
abort(403); {
} $allowed = explode(',', config('pixelfed.media_types'));
$count = count($mimes);
$photos = 0;
$videos = 0;
foreach($mimes as $mime) {
if(in_array($mime, $allowed) == false && $mime !== 'video/mp4') {
continue;
}
if(str_contains($mime, 'image/')) {
$photos++;
}
if(str_contains($mime, 'video/')) {
$videos++;
}
}
if($photos == 1 && $videos == 0) {
return 'photo';
}
if($videos == 1 && $photos == 0) {
return 'video';
}
if($photos > 1 && $videos == 0) {
return 'photo:album';
}
if($videos > 1 && $photos == 0) {
return 'video:album';
}
if($photos >= 1 && $videos >= 1) {
return 'photo:video:album';
}
}
$status->comments_disabled = $status->comments_disabled == true ? false : true; public function toggleVisibility(Request $request) {
$status->save(); $this->authCheck();
$this->validate($request, [
'item' => 'required|string|min:1|max:20',
'disableComments' => 'required|boolean'
]);
return response()->json([200]); $user = Auth::user();
} $id = $request->input('item');
$state = $request->input('disableComments');
public function storeView(Request $request) $status = Status::findOrFail($id);
{
abort_if(!$request->user(), 403);
$views = $request->input('_v'); if($status->profile_id != $user->profile->id && $user->is_admin == false) {
$uid = $request->user()->profile_id; abort(403);
}
if(empty($views) || !is_array($views)) { $status->comments_disabled = $status->comments_disabled == true ? false : true;
return response()->json(0); $status->save();
}
Cache::forget('profile:home-timeline-cursor:' . $request->user()->id); return response()->json([200]);
}
foreach($views as $view) {
if(!isset($view['sid']) || !isset($view['pid'])) {
continue;
}
DB::transaction(function () use($view, $uid) {
StatusView::firstOrCreate([
'status_id' => $view['sid'],
'status_profile_id' => $view['pid'],
'profile_id' => $uid
]);
});
}
return response()->json(1);
}
} }

View file

@ -1,501 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Media;
use App\Profile;
use App\Report;
use App\DirectMessage;
use App\Notification;
use App\Status;
use App\Story;
use App\StoryView;
use App\Models\Poll;
use App\Models\PollVote;
use App\Services\ProfileService;
use App\Services\StoryService;
use Cache, Storage;
use Image as Intervention;
use App\Services\FollowerService;
use App\Services\MediaPathService;
use FFMpeg;
use FFMpeg\Coordinate\Dimension;
use FFMpeg\Format\Video\X264;
use App\Jobs\StoryPipeline\StoryReactionDeliver;
use App\Jobs\StoryPipeline\StoryReplyDeliver;
use App\Jobs\StoryPipeline\StoryFanout;
use App\Jobs\StoryPipeline\StoryDelete;
use ImageOptimizer;
class StoryComposeController extends Controller
{
public function apiV1Add(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'file' => function() {
return [
'required',
'mimes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
]);
$user = $request->user();
$count = Story::whereProfileId($user->profile_id)
->whereActive(true)
->where('expires_at', '>', now())
->count();
if($count >= Story::MAX_PER_DAY) {
abort(418, 'You have reached your limit for new Stories today.');
}
$photo = $request->file('file');
$path = $this->storePhoto($photo, $user);
$story = new Story();
$story->duration = 3;
$story->profile_id = $user->profile_id;
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->bearcap_token = str_random(64);
$story->expires_at = now()->addMinutes(1440);
$story->save();
$url = $story->path;
$res = [
'code' => 200,
'msg' => 'Successfully added',
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
if($story->type === 'video') {
$video = FFMpeg::open($path);
$duration = $video->getDurationInSeconds();
$res['media_duration'] = $duration;
if($duration > 500) {
Storage::delete($story->path);
$story->delete();
return response()->json([
'message' => 'Video duration cannot exceed 60 seconds'
], 422);
}
}
return $res;
}
protected function storePhoto($photo, $user)
{
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
$fpath = storage_path('app/' . $path);
$img = Intervention::make($fpath);
$img->orientate();
$img->save($fpath, config_cache('pixelfed.image_quality'));
$img->destroy();
}
return $path;
}
public function cropPhoto(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required|integer|min:1',
'width' => 'required',
'height' => 'required',
'x' => 'required',
'y' => 'required'
]);
$user = $request->user();
$id = $request->input('media_id');
$width = round($request->input('width'));
$height = round($request->input('height'));
$x = round($request->input('x'));
$y = round($request->input('y'));
$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
$path = storage_path('app/' . $story->path);
if(!is_file($path)) {
abort(400, 'Invalid or missing media.');
}
if($story->type === 'photo') {
$img = Intervention::make($path);
$img->crop($width, $height, $x, $y);
$img->resize(1080, 1920, function ($constraint) {
$constraint->aspectRatio();
});
$img->save($path, config_cache('pixelfed.image_quality'));
}
return [
'code' => 200,
'msg' => 'Successfully cropped',
];
}
public function publishStory(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required',
'duration' => 'required|integer|min:3|max:120',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
$id = $request->input('media_id');
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = true;
$story->duration = $request->input('duration', 10);
$story->can_reply = $request->input('can_reply');
$story->can_react = $request->input('can_react');
$story->save();
StoryService::delLatest($story->profile_id);
StoryFanout::dispatch($story)->onQueue('story');
StoryService::addRotateQueue($story->id);
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function apiV1Delete(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = false;
$story->save();
StoryDelete::dispatch($story)->onQueue('story');
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function compose(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
return view('stories.compose');
}
public function createPoll(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
abort_if(!config_cache('instance.polls.enabled'), 404);
return $request->all();
}
public function publishStoryPoll(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'question' => 'required|string|min:6|max:140',
'options' => 'required|array|min:2|max:4',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
$pid = $request->user()->profile_id;
$count = Story::whereProfileId($pid)
->whereActive(true)
->where('expires_at', '>', now())
->count();
if($count >= Story::MAX_PER_DAY) {
abort(418, 'You have reached your limit for new Stories today.');
}
$story = new Story;
$story->type = 'poll';
$story->story = json_encode([
'question' => $request->input('question'),
'options' => $request->input('options')
]);
$story->public = false;
$story->local = true;
$story->profile_id = $pid;
$story->expires_at = now()->addMinutes(1440);
$story->duration = 30;
$story->can_reply = false;
$story->can_react = false;
$story->save();
$poll = new Poll;
$poll->story_id = $story->id;
$poll->profile_id = $pid;
$poll->poll_options = $request->input('options');
$poll->expires_at = $story->expires_at;
$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
return 0;
})->toArray();
$poll->save();
$story->active = true;
$story->save();
StoryService::delLatest($story->profile_id);
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function storyPollVote(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'ci' => 'required|integer|min:0|max:3'
]);
$pid = $request->user()->profile_id;
$ci = $request->input('ci');
$story = Story::findOrFail($request->input('sid'));
abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
$poll = Poll::whereStoryId($story->id)->firstOrFail();
$vote = new PollVote;
$vote->profile_id = $pid;
$vote->poll_id = $poll->id;
$vote->story_id = $story->id;
$vote->status_id = null;
$vote->choice = $ci;
$vote->save();
$poll->votes_count = $poll->votes_count + 1;
$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
return $ci == $key ? $tally + 1 : $tally;
})->toArray();
$poll->save();
return 200;
}
public function storeReport(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1',
]);
$pid = $request->user()->profile_id;
$sid = $request->input('id');
$type = $request->input('type');
$types = [
// original 3
'spam',
'sensitive',
'abusive',
// new
'underage',
'copyright',
'impersonation',
'scam',
'terrorism'
];
abort_if(!in_array($type, $types), 422, 'Invalid story report type');
$story = Story::findOrFail($sid);
abort_if($story->profile_id == $pid, 422, 'Cannot report your own story');
abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
if( Report::whereProfileId($pid)
->whereObjectType('App\Story')
->whereObjectId($story->id)
->exists()
) {
return response()->json(['error' => [
'code' => 409,
'message' => 'Cannot report the same story again'
]], 409);
}
$report = new Report;
$report->profile_id = $pid;
$report->user_id = $request->user()->id;
$report->object_id = $story->id;
$report->object_type = 'App\Story';
$report->reported_profile_id = $story->profile_id;
$report->type = $type;
$report->message = null;
$report->save();
return [200];
}
public function react(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'reaction' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('reaction');
$story = Story::findOrFail($request->input('sid'));
abort_if(!$story->can_react, 422);
abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
$status = new Status;
$status->profile_id = $pid;
$status->type = 'story:reaction';
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id,
'reaction' => $text
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:react';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'reaction' => $text
]);
$dm->save();
if($story->local) {
// generate notification
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:react';
$n->message = "{$request->user()->username} reacted to your story";
$n->rendered = "{$request->user()->username} reacted to your story";
$n->save();
} else {
StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
}
StoryService::reactIncrement($story->id, $pid);
return 200;
}
public function comment(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'caption' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('caption');
$story = Story::findOrFail($request->input('sid'));
abort_if(!$story->can_reply, 422);
$status = new Status;
$status->type = 'story:reply';
$status->profile_id = $pid;
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:comment';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
if($story->local) {
// generate notification
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$request->user()->username} commented on story";
$n->rendered = "{$request->user()->username} commented on story";
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
}
return 200;
}
}

View file

@ -4,128 +4,248 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\DirectMessage;
use App\Follower;
use App\Notification;
use App\Media; use App\Media;
use App\Profile; use App\Profile;
use App\Status;
use App\Story; use App\Story;
use App\StoryView; use App\StoryView;
use App\Services\PollService;
use App\Services\ProfileService;
use App\Services\StoryService; use App\Services\StoryService;
use Cache, Storage; use Cache, Storage;
use Image as Intervention; use Image as Intervention;
use App\Services\AccountService;
use App\Services\FollowerService; use App\Services\FollowerService;
use App\Services\MediaPathService;
use FFMpeg;
use FFMpeg\Coordinate\Dimension;
use FFMpeg\Format\Video\X264;
use League\Fractal\Manager;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Resource\Item;
use App\Transformer\ActivityPub\Verb\StoryVerb;
use App\Jobs\StoryPipeline\StoryViewDeliver;
class StoryController extends StoryComposeController
class StoryController extends Controller
{ {
public function recent(Request $request) public function apiV1Add(Request $request)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$pid = $request->user()->profile_id;
if(config('database.default') == 'pgsql') { $this->validate($request, [
$s = Story::select('stories.*', 'followers.following_id') 'file' => function() {
->leftJoin('followers', 'followers.following_id', 'stories.profile_id') return [
->where('followers.profile_id', $pid) 'required',
->where('stories.active', true) 'mimes:image/jpeg,image/png,video/mp4',
->get() 'max:' . config('pixelfed.max_photo_size'),
->map(function($s) { ];
$r = new \StdClass; },
$r->id = $s->id; ]);
$r->profile_id = $s->profile_id;
$r->type = $s->type; $user = $request->user();
$r->path = $s->path;
return $r; if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) {
}) abort(400, 'You have reached your limit for new Stories today.');
->unique('profile_id');
} else {
$s = Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->groupBy('followers.following_id')
->orderByDesc('id')
->get();
} }
$res = $s->map(function($s) use($pid) { $photo = $request->file('file');
$profile = AccountService::get($s->profile_id); $path = $this->storePhoto($photo);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}"); $story = new Story();
return [ $story->duration = 3;
'pid' => $profile['id'], $story->profile_id = $user->profile_id;
'avatar' => $profile['avatar'], $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
'local' => $profile['local'], $story->mime = $photo->getMimeType();
'username' => $profile['acct'], $story->path = $path;
'latest' => [ $story->local = true;
'id' => $s->id, $story->size = $photo->getSize();
'type' => $s->type, $story->expires_at = now()->addHours(24);
'preview_url' => url(Storage::url($s->path)) $story->save();
],
'url' => $url, return [
'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)), 'code' => 200,
'sid' => $s->id 'msg' => 'Successfully added',
]; 'media_url' => url(Storage::url($story->path))
}) ];
->sortBy('seen')
->values();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
public function profile(Request $request, $id) protected function storePhoto($photo)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); $monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
$sid = (string) Str::uuid();
$rid = Str::random(9).'.'.Str::random(9);
$mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$authed = $request->user()->profile_id; $storagePath = "public/_esm.t2/{$monthHash}/{$sid}/{$rid}";
$path = $photo->store($storagePath);
if(in_array($photo->getMimeType(), ['image/jpeg','image/png',])) {
$fpath = storage_path('app/' . $path);
$img = Intervention::make($fpath);
$img->orientate();
$img->save($fpath, config('pixelfed.image_quality'));
$img->destroy();
}
return $path;
}
public function apiV1Delete(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
$story->delete();
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function apiV1Recent(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
$following = $profile->following->pluck('id')->toArray();
if(config('database.default') == 'pgsql') {
$db = Story::with('profile')
->whereIn('profile_id', $following)
->where('expires_at', '>', now())
->distinct('profile_id')
->take(9)
->get();
} else {
$db = Story::with('profile')
->whereIn('profile_id', $following)
->where('expires_at', '>', now())
->orderByDesc('expires_at')
->groupBy('profile_id')
->take(9)
->get();
}
$stories = $db->map(function($s, $k) {
return [
'id' => (string) $s->id,
'photo' => $s->profile->avatarUrl(),
'name' => $s->profile->username,
'link' => $s->profile->url(),
'lastUpdated' => (int) $s->created_at->format('U'),
'seen' => $s->seen(),
'items' => [],
'pid' => (string) $s->profile->id
];
});
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Fetch(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile;
$profile = Profile::findOrFail($id); $profile = Profile::findOrFail($id);
if($id == $authed->id) {
if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) { $publicOnly = true;
return []; } else {
$publicOnly = (bool) $profile->followedBy($authed);
} }
$stories = Story::whereProfileId($profile->id) $stories = Story::whereProfileId($profile->id)
->whereActive(true) ->orderBy('expires_at', 'desc')
->orderBy('expires_at') ->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get() ->get()
->map(function($s, $k) use($authed) { ->map(function($s, $k) {
$seen = StoryService::hasSeen($authed, $s->id); return [
$res = [
'id' => (string) $s->id, 'id' => (string) $s->id,
'type' => $s->type, 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'duration' => $s->duration, 'length' => 3,
'src' => url(Storage::url($s->path)), 'src' => url(Storage::url($s->path)),
'created_at' => $s->created_at->toAtomString(), 'preview' => null,
'expires_at' => $s->expires_at->toAtomString(), 'link' => null,
'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null, 'linkText' => null,
'seen' => $seen, 'time' => $s->created_at->format('U'),
'progress' => $seen ? 100 : 0, 'expires_at' => (int) $s->expires_at->format('U'),
'can_reply' => (bool) $s->can_reply, 'seen' => $s->seen()
'can_react' => (bool) $s->can_react
]; ];
})->toArray();
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
if($s->type == 'poll') { public function apiV1Item(Request $request, $id)
$res['question'] = json_decode($s->story, true)['question']; {
$res['options'] = json_decode($s->story, true)['options']; abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$res['voted'] = PollService::votedStory($s->id, $authed);
if($res['voted']) {
$res['voted_index'] = PollService::storyChoice($s->id, $authed);
}
}
return $res; $authed = $request->user()->profile;
$story = Story::with('profile')
->where('expires_at', '>', now())
->findOrFail($id);
$profile = $story->profile;
if($story->profile_id == $authed->id) {
$publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
}
abort_if(!$publicOnly, 403);
$res = [
'id' => (string) $story->id,
'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo',
'length' => 3,
'src' => url(Storage::url($story->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $story->created_at->format('U'),
'expires_at' => (int) $story->expires_at->format('U'),
'seen' => $story->seen()
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Profile(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile;
$profile = Profile::findOrFail($id);
if($id == $authed->id) {
$publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
}
$stories = Story::whereProfileId($profile->id)
->orderBy('expires_at')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get()
->map(function($s, $k) {
return [
'id' => $s->id,
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'seen' => $s->seen()
];
})->toArray(); })->toArray();
if(count($stories) == 0) { if(count($stories) == 0) {
return []; return [];
@ -133,159 +253,110 @@ class StoryController extends StoryComposeController
$cursor = count($stories) - 1; $cursor = count($stories) - 1;
$stories = [[ $stories = [[
'id' => (string) $stories[$cursor]['id'], 'id' => (string) $stories[$cursor]['id'],
'nodes' => $stories, 'photo' => $profile->avatarUrl(),
'account' => AccountService::get($profile->id), 'name' => $profile->username,
'link' => $profile->url(),
'lastUpdated' => (int) now()->format('U'),
'seen' => null,
'items' => $stories,
'pid' => (string) $profile->id 'pid' => (string) $profile->id
]]; ]];
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
public function viewed(Request $request) public function apiV1Viewed(Request $request)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [ $this->validate($request, [
'id' => 'required|min:1', 'id' => 'required|integer|min:1|exists:stories',
]); ]);
$id = $request->input('id'); $id = $request->input('id');
$authed = $request->user()->profile; $authed = $request->user()->profile;
$story = Story::with('profile') $story = Story::with('profile')
->where('expires_at', '>', now())
->orderByDesc('expires_at')
->findOrFail($id); ->findOrFail($id);
$exp = $story->expires_at;
$profile = $story->profile; $profile = $story->profile;
if($story->profile_id == $authed->id) { if($story->profile_id == $authed->id) {
return []; $publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
} }
$publicOnly = (bool) $profile->followedBy($authed);
abort_if(!$publicOnly, 403); abort_if(!$publicOnly, 403);
StoryView::firstOrCreate([
$v = StoryView::firstOrCreate([
'story_id' => $id, 'story_id' => $id,
'profile_id' => $authed->id 'profile_id' => $authed->id
]); ]);
if($v->wasRecentlyCreated) {
Story::findOrFail($story->id)->increment('view_count');
if($story->local == false) {
StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
}
}
Cache::forget('stories:recent:by_id:' . $authed->id);
StoryService::addSeen($authed->id, $story->id);
return ['code' => 200]; return ['code' => 200];
} }
public function exists(Request $request, $id) public function apiV1Exists(Request $request, $id)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
return response()->json(Story::whereProfileId($id) $res = (bool) Story::whereProfileId($id)
->whereActive(true) ->where('expires_at', '>', now())
->exists()); ->count();
return response()->json($res);
}
public function apiV1Me(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
$stories = Story::whereProfileId($profile->id)
->orderBy('expires_at')
->where('expires_at', '>', now())
->get()
->map(function($s, $k) {
return [
'id' => $s->id,
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'seen' => true
];
})->toArray();
$ts = count($stories) ? last($stories)['time'] : null;
$res = [
'id' => (string) $profile->id,
'photo' => $profile->avatarUrl(),
'name' => $profile->username,
'link' => $profile->url(),
'lastUpdated' => $ts,
'seen' => true,
'items' => $stories
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function compose(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
return view('stories.compose');
} }
public function iRedirect(Request $request) public function iRedirect(Request $request)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user(); $user = $request->user();
abort_if(!$user, 404); abort_if(!$user, 404);
$username = $user->username; $username = $user->username;
return redirect("/stories/{$username}"); return redirect("/stories/{$username}");
} }
public function viewers(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required|string'
]);
$pid = $request->user()->profile_id;
$sid = $request->input('sid');
$story = Story::whereProfileId($pid)
->whereActive(true)
->findOrFail($sid);
$viewers = StoryView::whereStoryId($story->id)
->latest()
->simplePaginate(10)
->map(function($view) {
return AccountService::get($view->profile_id);
})
->values();
return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function remoteStory(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::findOrFail($id);
if($profile->user_id != null || $profile->domain == null) {
return redirect('/stories/' . $profile->username);
}
$pid = $profile->id;
return view('stories.show_remote', compact('pid'));
}
public function pollResults(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required|string'
]);
$pid = $request->user()->profile_id;
$sid = $request->input('sid');
$story = Story::whereProfileId($pid)
->whereActive(true)
->findOrFail($sid);
return PollService::storyResults($sid);
}
public function getActivityObject(Request $request, $username, $id)
{
abort_if(!config_cache('instance.stories.enabled'), 404);
if(!$request->wantsJson()) {
return redirect('/stories/' . $username);
}
abort_if(!$request->hasHeader('Authorization'), 404);
$profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
$story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
abort_if($story->bearcap_token == null, 404);
abort_if(now()->gt($story->expires_at), 404);
$token = substr($request->header('Authorization'), 7);
abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
$fractal = new Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Item($story, new StoryVerb());
$res = $fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function showSystemStory()
{
// return view('stories.system');
}
} }

View file

@ -2,32 +2,37 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Auth, Cache;
use App\Follower;
use App\Profile;
use App\Status;
use App\User;
use App\UserFilter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class TimelineController extends Controller class TimelineController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $this->middleware('auth');
$this->middleware('twofactor'); $this->middleware('twofactor');
} }
public function local(Request $request) public function local(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'layout' => 'nullable|string|in:grid,feed' 'layout' => 'nullable|string|in:grid,feed'
]); ]);
$layout = $request->input('layout', 'feed'); $layout = $request->input('layout', 'feed');
return view('timeline.local', compact('layout')); return view('timeline.local', compact('layout'));
} }
public function network(Request $request) public function network(Request $request)
{ {
abort_if(config('federation.network_timeline') == false, 404); $this->validate($request, [
$this->validate($request, [ 'layout' => 'nullable|string|in:grid,feed'
'layout' => 'nullable|string|in:grid,feed' ]);
]); $layout = $request->input('layout', 'feed');
$layout = $request->input('layout', 'feed'); return view('timeline.network', compact('layout'));
return view('timeline.network', compact('layout')); }
}
} }

View file

@ -9,16 +9,19 @@ use Illuminate\Support\Str;
class UserInviteController extends Controller class UserInviteController extends Controller
{ {
public function create(Request $request) public function __construct()
{ {
abort_if(!config('pixelfed.user_invites.enabled'), 404); abort_if(!config('pixelfed.user_invites.enabled'), 404);
}
public function create(Request $request)
{
abort_unless(Auth::check(), 403); abort_unless(Auth::check(), 403);
return view('settings.invites.create'); return view('settings.invites.create');
} }
public function show(Request $request) public function show(Request $request)
{ {
abort_if(!config('pixelfed.user_invites.enabled'), 404);
abort_unless(Auth::check(), 403); abort_unless(Auth::check(), 403);
$invites = UserInvite::whereUserId(Auth::id())->paginate(10); $invites = UserInvite::whereUserId(Auth::id())->paginate(10);
$limit = config('pixelfed.user_invites.limit.total'); $limit = config('pixelfed.user_invites.limit.total');
@ -28,7 +31,6 @@ class UserInviteController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
abort_if(!config('pixelfed.user_invites.enabled'), 404);
abort_unless(Auth::check(), 403); abort_unless(Auth::check(), 403);
$this->validate($request, [ $this->validate($request, [
'email' => 'required|email|unique:users|unique:user_invites', 'email' => 'required|email|unique:users|unique:user_invites',

View file

@ -32,11 +32,10 @@ class Kernel extends HttpKernel
\App\Http\Middleware\FrameGuard::class, \App\Http\Middleware\FrameGuard::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
// 'restricted', // 'restricted',
], ],

View file

@ -6,32 +6,31 @@ use Closure;
class EmailVerificationCheck class EmailVerificationCheck
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Closure $next * @param \Closure $next
* *
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if ($request->user() && if ($request->user() &&
config_cache('pixelfed.enforce_email_verification') && config('pixelfed.enforce_email_verification') &&
is_null($request->user()->email_verified_at) && is_null($request->user()->email_verified_at) &&
!$request->is( !$request->is(
'i/auth/*', 'i/auth/*',
'i/verify-email*', 'i/verify-email',
'log*', 'log*',
'site*', 'i/confirm-email/*',
'i/confirm-email/*', 'settings/home',
'settings/home', 'settings/email'
'settings/email' )
) ) {
) { return redirect('/i/verify-email');
return redirect('/i/verify-email'); }
}
return $next($request); return $next($request);
} }
} }

View file

@ -16,67 +16,68 @@ use Image as Intervention;
class AvatarOptimize implements ShouldQueue class AvatarOptimize implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile; protected $profile;
protected $current; protected $current;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Profile $profile, $current) public function __construct(Profile $profile, $current)
{ {
$this->profile = $profile; $this->profile = $profile;
$this->current = $current; $this->current = $current;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$avatar = $this->profile->avatar; $avatar = $this->profile->avatar;
$file = storage_path("app/$avatar->media_path"); $file = storage_path("app/$avatar->media_path");
try { try {
$img = Intervention::make($file)->orientate(); $img = Intervention::make($file)->orientate();
$img->fit(200, 200, function ($constraint) { $img->fit(200, 200, function ($constraint) {
$constraint->upsize(); $constraint->upsize();
}); });
$quality = config_cache('pixelfed.image_quality'); $quality = config('pixelfed.image_quality');
$img->save($file, $quality); $img->save($file, $quality);
$avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail(); $avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail();
$avatar->change_count = ++$avatar->change_count; $avatar->thumb_path = $avatar->media_path;
$avatar->last_processed_at = Carbon::now(); $avatar->change_count = ++$avatar->change_count;
$avatar->save(); $avatar->last_processed_at = Carbon::now();
Cache::forget('avatar:' . $avatar->profile_id); $avatar->save();
$this->deleteOldAvatar($avatar->media_path, $this->current); Cache::forget('avatar:' . $avatar->profile_id);
} catch (Exception $e) { $this->deleteOldAvatar($avatar->media_path, $this->current);
} } catch (Exception $e) {
} }
}
protected function deleteOldAvatar($new, $current) protected function deleteOldAvatar($new, $current)
{ {
if ( storage_path('app/'.$new) == $current || if ( storage_path('app/'.$new) == $current ||
Str::endsWith($current, 'avatars/default.png') || Str::endsWith($current, 'avatars/default.png') ||
Str::endsWith($current, 'avatars/default.jpg')) Str::endsWith($current, 'avatars/default.jpg'))
{ {
return; return;
} }
if (is_file($current)) { if (is_file($current)) {
@unlink($current); @unlink($current);
} }
} }
} }

View file

@ -45,6 +45,7 @@ class CreateAvatar implements ShouldQueue
$avatar = new Avatar(); $avatar = new Avatar();
$avatar->profile_id = $profile->id; $avatar->profile_id = $profile->id;
$avatar->media_path = $path; $avatar->media_path = $path;
$avatar->thumb_path = $path;
$avatar->change_count = 0; $avatar->change_count = 0;
$avatar->last_processed_at = \Carbon\Carbon::now(); $avatar->last_processed_at = \Carbon\Carbon::now();
$avatar->save(); $avatar->save();

View file

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

View file

@ -8,7 +8,6 @@ use App\{
UserFilter UserFilter
}; };
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\StatusService;
use DB, Cache, Log; use DB, Cache, Log;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@ -59,11 +58,6 @@ class CommentPipeline implements ShouldQueue
$target = $status->profile; $target = $status->profile;
$actor = $comment->profile; $actor = $comment->profile;
DB::transaction(function() use($status) {
$status->reply_count = DB::table('statuses')->whereInReplyToId($status->id)->count();
$status->save();
});
if ($actor->id === $target->id || $status->comments_disabled == true) { if ($actor->id === $target->id || $status->comments_disabled == true) {
return true; return true;
} }
@ -91,16 +85,6 @@ class CommentPipeline implements ShouldQueue
NotificationService::setNotification($notification); NotificationService::setNotification($notification);
NotificationService::set($notification->profile_id, $notification->id); NotificationService::set($notification->profile_id, $notification->id);
StatusService::del($comment->id);
}); });
if($exists = Cache::get('status:replies:all:' . $status->id)) {
if($exists && $exists->count() == 3) {
} else {
Cache::forget('status:replies:all:' . $status->id);
}
} else {
Cache::forget('status:replies:all:' . $status->id);
}
} }
} }

View file

@ -1,93 +0,0 @@
<?php
namespace App\Jobs\DeletePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Cache;
use DB;
use Illuminate\Support\Str;
use App\Profile;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
class FanoutDeletePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile;
public $timeout = 300;
public $tries = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($profile)
{
$this->profile = $profile;
}
public function handle()
{
$profile = $this->profile;
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$audience = Cache::remember('pf:ap:known_instances', now()->addHours(6), function() {
return Profile::whereNotNull('sharedInbox')->groupBy('sharedInbox')->pluck('sharedInbox')->toArray();
});
$activity = [
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => $profile->permalink('#delete'),
"type" => "Delete",
"actor" => $profile->permalink(),
"to" => [
"https://www.w3.org/ns/activitystreams#Public",
],
"object" => $profile->permalink(),
];
$payload = json_encode($activity);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
return 1;
}
}

View file

@ -14,62 +14,59 @@ use Illuminate\Support\Facades\Redis;
class FollowPipeline implements ShouldQueue class FollowPipeline implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $follower; protected $follower;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct($follower) public function __construct($follower)
{ {
$this->follower = $follower; $this->follower = $follower;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$follower = $this->follower; $follower = $this->follower;
$actor = $follower->actor; $actor = $follower->actor;
$target = $follower->target; $target = $follower->target;
Cache::forget('profile:following:' . $actor->id); if($target->domain || !$target->private_key) {
Cache::forget('profile:following:' . $target->id); return;
}
if($target->domain || !$target->private_key) { try {
return; $notification = new Notification();
} $notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'follow';
$notification->message = $follower->toText();
$notification->rendered = $follower->toHtml();
$notification->item_id = $target->id;
$notification->item_type = "App\Profile";
$notification->save();
try { $redis = Redis::connection();
$notification = new Notification();
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'follow';
$notification->message = $follower->toText();
$notification->rendered = $follower->toHtml();
$notification->item_id = $target->id;
$notification->item_type = "App\Profile";
$notification->save();
$redis = Redis::connection(); $nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
$redis->lpush($nkey, $notification->id);
$nkey = config('cache.prefix').':user.'.$target->id.'.notifications'; } catch (Exception $e) {
$redis->lpush($nkey, $notification->id); Log::error($e);
} catch (Exception $e) { }
Log::error($e); }
}
}
} }

View file

@ -41,7 +41,7 @@ class ImageOptimize implements ShouldQueue
{ {
$media = $this->media; $media = $this->media;
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
if (!is_file($path) || $media->skip_optimize) { if (!is_file($path)) {
return; return;
} }

View file

@ -45,7 +45,7 @@ class ImageResize implements ShouldQueue
return; return;
} }
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
if (!is_file($path) || $media->skip_optimize) { if (!is_file($path)) {
return; return;
} }

View file

@ -11,73 +11,81 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use ImageOptimizer; use ImageOptimizer;
use Illuminate\Http\File; use Illuminate\Http\File;
use App\Services\MediaPathService;
use App\Jobs\MediaPipeline\MediaStoragePipeline;
class ImageUpdate implements ShouldQueue class ImageUpdate implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media; protected $media;
protected $protectedMimes = [ protected $protectedMimes = [
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/webp' ];
];
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Media $media) public function __construct(Media $media)
{ {
$this->media = $media; $this->media = $media;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$media = $this->media; $media = $this->media;
if(!$media) { if(!$media) {
return; return;
} }
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
$thumb = storage_path('app/'.$media->thumbnail_path); $thumb = storage_path('app/'.$media->thumbnail_path);
if (!is_file($path)) { if (!is_file($path)) {
return; return;
} }
if (in_array($media->mime, $this->protectedMimes) == true) { if (in_array($media->mime, $this->protectedMimes) == true) {
ImageOptimizer::optimize($thumb); ImageOptimizer::optimize($thumb);
if(!$media->skip_optimize) { ImageOptimizer::optimize($path);
ImageOptimizer::optimize($path); }
}
}
if (!is_file($path) || !is_file($thumb)) { if (!is_file($path) || !is_file($thumb)) {
return; return;
} }
$photo_size = filesize($path); $photo_size = filesize($path);
$thumb_size = filesize($thumb); $thumb_size = filesize($thumb);
$total = ($photo_size + $thumb_size); $total = ($photo_size + $thumb_size);
$media->size = $total; $media->size = $total;
$media->save(); $media->save();
MediaStoragePipeline::dispatch($media); if(config('pixelfed.cloud_storage') == true) {
} $p = explode('/', $media->media_path);
$monthHash = $p[2];
$userHash = $p[3];
$storagePath = "public/m/{$monthHash}/{$userHash}";
$file = Storage::disk(config('filesystems.cloud'))->putFile($storagePath, new File($path), 'public');
$url = Storage::disk(config('filesystems.cloud'))->url($file);
$thumbFile = Storage::disk(config('filesystems.cloud'))->putFile($storagePath, new File($thumb), 'public');
$thumbUrl = Storage::disk(config('filesystems.cloud'))->url($thumbFile);
$media->thumbnail_url = $thumbUrl;
$media->cdn_url = $url;
$media->optimized_url = $url;
$media->save();
}
}
} }

View file

@ -12,120 +12,116 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\{ use App\{
ImportJob, ImportJob,
ImportData, ImportData,
Media, Media,
Profile, Profile,
Status, Status,
}; };
class ImportInstagram implements ShouldQueue class ImportInstagram implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $import; protected $import;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(ImportJob $import) public function __construct(ImportJob $import)
{ {
$this->import = $import; $this->import = $import;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
if(config_cache('pixelfed.import.instagram.enabled') != true) { if(config('pixelfed.import.instagram.enabled') != true) {
return; return;
} }
$job = ImportJob::findOrFail($this->import->id); $job = ImportJob::findOrFail($this->import->id);
$profile = Profile::findOrFail($job->profile_id); $profile = Profile::findOrFail($job->profile_id);
$user = $profile->user; $user = $profile->user;
$json = $job->mediaJson(); $json = $job->mediaJson();
$collection = array_reverse($json['photos']); $collection = array_reverse($json['photos']);
$files = $job->files; $files = $job->files;
$monthHash = hash('sha1', date('Y').date('m')); $monthHash = hash('sha1', date('Y').date('m'));
$userHash = hash('sha1', $user->id . (string) $user->created_at); $userHash = hash('sha1', $user->id . (string) $user->created_at);
$fs = new Filesystem; $fs = new Filesystem;
foreach($collection as $import) foreach($collection as $import)
{ {
$caption = $import['caption']; $caption = $import['caption'];
try { try {
$min = Carbon::create(2010, 10, 6, 0, 0, 0); $min = Carbon::create(2010, 10, 6, 0, 0, 0);
$taken_at = Carbon::parse($import['taken_at']); $taken_at = Carbon::parse($import['taken_at']);
if(!$min->lt($taken_at)) { if(!$min->lt($taken_at)) {
$taken_at = Carbon::now(); $taken_at = Carbon::now();
} }
} catch (Exception $e) { } catch (Exception $e) {
} }
$filename = last( explode('/', $import['path']) ); $filename = last( explode('/', $import['path']) );
$importData = ImportData::whereJobId($job->id) $importData = ImportData::whereJobId($job->id)
->whereOriginalName($filename) ->whereOriginalName($filename)
->first(); ->first();
if(empty($importData) || is_file(storage_path("app/$importData->path")) == false) { if(empty($importData) || is_file(storage_path("app/$importData->path")) == false) {
continue; continue;
} }
DB::transaction(function() use( DB::transaction(function() use(
$fs, $job, $profile, $caption, $taken_at, $filename, $fs, $job, $profile, $caption, $taken_at, $filename,
$monthHash, $userHash, $importData $monthHash, $userHash, $importData
) { ) {
$status = new Status(); $status = new Status();
$status->profile_id = $profile->id; $status->profile_id = $profile->id;
$status->caption = strip_tags($caption); $status->caption = strip_tags($caption);
$status->is_nsfw = false; $status->is_nsfw = false;
$status->type = 'photo'; $status->type = 'photo';
$status->scope = 'unlisted'; $status->scope = 'unlisted';
$status->visibility = 'unlisted'; $status->visibility = 'unlisted';
$status->created_at = $taken_at; $status->created_at = $taken_at;
$status->save(); $status->save();
$path = storage_path("app/$importData->path"); $path = storage_path("app/$importData->path");
$storagePath = "public/m/{$monthHash}/{$userHash}"; $storagePath = "public/m/{$monthHash}/{$userHash}";
$dir = "app/$storagePath"; $newPath = "app/$storagePath/$filename";
if(!is_dir(storage_path($dir))) { $fs->move($path,storage_path($newPath));
mkdir(storage_path($dir), 0755, true); $path = $newPath;
} $hash = \hash_file('sha256', storage_path($path));
$newPath = "$dir/$filename"; $media = new Media();
$fs->move($path,storage_path($newPath)); $media->status_id = $status->id;
$path = $newPath; $media->profile_id = $profile->id;
$hash = \hash_file('sha256', storage_path($path)); $media->user_id = $profile->user->id;
$media = new Media(); $media->media_path = "$storagePath/$filename";
$media->status_id = $status->id; $media->original_sha256 = $hash;
$media->profile_id = $profile->id; $media->size = $fs->size(storage_path($path));
$media->user_id = $profile->user->id; $media->mime = $fs->mimeType(storage_path($path));
$media->media_path = "$storagePath/$filename"; $media->filter_class = null;
$media->original_sha256 = $hash; $media->filter_name = null;
$media->size = $fs->size(storage_path($path)); $media->order = 1;
$media->mime = $fs->mimeType(storage_path($path)); $media->save();
$media->filter_class = null; ImageOptimize::dispatch($media);
$media->filter_name = null; });
$media->order = 1; }
$media->save();
ImageOptimize::dispatch($media);
});
}
$job->completed_at = Carbon::now(); $job->completed_at = Carbon::now();
$job->save(); $job->save();
} }
} }

View file

@ -1,223 +0,0 @@
<?php
namespace App\Jobs\InboxPipeline;
use Cache;
use App\Profile;
use App\Util\ActivityPub\{
Helpers,
HttpSignature,
Inbox
};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Zttp\Zttp;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
class DeleteWorker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $headers;
protected $payload;
public $timeout = 60;
public $tries = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($headers, $payload)
{
$this->headers = $headers;
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = null;
$headers = $this->headers;
$payload = json_decode($this->payload, true, 8);
if(isset($payload['id'])) {
$lockKey = 'pf:ap:del-lock:' . hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 300);
}
if(!isset($headers['signature']) || !isset($headers['date'])) {
return;
}
if(empty($headers) || empty($payload)) {
return;
}
if( $payload['type'] === 'Delete' &&
( ( is_string($payload['object']) &&
$payload['object'] === $payload['actor'] ) ||
( is_array($payload['object']) &&
isset($payload['object']['id'], $payload['object']['type']) &&
$payload['object']['type'] === 'Person' &&
$payload['actor'] === $payload['object']['id']
))
) {
$actor = $payload['actor'];
$hash = strlen($actor) <= 48 ?
'b:' . base64_encode($actor) :
'h:' . hash('sha256', $actor);
$lockKey = 'ap:inbox:actor-delete-exists:lock:' . $hash;
Cache::lock($lockKey, 10)->block(5, function () use(
$headers,
$payload,
$actor,
$hash
) {
$key = 'ap:inbox:actor-delete-exists:' . $hash;
$actorDelete = Cache::remember($key, now()->addMinutes(15), function() use($actor) {
return Profile::whereRemoteUrl($actor)
->whereNotNull('domain')
->exists();
});
if($actorDelete) {
if($this->verifySignature($headers, $payload) == true) {
Cache::set($key, false);
$profile = Profile::whereNotNull('domain')
->whereNull('status')
->whereRemoteUrl($actor)
->first();
if($profile) {
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('delete');
}
return;
} else {
// Signature verification failed, exit.
return;
}
} else {
// Remote user doesn't exist, exit early.
return;
}
});
return;
}
$profile = null;
if($this->verifySignature($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else if($this->blindKeyRotation($headers, $payload) == true) {
(new Inbox($headers, $profile, $payload))->handle();
return;
} else {
return;
}
}
protected function verifySignature($headers, $payload)
{
$body = $this->payload;
$bodyDecoded = $payload;
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$id = Helpers::validateUrl($bodyDecoded['id']);
$keyDomain = parse_url($keyId, PHP_URL_HOST);
$idDomain = parse_url($id, PHP_URL_HOST);
if(isset($bodyDecoded['object'])
&& is_array($bodyDecoded['object'])
&& isset($bodyDecoded['object']['attributedTo'])
) {
if(parse_url($bodyDecoded['object']['attributedTo'], PHP_URL_HOST) !== $keyDomain) {
return;
}
}
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
return;
}
$actor = Profile::whereKeyId($keyId)->first();
if(!$actor) {
$actorUrl = is_array($bodyDecoded['actor']) ? $bodyDecoded['actor'][0] : $bodyDecoded['actor'];
$actor = Helpers::profileFirstOrNew($actorUrl);
}
if(!$actor) {
return;
}
$pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
}
$inboxPath = "/f/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) {
return true;
} else {
return false;
}
}
protected function blindKeyRotation($headers, $payload)
{
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
if(!$signature) {
return;
}
if(!$date) {
return;
}
if(!now()->parse($date)->gt(now()->subDays(1)) ||
!now()->parse($date)->lt(now()->addDays(1))
) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
$keyId = Helpers::validateUrl($signatureData['keyId']);
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
if(!$actor) {
return;
}
if(Helpers::validateUrl($actor->remote_url) == false) {
return;
}
$res = Zttp::timeout(5)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url);
$res = json_decode($res->body(), true, 8);
if($res['publicKey']['id'] !== $actor->key_id) {
return;
}
$actor->public_key = $res['publicKey']['publicKeyPem'];
$actor->save();
return $this->verifySignature($headers, $payload);
}
}

View file

@ -53,15 +53,6 @@ class InboxValidator implements ShouldQueue
$profile = Profile::whereNull('domain')->whereUsername($username)->first(); $profile = Profile::whereNull('domain')->whereUsername($username)->first();
if(isset($payload['id'])) {
$lockKey = hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 300);
}
if(!isset($headers['signature']) || !isset($headers['date'])) { if(!isset($headers['signature']) || !isset($headers['date'])) {
return; return;
} }
@ -182,9 +173,6 @@ class InboxValidator implements ShouldQueue
return; return;
} }
$pkey = openssl_pkey_get_public($actor->public_key); $pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
}
$inboxPath = "/users/{$profile->username}/inbox"; $inboxPath = "/users/{$profile->username}/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body); list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) { if($verified == 1) {

View file

@ -49,15 +49,6 @@ class InboxWorker implements ShouldQueue
$headers = $this->headers; $headers = $this->headers;
$payload = json_decode($this->payload, true, 8); $payload = json_decode($this->payload, true, 8);
if(isset($payload['id'])) {
$lockKey = hash('sha256', $payload['id']);
if(Cache::get($lockKey) !== null) {
// Job processed already
return 1;
}
Cache::put($lockKey, 1, 300);
}
if(!isset($headers['signature']) || !isset($headers['date'])) { if(!isset($headers['signature']) || !isset($headers['date'])) {
return; return;
} }
@ -170,9 +161,6 @@ class InboxWorker implements ShouldQueue
return; return;
} }
$pkey = openssl_pkey_get_public($actor->public_key); $pkey = openssl_pkey_get_public($actor->public_key);
if(!$pkey) {
return 0;
}
$inboxPath = "/f/inbox"; $inboxPath = "/f/inbox";
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body); list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
if($verified == 1) { if($verified == 1) {

View file

@ -1,56 +0,0 @@
<?php
namespace App\Jobs\InstancePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use App\Instance;
use App\Profile;
use App\Services\NodeinfoService;
class FetchNodeinfoPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $instance;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Instance $instance)
{
$this->instance = $instance;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$instance = $this->instance;
$ni = NodeinfoService::get($instance->domain);
if($ni) {
if(isset($ni['software']) && is_array($ni['software']) && isset($ni['software']['name'])) {
$software = $ni['software']['name'];
$instance->software = strtolower(strip_tags($software));
$instance->last_crawled_at = now();
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->save();
}
} else {
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->last_crawled_at = now();
$instance->save();
}
}
}

View file

@ -1,43 +0,0 @@
<?php
namespace App\Jobs\InstancePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use App\Instance;
use App\Profile;
use App\Services\NodeinfoService;
class InstanceCrawlPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Instance::whereNull('last_crawled_at')->whereNull('software')->chunk(50, function($instances) {
foreach($instances as $instance) {
FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
}
});
}
}

View file

@ -2,7 +2,7 @@
namespace App\Jobs\LikePipeline; namespace App\Jobs\LikePipeline;
use Cache, DB, Log; use Cache, Log;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use App\{Like, Notification}; use App\{Like, Notification};
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -14,7 +14,6 @@ use App\Util\ActivityPub\Helpers;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\Like as LikeTransformer; use App\Transformer\ActivityPub\Verb\Like as LikeTransformer;
use App\Services\StatusService;
class LikePipeline implements ShouldQueue class LikePipeline implements ShouldQueue
{ {
@ -59,11 +58,6 @@ class LikePipeline implements ShouldQueue
return; return;
} }
$status->likes_count = DB::table('likes')->whereStatusId($status->id)->count();
$status->save();
StatusService::refresh($status->id);
if($status->url && $actor->domain == null) { if($status->url && $actor->domain == null) {
return $this->remoteLikeDeliver(); return $this->remoteLikeDeliver();
} }

View file

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

View file

@ -1,67 +0,0 @@
<?php
namespace App\Jobs\MediaPipeline;
use App\Media;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
class MediaDeletePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public function __construct(Media $media)
{
$this->media = $media;
}
public function handle()
{
$media = $this->media;
$path = $media->media_path;
$thumb = $media->thumbnail_path;
if(!$path) {
return 1;
}
$e = explode('/', $path);
array_pop($e);
$i = implode('/', $e);
if(config_cache('pixelfed.cloud_storage') == true) {
$disk = Storage::disk(config('filesystems.cloud'));
if($disk->exists($path)) {
$disk->delete($path);
}
if($disk->exists($thumb)) {
$disk->delete($thumb);
}
if(count($e) > 4 && count($disk->files($i)) == 0) {
$disk->deleteDirectory($i);
}
}
$disk = Storage::disk(config('filesystems.local'));
if($disk->exists($path)) {
$disk->delete($path);
}
if($disk->exists($thumb)) {
$disk->delete($thumb);
}
if(count($e) > 4 && count($disk->files($i)) == 0) {
$disk->deleteDirectory($i);
}
return 1;
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Jobs\MediaPipeline;
use App\Media;
use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use App\Services\MediaStorageService;
class MediaStoragePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public function __construct(Media $media)
{
$this->media = $media;
}
public function handle()
{
MediaStorageService::store($this->media);
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace App\Jobs\MediaPipeline;
use App\Media;
use App\User;
use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\StatusService;
class MediaSyncLicensePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $userId;
protected $licenseId;
public function __construct($userId, $licenseId)
{
$this->userId = $userId;
$this->licenseId = $licenseId;
}
public function handle()
{
$licenseId = $this->licenseId;
if(!$licenseId || !$this->userId) {
return 1;
}
Media::whereUserId($this->userId)
->chunk(100, function($medias) use($licenseId) {
foreach($medias as $media) {
$media->license = $licenseId;
$media->save();
Cache::forget('status:transformer:media:attachments:'. $media->status_id);
StatusService::del($media->status_id);
}
});
}
}

View file

@ -1,52 +0,0 @@
<?php
namespace App\Jobs\ModPipeline;
use Cache;
use App\Profile;
use App\Status;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\StatusService;
class HandleSpammerPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile;
public $deleteWhenMissingModels = true;
public function __construct(Profile $profile)
{
$this->profile = $profile;
}
public function handle()
{
$profile = $this->profile;
$profile->unlisted = true;
$profile->cw = true;
$profile->no_autolink = true;
$profile->save();
Status::whereProfileId($profile->id)
->chunk(50, function($statuses) {
foreach($statuses as $status) {
$status->is_nsfw = true;
$status->scope = $status->scope === 'public' ? 'unlisted' : $status->scope;
$status->visibility = $status->scope;
$status->save();
StatusService::del($status->id, true);
}
});
Cache::forget('_api:statuses:recent_9:'.$profile->id);
return 1;
}
}

View file

@ -15,141 +15,135 @@ use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\Announce; use App\Transformer\ActivityPub\Verb\Announce;
use GuzzleHttp\{Pool, Client, Promise}; use GuzzleHttp\{Pool, Client, Promise};
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use App\Services\StatusService;
class SharePipeline implements ShouldQueue class SharePipeline implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; protected $status;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$status = $this->status; $status = $this->status;
$parent = $this->status->parent(); $actor = $status->profile;
$actor = $status->profile; $target = $status->parent()->profile;
$target = $parent->profile;
if ($status->uri !== null) { if ($status->uri !== null) {
// Ignore notifications to remote statuses // Ignore notifications to remote statuses
return; return;
} }
$exists = Notification::whereProfileId($target->id) $exists = Notification::whereProfileId($target->id)
->whereActorId($status->profile_id) ->whereActorId($status->profile_id)
->whereAction('share') ->whereAction('share')
->whereItemId($status->reblog_of_id) ->whereItemId($status->reblog_of_id)
->whereItemType('App\Status') ->whereItemType('App\Status')
->exists(); ->count();
if($target->id === $status->profile_id) { if ($target->id === $status->profile_id) {
$this->remoteAnnounceDeliver(); $this->remoteAnnounceDeliver();
return true; return true;
} }
if($exists === true) { if( $exists !== 0) {
return true; return true;
} }
$this->remoteAnnounceDeliver(); $this->remoteAnnounceDeliver();
$parent->reblogs_count = $parent->shares()->count(); try {
$parent->save(); $notification = new Notification;
StatusService::del($parent->id); $notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'share';
$notification->message = $status->shareToText();
$notification->rendered = $status->shareToHtml();
$notification->item_id = $status->reblog_of_id ?? $status->id;
$notification->item_type = "App\Status";
$notification->save();
try { $redis = Redis::connection();
$notification = new Notification; $key = config('cache.prefix').':user.'.$status->profile_id.'.notifications';
$notification->profile_id = $target->id; $redis->lpush($key, $notification->id);
$notification->actor_id = $actor->id; } catch (Exception $e) {
$notification->action = 'share'; Log::error($e);
$notification->message = $status->shareToText(); }
$notification->rendered = $status->shareToHtml(); }
$notification->item_id = $status->reblog_of_id ?? $status->id;
$notification->item_type = "App\Status";
$notification->save();
$redis = Redis::connection(); public function remoteAnnounceDeliver()
$key = config('cache.prefix').':user.'.$status->profile_id.'.notifications'; {
$redis->lpush($key, $notification->id); if(config('federation.activitypub.enabled') == false) {
} catch (Exception $e) { return true;
Log::error($e); }
} $status = $this->status;
} $profile = $status->profile;
public function remoteAnnounceDeliver() $fractal = new Fractal\Manager();
{ $fractal->setSerializer(new ArraySerializer());
if(config_cache('federation.activitypub.enabled') == false) { $resource = new Fractal\Resource\Item($status, new Announce());
return true; $activity = $fractal->createData($resource)->toArray();
}
$status = $this->status;
$profile = $status->profile;
$fractal = new Fractal\Manager(); $audience = $status->profile->getAudienceInbox();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new Announce());
$activity = $fractal->createData($resource)->toArray();
$audience = $status->profile->getAudienceInbox(); if(empty($audience) || $status->scope != 'public') {
// Return on profiles with no remote followers
return;
}
if(empty($audience) || $status->scope != 'public') { $payload = json_encode($activity);
// Return on profiles with no remote followers
return;
}
$payload = json_encode($activity); $client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$client = new Client([ $requests = function($audience) use ($client, $activity, $profile, $payload) {
'timeout' => config('federation.activitypub.delivery.timeout') foreach($audience as $url) {
]); $headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$requests = function($audience) use ($client, $activity, $profile, $payload) { $pool = new Pool($client, $requests($audience), [
foreach($audience as $url) { 'concurrency' => config('federation.activitypub.delivery.concurrency'),
$headers = HttpSignature::sign($profile, $url, $activity); 'fulfilled' => function ($response, $index) {
yield function() use ($client, $url, $headers, $payload) { },
return $client->postAsync($url, [ 'rejected' => function ($reason, $index) {
'curl' => [ }
CURLOPT_HTTPHEADER => $headers, ]);
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [ $promise = $pool->promise();
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise(); $promise->wait();
$promise->wait(); }
}
} }

View file

@ -1,118 +0,0 @@
<?php
namespace App\Jobs\SharePipeline;
use Cache, Log;
use Illuminate\Support\Facades\Redis;
use App\{Status, Notification};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\UndoAnnounce;
use GuzzleHttp\{Pool, Client, Promise};
use App\Util\ActivityPub\HttpSignature;
use App\Services\StatusService;
class UndoSharePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
public $deleteWhenMissingModels = true;
public function __construct(Status $status)
{
$this->status = $status;
}
public function handle()
{
$status = $this->status;
$actor = $status->profile;
$parent = $status->parent();
$target = $status->parent()->profile;
if ($status->uri !== null) {
return;
}
if($target->domain === null) {
Notification::whereProfileId($target->id)
->whereActorId($status->profile_id)
->whereAction('share')
->whereItemId($status->reblog_of_id)
->whereItemType('App\Status')
->delete();
}
$this->remoteAnnounceDeliver();
if($parent->reblogs_count > 0) {
$parent->reblogs_count = $parent->reblogs_count - 1;
$parent->save();
StatusService::del($parent->id);
}
$status->forceDelete();
return 1;
}
public function remoteAnnounceDeliver()
{
if(config_cache('federation.activitypub.enabled') == false) {
return 1;
}
$status = $this->status;
$profile = $status->profile;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new UndoAnnounce());
$activity = $fractal->createData($resource)->toArray();
$audience = $status->profile->getAudienceInbox();
if(empty($audience) || $status->scope != 'public') {
return 1;
}
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
}
}

View file

@ -12,7 +12,6 @@ use Illuminate\Queue\SerializesModels;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateNote; use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool; use GuzzleHttp\Pool;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@ -21,97 +20,85 @@ use App\Util\ActivityPub\HttpSignature;
class StatusActivityPubDeliver implements ShouldQueue class StatusActivityPubDeliver implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; protected $status;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$status = $this->status; $status = $this->status;
$profile = $status->profile; $profile = $status->profile;
if($status->local == false || $status->url || $status->uri) { if($status->local == false || $status->url || $status->uri) {
return; return;
} }
$audience = $status->profile->getAudienceInbox(); $audience = $status->profile->getAudienceInbox();
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
// Return on profiles with no remote followers // Return on profiles with no remote followers
return; return;
} }
switch($status->type) {
case 'poll':
$activitypubObject = new CreateQuestion();
break;
default:
$activitypubObject = new CreateNote();
break;
}
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer()); $fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, $activitypubObject); $resource = new Fractal\Resource\Item($status, new CreateNote());
$activity = $fractal->createData($resource)->toArray(); $activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([ $client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout') 'timeout' => config('federation.activitypub.delivery.timeout')
]); ]);
$requests = function($audience) use ($client, $activity, $profile, $payload) { $requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) { foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity); $headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) { yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [ return $client->postAsync($url, [
'curl' => [ 'curl' => [
CURLOPT_HTTPHEADER => $headers, CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload, CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true, CURLOPT_HEADER => true
CURLOPT_SSL_VERIFYPEER => false, ]
CURLOPT_SSL_VERIFYHOST => false ]);
] };
]); }
}; };
}
};
$pool = new Pool($client, $requests($audience), [ $pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'), 'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) { 'fulfilled' => function ($response, $index) {
}, },
'rejected' => function ($reason, $index) { 'rejected' => function ($reason, $index) {
} }
]); ]);
$promise = $pool->promise(); $promise = $pool->promise();
$promise->wait(); $promise->wait();
} }
} }

View file

@ -2,14 +2,14 @@
namespace App\Jobs\StatusPipeline; namespace App\Jobs\StatusPipeline;
use DB, Storage; use DB;
use App\{ use App\{
AccountInterstitial, AccountInterstitial,
MediaTag, MediaTag,
Notification, Notification,
Report, Report,
Status, Status,
StatusHashtag, StatusHashtag,
}; };
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,7 +17,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use League\Fractal; use League\Fractal;
use Illuminate\Support\Str;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\DeleteNote; use App\Transformer\ActivityPub\Verb\DeleteNote;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
@ -25,149 +24,163 @@ use GuzzleHttp\Pool;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Promise; use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use App\Services\StatusService;
use App\Services\MediaStorageService;
class StatusDelete implements ShouldQueue class StatusDelete implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; protected $status;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$status = $this->status; $status = $this->status;
$profile = $this->status->profile; $profile = $this->status->profile;
StatusService::del($status->id, true); $count = $profile->statuses()
->getQuery()
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->count();
if(in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) { $profile->status_count = ($count - 1);
$profile->status_count = $profile->status_count - 1; $profile->save();
$profile->save();
}
if(config_cache('federation.activitypub.enabled') == true) { if(config('federation.activitypub.enabled') == true) {
$this->fanoutDelete($status); $this->fanoutDelete($status);
} else { } else {
$this->unlinkRemoveMedia($status); $this->unlinkRemoveMedia($status);
} }
} }
public function unlinkRemoveMedia($status) public function unlinkRemoveMedia($status)
{ {
foreach ($status->media as $media) { foreach ($status->media as $media) {
MediaStorageService::delete($media, true); $thumbnail = storage_path("app/{$media->thumbnail_path}");
} $photo = storage_path("app/{$media->media_path}");
if($status->in_reply_to_id) { try {
DB::transaction(function() use($status) { if (is_file($thumbnail)) {
$parent = Status::findOrFail($status->in_reply_to_id); unlink($thumbnail);
--$parent->reply_count; }
$parent->save(); if (is_file($photo)) {
}); unlink($photo);
} }
DB::transaction(function() use($status) { $media->delete();
$comments = Status::where('in_reply_to_id', $status->id)->get(); } catch (Exception $e) {
foreach ($comments as $comment) { }
$comment->in_reply_to_id = null; }
$comment->save(); if($status->in_reply_to_id) {
Notification::whereItemType('App\Status') DB::transaction(function() use($status) {
->whereItemId($comment->id) $parent = Status::findOrFail($status->in_reply_to_id);
->delete(); --$parent->reply_count;
} $parent->save();
$status->likes()->delete(); });
Notification::whereItemType('App\Status') }
->whereItemId($status->id) DB::transaction(function() use($status) {
->delete(); $comments = Status::where('in_reply_to_id', $status->id)->get();
StatusHashtag::whereStatusId($status->id)->delete(); foreach ($comments as $comment) {
Report::whereObjectType('App\Status') $comment->in_reply_to_id = null;
->whereObjectId($status->id) $comment->save();
->delete(); Notification::whereItemType('App\Status')
MediaTag::where('status_id', $status->id) ->whereItemId($comment->id)
->cursor() ->delete();
->each(function($tag) { }
Notification::where('item_type', 'App\MediaTag') $status->likes()->delete();
->where('item_id', $tag->id) Notification::whereItemType('App\Status')
->forceDelete(); ->whereItemId($status->id)
$tag->delete(); ->delete();
}); StatusHashtag::whereStatusId($status->id)->delete();
AccountInterstitial::where('item_type', 'App\Status') Report::whereObjectType('App\Status')
->where('item_id', $status->id) ->whereObjectId($status->id)
->delete(); ->delete();
$status->forceDelete(); MediaTag::where('status_id', $status->id)
}); ->cursor()
->each(function($tag) {
Notification::where('item_type', 'App\MediaTag')
->where('item_id', $tag->id)
->forceDelete();
$tag->delete();
});
return true; AccountInterstitial::where('item_type', 'App\Status')
} ->where('item_id', $status->id)
->delete();
protected function fanoutDelete($status) $status->forceDelete();
{ });
$audience = $status->profile->getAudienceInbox();
$profile = $status->profile;
$fractal = new Fractal\Manager(); return true;
$fractal->setSerializer(new ArraySerializer()); }
$resource = new Fractal\Resource\Item($status, new DeleteNote());
$activity = $fractal->createData($resource)->toArray();
$this->unlinkRemoveMedia($status); protected function fanoutDelete($status)
{
$audience = $status->profile->getAudienceInbox();
$profile = $status->profile;
$payload = json_encode($activity); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new DeleteNote());
$activity = $fractal->createData($resource)->toArray();
$client = new Client([ $this->unlinkRemoveMedia($status);
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) { $payload = json_encode($activity);
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [ $client = new Client([
'concurrency' => config('federation.activitypub.delivery.concurrency'), 'timeout' => config('federation.activitypub.delivery.timeout')
'fulfilled' => function ($response, $index) { ]);
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise(); $requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$promise->wait(); $pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
} $promise = $pool->promise();
$promise->wait();
}
} }

View file

@ -21,158 +21,146 @@ use Illuminate\Queue\SerializesModels;
class StatusEntityLexer implements ShouldQueue class StatusEntityLexer implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; protected $status;
protected $entities; protected $entities;
protected $autolink; protected $autolink;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$profile = $this->status->profile; $profile = $this->status->profile;
$status = $this->status;
if(in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) { $count = $profile->statuses()
$profile->status_count = $profile->status_count + 1; ->getQuery()
$profile->save(); ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
} ->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->count();
if($profile->no_autolink == false) { $profile->status_count = $count;
$this->parseEntities(); $profile->save();
}
}
public function parseEntities() if($profile->no_autolink == false) {
{ $this->parseEntities();
$this->extractEntities(); }
} }
public function extractEntities() public function parseEntities()
{ {
$this->entities = Extractor::create()->extract($this->status->caption); $this->extractEntities();
$this->autolinkStatus(); }
}
public function autolinkStatus() public function extractEntities()
{ {
$this->autolink = Autolink::create()->autolink($this->status->caption); $this->entities = Extractor::create()->extract($this->status->caption);
$this->storeEntities(); $this->autolinkStatus();
} }
public function storeEntities() public function autolinkStatus()
{ {
$this->storeHashtags(); $this->autolink = Autolink::create()->autolink($this->status->caption);
DB::transaction(function () { $this->storeEntities();
$status = $this->status; }
$status->rendered = nl2br($this->autolink);
$status->entities = json_encode($this->entities);
$status->save();
});
}
public function storeHashtags() public function storeEntities()
{ {
$tags = array_unique($this->entities['hashtags']); $this->storeHashtags();
$status = $this->status; DB::transaction(function () {
$status = $this->status;
$status->rendered = nl2br($this->autolink);
$status->entities = json_encode($this->entities);
$status->save();
});
}
foreach ($tags as $tag) { public function storeHashtags()
if(mb_strlen($tag) > 124) { {
continue; $tags = array_unique($this->entities['hashtags']);
} $status = $this->status;
DB::transaction(function () use ($status, $tag) {
$slug = str_slug($tag, '-', false);
$hashtag = Hashtag::where('slug', $slug)->first();
if (!$hashtag) {
$hashtag = Hashtag::create(
['name' => $tag, 'slug' => $slug]
);
}
StatusHashtag::firstOrCreate( foreach ($tags as $tag) {
[ if(mb_strlen($tag) > 124) {
'status_id' => $status->id, continue;
'hashtag_id' => $hashtag->id, }
'profile_id' => $status->profile_id, DB::transaction(function () use ($status, $tag) {
'status_visibility' => $status->visibility, $slug = str_slug($tag, '-', false);
] $hashtag = Hashtag::firstOrCreate(
); ['name' => $tag, 'slug' => $slug]
}); );
} StatusHashtag::firstOrCreate(
$this->storeMentions(); [
} 'status_id' => $status->id,
'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id,
'status_visibility' => $status->visibility,
]
);
});
}
$this->storeMentions();
}
public function storeMentions() public function storeMentions()
{ {
$mentions = array_unique($this->entities['mentions']); $mentions = array_unique($this->entities['mentions']);
$status = $this->status; $status = $this->status;
foreach ($mentions as $mention) { foreach ($mentions as $mention) {
$mentioned = Profile::whereUsername($mention)->first(); $mentioned = Profile::whereUsername($mention)->first();
if (empty($mentioned) || !isset($mentioned->id)) { if (empty($mentioned) || !isset($mentioned->id)) {
continue; continue;
} }
DB::transaction(function () use ($status, $mentioned) { DB::transaction(function () use ($status, $mentioned) {
$m = new Mention(); $m = new Mention();
$m->status_id = $status->id; $m->status_id = $status->id;
$m->profile_id = $mentioned->id; $m->profile_id = $mentioned->id;
$m->save(); $m->save();
MentionPipeline::dispatch($status, $m); MentionPipeline::dispatch($status, $m);
}); });
} }
$this->deliver(); $this->deliver();
} }
public function deliver() public function deliver()
{ {
$status = $this->status; $status = $this->status;
$types = [
'photo',
'photo:album',
'video',
'video:album',
'photo:video:album'
];
if(config_cache('pixelfed.bouncer.enabled')) { if(config('pixelfed.bouncer.enabled')) {
Bouncer::get($status); Bouncer::get($status);
} }
if( $status->uri == null && if($status->uri == null && $status->scope == 'public') {
$status->scope == 'public' && PublicTimelineService::add($status->id);
in_array($status->type, $types) && }
$status->in_reply_to_id === null &&
$status->reblog_of_id === null
) {
PublicTimelineService::add($status->id);
}
if(config_cache('federation.activitypub.enabled') == true && config('app.env') == 'production') { if(config('federation.activitypub.enabled') == true && config('app.env') == 'production') {
StatusActivityPubDeliver::dispatch($status); StatusActivityPubDeliver::dispatch($this->status);
} }
} }
} }

View file

@ -1,89 +0,0 @@
<?php
namespace App\Jobs\StatusPipeline;
use App\Notification;
use App\Status;
use Cache;
use DB;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use App\Services\NotificationService;
class StatusReplyPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
public $timeout = 5;
public $tries = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$actor = $status->profile;
$reply = Status::find($status->in_reply_to_id);
if(!$actor || !$reply) {
return 1;
}
$target = $reply->profile;
$exists = Notification::whereProfileId($target->id)
->whereActorId($actor->id)
->whereIn('action', ['mention', 'comment'])
->whereItemId($status->id)
->whereItemType('App\Status')
->count();
if ($actor->id === $target || $exists !== 0) {
return 1;
}
DB::transaction(function() use($target, $actor, $status) {
$notification = new Notification();
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'comment';
$notification->message = $status->replyToText();
$notification->rendered = $status->replyToHtml();
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();
NotificationService::setNotification($notification);
NotificationService::set($notification->profile_id, $notification->id);
});
return 1;
}
}

View file

@ -1,51 +0,0 @@
<?php
namespace App\Jobs\StatusPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\CustomEmojiService;
use App\Services\StatusService;
class StatusTagsPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $activity;
protected $status;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($activity, $status)
{
$this->activity = $activity;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$res = $this->activity;
collect($res['tag'])
->filter(function($tag) {
// todo: finish hashtag + mention import
// return in_array($tag['type'], ['Emoji', 'Hashtag', 'Mention']);
return $tag && $tag['type'] == 'Emoji';
})
->map(function($tag) {
CustomEmojiService::import($tag['id'], $this->status->id);
});
}
}

View file

@ -1,136 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Storage;
use App\Story;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryDelete implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == false) {
return;
}
StoryService::removeRotateQueue($story->id);
StoryService::delLatest($story->profile_id);
StoryService::delById($story->id);
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
$story->views()->delete();
$profile = $story->profile;
$activity = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $story->url() . '#delete',
'type' => 'Delete',
'actor' => $profile->permalink(),
'object' => [
'id' => $story->url(),
'type' => 'Story',
],
];
$this->fanoutExpiry($profile, $activity);
// delete notifications
// delete polls
// delete reports
$story->delete();
return;
}
protected function fanoutExpiry($profile, $activity)
{
$audience = FollowerService::softwareAudience($profile->id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
}
}

View file

@ -1,169 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Storage;
use App\Story;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryExpire implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == false) {
$this->handleRemoteExpiry();
return;
}
if($story->active == false) {
return;
}
if($story->expires_at->gt(now())) {
return;
}
$story->active = false;
$story->save();
$this->rotateMediaPath();
$this->fanoutExpiry();
StoryService::delLatest($story->profile_id);
}
protected function rotateMediaPath()
{
$story = $this->story;
$date = date('Y').date('m');
$old = $story->path;
$base = "story_archives/{$story->profile_id}/{$date}/";
$paths = explode('/', $old);
$path = array_pop($paths);
$newPath = $base . $path;
if(Storage::exists($old) == true) {
$dir = implode('/', $paths);
Storage::move($old, $newPath);
Storage::delete($old);
$story->bearcap_token = null;
$story->path = $newPath;
$story->save();
Storage::deleteDirectory($dir);
}
}
protected function fanoutExpiry()
{
$story = $this->story;
$profile = $story->profile;
if($story->local == false || $story->remote_url) {
return;
}
$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($story, new DeleteStory());
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
}
protected function handleRemoteExpiry()
{
$story = $this->story;
$story->active = false;
$story->save();
$path = $story->path;
if(Storage::exists($path) == true) {
Storage::delete($path);
}
$story->views()->delete();
$story->delete();
}
}

View file

@ -1,107 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use Cache, Log;
use App\Story;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryFanout implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$profile = $story->profile;
if($story->local == false || $story->remote_url) {
return;
}
StoryService::delLatest($story->profile_id);
$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($story, new CreateStory());
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
}
}

View file

@ -1,144 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use Cache, Log;
use App\Story;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
use App\Services\FollowerService;
use App\Util\Lexer\Bearcap;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\ConnectionException;
use App\Util\ActivityPub\Validator\StoryValidator;
use App\Services\StoryService;
use App\Services\MediaPathService;
use Illuminate\Support\Str;
use Illuminate\Http\File;
use Illuminate\Support\Facades\Storage;
class StoryFetch implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $activity;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$activity = $this->activity;
$activityId = $activity['id'];
$activityActor = $activity['actor'];
if(parse_url($activityId, PHP_URL_HOST) !== parse_url($activityActor, PHP_URL_HOST)) {
return;
}
$bearcap = Bearcap::decode($activity['object']['object']);
if(!$bearcap) {
return;
}
$url = $bearcap['url'];
$token = $bearcap['token'];
if(parse_url($activityId, PHP_URL_HOST) !== parse_url($url, PHP_URL_HOST)) {
return;
}
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
];
try {
$res = Http::withHeaders($headers)
->timeout(30)
->get($url);
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (\Exception $e) {
return false;
}
$payload = $res->json();
if(StoryValidator::validate($payload) == false) {
return;
}
if(Helpers::validateUrl($payload['attachment']['url']) == false) {
return;
}
$type = $payload['attachment']['type'] == 'Image' ? 'photo' : 'video';
$profile = Helpers::profileFetch($payload['attributedTo']);
$ext = pathinfo($payload['attachment']['url'], PATHINFO_EXTENSION);
$storagePath = MediaPathService::story($profile);
$fileName = Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $ext;
$contextOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peername' => false
]
];
$ctx = stream_context_create($contextOptions);
$data = file_get_contents($payload['attachment']['url'], false, $ctx);
$tmpBase = storage_path('app/remcache/');
$tmpPath = $profile->id . '-' . $fileName;
$tmpName = $tmpBase . $tmpPath;
file_put_contents($tmpName, $data);
$disk = Storage::disk(config('filesystems.default'));
$path = $disk->putFileAs($storagePath, new File($tmpName), $fileName, 'public');
$size = filesize($tmpName);
unlink($tmpName);
$story = new Story;
$story->profile_id = $profile->id;
$story->object_id = $payload['id'];
$story->size = $size;
$story->mime = $payload['attachment']['mediaType'];
$story->duration = $payload['duration'];
$story->media_url = $payload['attachment']['url'];
$story->type = $type;
$story->public = false;
$story->local = false;
$story->active = true;
$story->path = $path;
$story->view_count = 0;
$story->can_reply = $payload['can_reply'];
$story->can_react = $payload['can_react'];
$story->created_at = now()->parse($payload['published']);
$story->expires_at = now()->parse($payload['expiresAt']);
$story->save();
StoryService::delLatest($story->profile_id);
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use App\Story;
use App\Status;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
class StoryReactionDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
protected $status;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story, Status $status)
{
$this->story = $story;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$status = $this->status;
if($story->local == true) {
return;
}
$target = $story->profile;
$actor = $status->profile;
$to = $target->inbox_url;
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Story:Reaction',
'to' => $target->permalink(),
'actor' => $actor->permalink(),
'content' => $status->caption,
'inReplyTo' => $story->object_id,
'published' => $status->created_at->toAtomString()
];
Helpers::sendSignedObject($actor, $to, $payload);
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace App\Jobs\StoryPipeline;
use App\Story;
use App\Status;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
class StoryReplyDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
protected $status;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story, Status $status)
{
$this->story = $story;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$status = $this->status;
if($story->local == true) {
return;
}
$target = $story->profile;
$actor = $status->profile;
$to = $target->inbox_url;
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Story:Reply',
'to' => $target->permalink(),
'actor' => $actor->permalink(),
'content' => $status->caption,
'inReplyTo' => $story->object_id,
'published' => $status->created_at->toAtomString()
];
Helpers::sendSignedObject($actor, $to, $payload);
}
}

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