diff --git a/.babelrc b/.babelrc deleted file mode 100644 index d9feadb871c..00000000000 --- a/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "env": { - "test": { - "presets": [ - ["@babel/preset-env", { - "targets": { - "node": "current" - } - }] - ] - } - } -} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index b365d4342ea..a4ecda7248a 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -8,6 +8,56 @@ jobs: pr-title: runs-on: ubuntu-latest steps: - - uses: deepakputhraya/action-pr-title@master - with: - regex: '^\[\d+\.x\]\s' + - name: Validate PR title matches target branch + env: + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + # Validates PR title against target branch + # Returns error message if invalid, empty string if valid + validate_pr_title() { + local target_branch="$1" + local pr_title="$2" + local default_branch="$3" + + # Check if target branch is a version branch (e.g., 5.x, 4.x) + if [[ $target_branch =~ ^([0-9]+)\.x$ ]]; then + local version="${BASH_REMATCH[1]}" + if [[ ! $pr_title =~ ^\[$version\.x\][[:space:]] ]]; then + echo "PR targeting '$target_branch' must have title starting with '[$version.x] '" + return + fi + + # Check if target branch is master (next major version) + elif [[ $target_branch == "master" ]]; then + local current_version="${default_branch//\.x/}" + local next_version=$((current_version + 1)) + if [[ ! $pr_title =~ ^\[$next_version\.x\][[:space:]] ]]; then + echo "PR targeting 'master' must have title starting with '[$next_version.x] '" + return + fi + + # For other branches, just enforce that there's a version prefix + else + if [[ ! $pr_title =~ ^\[[0-9]+\.x\][[:space:]] ]]; then + echo "PR title must start with a version prefix like '[5.x] '" + return + fi + fi + + echo "" + } + + echo "PR Title: $PR_TITLE" + echo "Base Branch: $BASE_BRANCH" + echo "Default Branch: $DEFAULT_BRANCH" + + ERROR=$(validate_pr_title "$BASE_BRANCH" "$PR_TITLE" "$DEFAULT_BRANCH") + + if [[ -n $ERROR ]]; then + echo $ERROR + exit 1 + fi + + echo "PR title validation passed" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d04436a6bd1..e68c4371d6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 504755ad063..a150d57e5b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,8 +14,8 @@ jobs: strategy: matrix: - php: [8.1, 8.2, 8.3, 8.4] - laravel: [10.*, 11.*] + php: [8.1, 8.2, 8.3, 8.4, 8.5] + laravel: [10.*, 11.*, 12.*] stability: [prefer-lowest, prefer-stable] os: [ubuntu-latest] include: @@ -30,8 +30,14 @@ jobs: exclude: - php: 8.1 laravel: 11.* + - php: 8.1 + laravel: 12.* - php: 8.4 laravel: 10.* + - php: 8.5 + laravel: 10.* + - php: 8.5 + laravel: 11.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} @@ -41,7 +47,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v46 with: files: | config @@ -101,7 +107,7 @@ jobs: run: vendor/bin/phpunit js-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[ci skip]')" name: JavaScript tests @@ -112,7 +118,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v46 with: files: | **/*.{js,vue,ts} @@ -145,7 +151,7 @@ jobs: slack: name: Slack Notification - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: [php-tests, js-tests] if: always() steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index e19344b6f33..2bc8d62c3f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,805 @@ # Release Notes -## 5.42.0.1 (2024-12-09) +## 5.73.10 (2026-02-20) + +### What's fixed +- Fixes `shouldUpdateUris` regex adding additional brackets to Antlers [#13995](https://github.com/statamic/cms/issues/13995) by @martyf +- Validate password reset url [#14023](https://github.com/statamic/cms/issues/14023) [#14008](https://github.com/statamic/cms/issues/14008) by @jasonvarga +- Harden html rendering [#14006](https://github.com/statamic/cms/issues/14006) by @jasonvarga + + + +## 5.73.9 (2026-02-18) + +### What's fixed +- Correct test namespaces to avoid PSR-4 warnings [#13989](https://github.com/statamic/cms/issues/13989) by @duncanmcclean +- Sanitize html in html fieldtype [#13992](https://github.com/statamic/cms/issues/13992) by @jasonvarga + + + +## 5.73.8 (2026-02-18) + +### What's fixed +- Avoid replacing nocache regions in initial full-measure response [#13953](https://github.com/statamic/cms/issues/13953) by @duncanmcclean +- Fix Icon fieldtype augment error when value is empty [#13966](https://github.com/statamic/cms/issues/13966) by @jhhazelaar +- Fix `whereIn()`/`whereNotIn()` error for booleans [#13952](https://github.com/statamic/cms/issues/13952) by @duncanmcclean + + + +## 5.73.7 (2026-02-13) + +### What's fixed +- Revert etags [#13933](https://github.com/statamic/cms/issues/13933) by @jasonvarga + + + +## 5.73.6 (2026-02-11) + +### What's fixed +- Fix after_save preference not persisting when default preferences override 'listing' [#13879](https://github.com/statamic/cms/issues/13879) by @el-schneider +- Asset auth fix [#13883](https://github.com/statamic/cms/issues/13883) by @duncanmcclean +- Account for custom fields when checking if entry URIs should be updated [#13859](https://github.com/statamic/cms/issues/13859) by @duncanmcclean + + + +## 5.73.5 (2026-02-03) + +### What's fixed +- Add auth to asset routes [#13810](https://github.com/statamic/cms/issues/13810) by @jasonvarga + + + +## 5.73.4 (2026-02-03) + +### What's fixed +- Handle `0` values in text fields and `null` string in slugs [#13786](https://github.com/statamic/cms/issues/13786) by @joshuablum +- Fix multi-site URL invalidation in `ApplicationCacher` [#13793](https://github.com/statamic/cms/issues/13793) by @joshuablum + + + +## 5.73.3 (2026-01-30) + +### What's fixed +- Avoid showing large number of assets in listing [#13758](https://github.com/statamic/cms/issues/13758) by @jasonvarga +- Abort 404 when asset is not found in AssetsController [#13741](https://github.com/statamic/cms/issues/13741) by @mynetx + + + +## 5.73.2 (2026-01-26) + +### What's fixed +- Revert `AssetContainer::accessible()` visibility change [#13673](https://github.com/statamic/cms/issues/13673) by @duncanmcclean +- Fix: Prevent 304 responses without client cache headers [#13654](https://github.com/statamic/cms/issues/13654) by @mynetx +- Fix uninitialized property error from `HandleEntrySchedule` job [#13648](https://github.com/statamic/cms/issues/13648) by @duncanmcclean +- Bump lodash from 4.17.21 to 4.17.23 [#13628](https://github.com/statamic/cms/issues/13628) by @dependabot +- Avoid updating Bard value unless content has actually changed [#13645](https://github.com/statamic/cms/issues/13645) by @duncanmcclean + + + +## 5.73.1 (2026-01-21) + +### What's fixed +- Revert config values in forms [#13632](https://github.com/statamic/cms/issues/13632) by @jasonvarga + + + +## 5.73.0 (2026-01-21) + +### What's new +- ~Allow config values to be used in forms~ (Reverted in 5.73.1) [#11403](https://github.com/statamic/cms/issues/11403) by @FrittenKeeZ +- Allow closure in cascade content hydration [#13580](https://github.com/statamic/cms/issues/13580) by @marcorieser + +### What's fixed +- ~`AssetContainer::accessible()` should take filesystem visibility into account~ (Reverted in 5.73.2) [#13621](https://github.com/statamic/cms/issues/13621) by @duncanmcclean +- Augment appended form config fields for Antlers [#13111](https://github.com/statamic/cms/issues/13111) by @marcorieser +- Fix error from `DefaultInvalidator` when creating a nav [#13596](https://github.com/statamic/cms/issues/13596) by @duncanmcclean +- Prevent redirect when creating term via fieldtype [#13595](https://github.com/statamic/cms/issues/13595) by @duncanmcclean +- Handle null value gracefully [#13598](https://github.com/statamic/cms/issues/13598) by @aerni +- Fix existing field validation with prefixed fieldset imports [#13551](https://github.com/statamic/cms/issues/13551) by @duncanmcclean + + + +## 5.72.0 (2026-01-13) + +### What's new +- Support query scopes in navigations [#13509](https://github.com/statamic/cms/issues/13509) by @el-schneider + +### What's fixed +- Generate etag after nocache replacements [#13433](https://github.com/statamic/cms/issues/13433) by @mmodler +- Support custom validation rules for asset containers [#13459](https://github.com/statamic/cms/issues/13459) by @duncanmcclean +- Fix filterWhere with arrays [#13507](https://github.com/statamic/cms/issues/13507) by @aerni +- Dutch translations [#13532](https://github.com/statamic/cms/issues/13532) by @laurenskr + + + +## 5.71.0 (2026-01-08) + +### What's new +- PHP 8.5 Compatibility [#13112](https://github.com/statamic/cms/issues/13112) by @duncanmcclean +- Add ability to get raw array directly from Values object [#13318](https://github.com/statamic/cms/issues/13318) by @andjsch +- Make GetItemsContainingData hookable [#13302](https://github.com/statamic/cms/issues/13302) by @ryanmitchell + +### What's fixed +- Fix structure not being saved to collection [#13479](https://github.com/statamic/cms/issues/13479) by @jasonvarga +- Fix JsDriver::addToFormData call to match interface signature [#13463](https://github.com/statamic/cms/issues/13463) by @andrii-trush +- Fix add set button not overlap content on small container [#13269](https://github.com/statamic/cms/issues/13269) by @lecoa +- Terms Filter: Use `terms` fieldtype instead of `select` [#13439](https://github.com/statamic/cms/issues/13439) by @duncanmcclean +- Fix blueprint cache [#13430](https://github.com/statamic/cms/issues/13430) by @aerni +- Add `Nav::clearCachedUrls` expectation to `AddonTestCase` [#13396](https://github.com/statamic/cms/issues/13396) by @duncanmcclean +- Remove "Bulgarian Lev" from Currencies dictionary [#13414](https://github.com/statamic/cms/issues/13414) by @duncanmcclean +- Ensure field parent is set correctly [#13305](https://github.com/statamic/cms/issues/13305) by @aerni +- Invalidate nav's URL cache when collection/taxonomy/etc is created [#13297](https://github.com/statamic/cms/issues/13297) by @duncanmcclean +- Fix whereNotIn error with nulls [#13266](https://github.com/statamic/cms/issues/13266) by @jasonvarga +- Fix `eloquent:import-users` command with computed values [#13260](https://github.com/statamic/cms/issues/13260) by @duncanmcclean +- Fix page collection and mounted collection [#13250](https://github.com/statamic/cms/issues/13250) by @jasonvarga +- French translations [#13300](https://github.com/statamic/cms/issues/13300) by @ebeauchamps +- Bump validator from 13.15.20 to 13.15.22 [#13234](https://github.com/statamic/cms/issues/13234) by @dependabot +- Bump qs from 6.11.1 to 6.14.1 [#13409](https://github.com/statamic/cms/issues/13409) by @dependabot + + + +## 5.70.0 (2025-12-03) + +### What's new +- Pass original upload filename into `AssetUploaded` event [#11423](https://github.com/statamic/cms/issues/11423) by @daun +- Allow statamic URLs to use fragments or query strings [#13085](https://github.com/statamic/cms/issues/13085) by @miicah +- Add Glide Asset Cleared Event [#13004](https://github.com/statamic/cms/issues/13004) by @infabo + +### What's fixed +- Performance Optimizations for Stache and Query Operations [#12894](https://github.com/statamic/cms/issues/12894) by @hastinbe +- Avoid hardcoded nocache url in js [#13199](https://github.com/statamic/cms/issues/13199) by @JorisOrangeStudio +- Fix nocache tag not replacing element correctly [#13177](https://github.com/statamic/cms/issues/13177) by @duncanmcclean +- Date modifiers shouldn't return anything when value is empty [#13178](https://github.com/statamic/cms/issues/13178) by @duncanmcclean +- Terms fieldtype: Only show "Allow Creating" option when using stack selector [#13151](https://github.com/statamic/cms/issues/13151) by @duncanmcclean +- Ensure updated_at and updated_by is not null in TracksLastModified [#13099](https://github.com/statamic/cms/issues/13099) by @simonerd +- Correct namespace in `FakesQueriesTest` [#13029](https://github.com/statamic/cms/issues/13029) by @duncanmcclean +- Require url in nocache request [#12975](https://github.com/statamic/cms/issues/12975) by @Jade-GG +- Fix HTML entities in currency translations [#12982](https://github.com/statamic/cms/issues/12982) by @duncanmcclean +- Fix failing tests due to lowercase `utf-8` charset [#13213](https://github.com/statamic/cms/issues/13213) by @duncanmcclean +- French translations [#13136](https://github.com/statamic/cms/issues/13136) by @ebeauchamps +- Bump lowest composer constraints [#13037](https://github.com/statamic/cms/issues/13037) by @jasonvarga +- Bump js-yaml from 3.14.1 to 3.14.2 [#13097](https://github.com/statamic/cms/issues/13097) by @dependabot + + + +## 5.69.0 (2025-11-06) + +### What's new +- Support multiple sites on the search tag [#12923](https://github.com/statamic/cms/issues/12923) by @jasonvarga +- Keep selects open if multiple is enabled [#12772](https://github.com/statamic/cms/issues/12772) by @godismyjudge95 + +### What's fixed +- Update currencies dictionary [#12960](https://github.com/statamic/cms/issues/12960) by @jasonvarga +- Fix error when visiting expired LivePreview url [#12609](https://github.com/statamic/cms/issues/12609) by @waldemar-p +- Fix term reference updates after slug change [#11058](https://github.com/statamic/cms/issues/11058) by @daun +- Fix localized terms being returned incorrectly in the REST API [#11362](https://github.com/statamic/cms/issues/11362) by @duncanmcclean +- Delete .babelrc [#12939](https://github.com/statamic/cms/issues/12939) by @duncanmcclean +- Fix declarative shadow root elements inside nocache tags [#12929](https://github.com/statamic/cms/issues/12929) by @duncanmcclean +- Revert "CP nav reordering fixes" [#12926](https://github.com/statamic/cms/issues/12926) by @duncanmcclean +- Fix isset and empty on Values [#12865](https://github.com/statamic/cms/issues/12865) by @edalzell +- Fix translations for `Regards` [#12969](https://github.com/statamic/cms/issues/12969) by @marcorieser +- French translations [#12930](https://github.com/statamic/cms/issues/12930) by @ebeauchamps +- French translations [#12959](https://github.com/statamic/cms/issues/12959) by @ebeauchamps + + + +## 5.68.0 (2025-10-30) + +### What's new +- Add support for `whereHas()` etc to query builders [#8476](https://github.com/statamic/cms/issues/8476) by @ryanmitchell +- Support whereHas etc in eloquent builder [#12773](https://github.com/statamic/cms/issues/12773) by @ryanmitchell +- Add missing Stache Fake Query methods [#12885](https://github.com/statamic/cms/issues/12885) by @marcorieser +- Set etags [#11441](https://github.com/statamic/cms/issues/11441) by @indykoning + +### What's fixed +- Use attributes on term query builder tests, not comments [#12774](https://github.com/statamic/cms/issues/12774) by @ryanmitchell +- Update `updated_at` and `updated_by` when duplicating an entry [#12777](https://github.com/statamic/cms/issues/12777) by @aerni +- Fix edit form errors after change of term slug [#11056](https://github.com/statamic/cms/issues/11056) by @daun +- Fix `bard_text` modifier adding unwanted spaces [#12855](https://github.com/statamic/cms/issues/12855) by @aerni +- Detect imported fields by checking field value instead of config key [#12905](https://github.com/statamic/cms/issues/12905) by @el-schneider +- Allow Cache Tags for POST requests [#12910](https://github.com/statamic/cms/issues/12910) by @marcorieser +- Update entry URIs when collection route is changed [#11150](https://github.com/statamic/cms/issues/11150) by @duncanmcclean +- Fix field config overrides being lost when ensuring referenced fields [#12915](https://github.com/statamic/cms/issues/12915) by @el-schneider +- Bump validator from 13.9.0 to 13.15.20 [#12896](https://github.com/statamic/cms/issues/12896) by @dependabot + + + +## 5.67.0 (2025-10-16) + +### What's new +- Ability to configure blueprint storage paths [#10639](https://github.com/statamic/cms/issues/10639) by @jacksleight +- Added the ability for Protectors to allow static caching [#11542](https://github.com/statamic/cms/issues/11542) by @kingsven +- Allow stache stores to be excluded from warming and clearing [#11830](https://github.com/statamic/cms/issues/11830) by @ryanmitchell +- Provide search index name callback [#10435](https://github.com/statamic/cms/issues/10435) by @ajnsn +- Add mount field to Collection type in GraphQL [#12607](https://github.com/statamic/cms/issues/12607) by @Skullsneeze +- Throw exception if collection is queried after status [#12744](https://github.com/statamic/cms/issues/12744) by @jasonvarga +- Allow adding of GraphQL mutations [#11908](https://github.com/statamic/cms/issues/11908) by @Skullsneeze + +### What's fixed +- Normalize query string when creating nocache session [#11545](https://github.com/statamic/cms/issues/11545) by @duncanmcclean +- CP nav reordering fixes [#11054](https://github.com/statamic/cms/issues/11054) by @jesseleite +- Fix GraphQL error when field doesnt have type [#12757](https://github.com/statamic/cms/issues/12757) by @jasonvarga +- Fix Bard entry links across domains [#12694](https://github.com/statamic/cms/issues/12694) by @edalzell +- Update norwegian translations [#12714](https://github.com/statamic/cms/issues/12714) by @kjetilhole +- Updating CP search to respect selected site. [#12704](https://github.com/statamic/cms/issues/12704) by @tommulroy +- Registering a custom preset should update existing `Server` instance [#12660](https://github.com/statamic/cms/issues/12660) by @duncanmcclean + + + +## 5.66.0 (2025-10-03) + +### What's new +- Ability to register custom image presets [#12624](https://github.com/statamic/cms/issues/12624) by @duncanmcclean +- Add `isDefault` to `Site` [#12574](https://github.com/statamic/cms/issues/12574) by @edalzell +- Add whereInId to EntryRepository [#11668](https://github.com/statamic/cms/issues/11668) by @nadinengland + +### What's fixed +- Fix performance regression from 11863 [#12628](https://github.com/statamic/cms/issues/12628) by @simonworkhouse +- Ensure HandleEntrySchedule uses the minute it was dispatched [#12626](https://github.com/statamic/cms/issues/12626) by @ryanmitchell +- Select correct site when using multiple domains [#11042](https://github.com/statamic/cms/issues/11042) by @aerni +- Changing Stache index name from collection to collectionHandle [#11324](https://github.com/statamic/cms/issues/11324) by @Krzemo +- Prevent entry propagation when duplicating [#12186](https://github.com/statamic/cms/issues/12186) by @marcorieser +- Italian translations [#12642](https://github.com/statamic/cms/issues/12642) by @ivanandre +- Italian translations [#12643](https://github.com/statamic/cms/issues/12643) by @ivanandre +- Norwegian translations [#12636](https://github.com/statamic/cms/issues/12636) by @Keuto + + + +## 5.65.2 (2025-09-24) + +### What's fixed +- Prevent duplicate queries on collection structure [#12276](https://github.com/statamic/cms/issues/12276) by @ryanmitchell +- Prevent caching during live preview [#12558](https://github.com/statamic/cms/issues/12558) by @helloiamlukas +- Prevent empty cache tag when using Blade [#12567](https://github.com/statamic/cms/issues/12567) by @helloiamlukas +- Fix incorrect blueprint being resolved on localizations [#11810](https://github.com/statamic/cms/issues/11810) by @duncanmcclean +- Make asset GraphQL type always nullable [#11975](https://github.com/statamic/cms/issues/11975) by @lostgeek +- Resolving PHP Warning and PHP Notice errors [#12257](https://github.com/statamic/cms/issues/12257) by @martinoak +- Fix form submission search query [#12514](https://github.com/statamic/cms/issues/12514) by @duncanmcclean + + + +## 5.65.1 (2025-09-12) + +### What's fixed +- Fix casing of "Edit Nav item" dropdown item [#12387](https://github.com/statamic/cms/issues/12387) by @duncanmcclean +- Require `spatie/error-solutions` instead of `spatie/ignition` [#12385](https://github.com/statamic/cms/issues/12385) by @duncanmcclean +- Allow `spatie/error-solutions` v1 [#12418](https://github.com/statamic/cms/issues/12418) by @jasonvarga +- Bump axios from 1.8.2 to 1.12.0 [#12420](https://github.com/statamic/cms/issues/12420) by @dependabot + + + +## 5.65.0 (2025-09-08) + +### What's new +- Make UpdateAssetReferences overwritable [#12283](https://github.com/statamic/cms/issues/12283) by @simonerd +- Ability to opt into v6 asset folder permissions [#12060](https://github.com/statamic/cms/issues/12060) by @simonerd + +### What's fixed +- Prevent PSR-4 warnings [#12347](https://github.com/statamic/cms/issues/12347) by @duncanmcclean +- Corrects issue with unless conditions. [#12253](https://github.com/statamic/cms/issues/12253) by @JohnathonKoster +- Fix blueprint blink cache issue [#12232](https://github.com/statamic/cms/issues/12232) by @aerni +- Translate additional blueprint titles [#12242](https://github.com/statamic/cms/issues/12242) by @martyf +- Dutch translations [#12212](https://github.com/statamic/cms/issues/12212) by @robdekort +- Prevent null parse_url deprecation warning [#12197](https://github.com/statamic/cms/issues/12197) by @martinoak +- Bump brace-expansion [#12072](https://github.com/statamic/cms/issues/12072) by @dependabot + + + +## 5.64.0 (2025-08-21) + +### What's new +- Add `overlaps` and `doesnt_overlap` conditions and modifiers [#9491](https://github.com/statamic/cms/issues/9491) by @ryanmitchell +- Support whereJsonOverlaps in query builders [#11112](https://github.com/statamic/cms/issues/11112) by @ryanmitchell +- Support pluck() on eloquent query builder [#12027](https://github.com/statamic/cms/issues/12027) by @ryanmitchell +- Show path when searching assets [#12032](https://github.com/statamic/cms/issues/12032) by @duncanmcclean + +### What's fixed +- Prevent zeros being filtered out in Array fieldtype [#12039](https://github.com/statamic/cms/issues/12039) by @duncanmcclean +- Cast numbers in list fieldtype [#11970](https://github.com/statamic/cms/issues/11970) by @duncanmcclean +- Updated the Stache Store to not remove stache items that exist [#11863](https://github.com/statamic/cms/issues/11863) by @simonworkhouse +- Fix Custom Set Icons not working if path contains a dot (.) [#11866](https://github.com/statamic/cms/issues/11866) by @andjsch +- Antlers: Corrects parser error with shorthand array syntax [#12031](https://github.com/statamic/cms/issues/12031) by @JohnathonKoster +- Dutch translations [#12059](https://github.com/statamic/cms/issues/12059) by @Jade-GG +- Fix windows test [#12048](https://github.com/statamic/cms/issues/12048) by @duncanmcclean + + + +## 5.63.0 (2025-08-06) + +### What's new +- Support multiple defaults for checkboxes fieldtype [#12021](https://github.com/statamic/cms/issues/12021) by @godismyjudge95 + +### What's fixed +- Ensure orderByDesc uses column() function in eloquent query builder [#12022](https://github.com/statamic/cms/issues/12022) by @ryanmitchell +- Fix nav padding, only apply to last ul [#12024](https://github.com/statamic/cms/issues/12024) by @jackmcdade + + + +## 5.62.0 (2025-08-04) + +### What's new +- Pass form_config to email view [#11417](https://github.com/statamic/cms/issues/11417) by @andjsch +- Add user profile form tabs and sections [#11836](https://github.com/statamic/cms/issues/11836) by @AtmoFX +- Ability to explicitly disable text fieldtype focus [#12011](https://github.com/statamic/cms/issues/12011) by @jasonvarga + +### What's fixed +- Fix entry redirect to @child fails if no child exists [#11953](https://github.com/statamic/cms/issues/11953) by @MartinSpicka +- Cast toggle fieldtype queryable value to boolean [#12019](https://github.com/statamic/cms/issues/12019) by @jasonvarga +- Fix docs link in template fieldtype [#11990](https://github.com/statamic/cms/issues/11990) by @duncanmcclean +- Fix incorrect boolean in eloquent whereNotBetween [#12005](https://github.com/statamic/cms/issues/12005) by @ryanmitchell +- Fix assets:generate-presets command stdout [#12015](https://github.com/statamic/cms/issues/12015) by @0kyn +- Fix asset styling in link fieldtype [#12016](https://github.com/statamic/cms/issues/12016) by @Jamesking56 +- Apply bottom padding to main nav [#12012](https://github.com/statamic/cms/issues/12012) by @daun + + + +## 5.61.0 (2025-07-25) + +### What's new +- Allow static warm to use insecure by default with config key [#11978](https://github.com/statamic/cms/issues/11978) by @macaws + +### What's fixed +- Escape redirect in user tag [#11999](https://github.com/statamic/cms/issues/11999) by @jasonvarga +- Fix AddonTestCase path for Windows [#11994](https://github.com/statamic/cms/issues/11994) by @godismyjudge95 +- Bump form-data from 4.0.0 to 4.0.4 [#11979](https://github.com/statamic/cms/issues/11979) by @dependabot +- Loosen up assertions in `ViteTest` [#11985](https://github.com/statamic/cms/issues/11985) by @duncanmcclean +- Update security contact info [#11996](https://github.com/statamic/cms/issues/11996) by @duncanmcclean + + + +## 5.60.0 (2025-07-15) + +### What's new +- Add command to clear asset_meta and asset_container_contents caches [#11960](https://github.com/statamic/cms/issues/11960) by @ryanmitchell + +### What's fixed +- Addon Manifest improvements [#11948](https://github.com/statamic/cms/issues/11948) by @duncanmcclean +- Licensing fixes [#11950](https://github.com/statamic/cms/issues/11950) by @duncanmcclean +- Allow classes to extend Markdown Parser [#11946](https://github.com/statamic/cms/issues/11946) by @JohnathonKoster + + + +## 5.59.0 (2025-07-10) + +### What's new +- Add optional fallback for missing keys in `{{ trans }}` tag [#11944](https://github.com/statamic/cms/issues/11944) by @daun +- `resolve` modifier [#11890](https://github.com/statamic/cms/issues/11890) by @marcorieser +- Allow fetching children of other entries in `{{ children }}` tag [#11922](https://github.com/statamic/cms/issues/11922) by @daun +- Prompt to update search indexes when installing starter kits [#11924](https://github.com/statamic/cms/issues/11924) by @jesseleite + +### What's fixed +- Use `app.locale` as fallback when there is no explicit site locale [#11939](https://github.com/statamic/cms/issues/11939) by @CasEbb +- Check if sometimes rule is present before adding nonNull type [#11917](https://github.com/statamic/cms/issues/11917) by @TheBnl +- Fix issue around spaces in git paths [#11933](https://github.com/statamic/cms/issues/11933) by @jesseleite +- Fix active state for nav items with implicit children [#11937](https://github.com/statamic/cms/issues/11937) by @jesseleite +- Fix visibility of custom nav items for users w/ limited permissions [#11930](https://github.com/statamic/cms/issues/11930) by @jesseleite +- Fix group fieldtype child field validation rules when using {this} within a replicator/grid [#11931](https://github.com/statamic/cms/issues/11931) by @martyf +- Relax strict type check in `Tree::move()` [#11927](https://github.com/statamic/cms/issues/11927) by @duncanmcclean +- Prevent group fieldtype from filtering out `false` values [#11928](https://github.com/statamic/cms/issues/11928) by @duncanmcclean +- Class "DB" not found issue [#11911](https://github.com/statamic/cms/issues/11911) by @leganz +- Fix casing on dropdown item [#11907](https://github.com/statamic/cms/issues/11907) by @duncanmcclean +- German translations [#11903](https://github.com/statamic/cms/issues/11903) by @helloDanuk + + + +## 5.58.1 (2025-06-25) + +### What's fixed +- Fix Overflow buttons preview [#11891](https://github.com/statamic/cms/issues/11891) by @marcorieser +- Revert detect recursion when augmenting Entries (#11854) [#11894](https://github.com/statamic/cms/issues/11894) by @JohnathonKoster +- Add entry serialization test [#11900](https://github.com/statamic/cms/issues/11900) by @jasonvarga + + + +## 5.58.0 (2025-06-20) + +### What's new +- Detect recursion when augmenting Entries [#11854](https://github.com/statamic/cms/issues/11854) by @JohnathonKoster +- Add `hasField` method to `Fieldset` [#11882](https://github.com/statamic/cms/issues/11882) by @duncanmcclean +- Estonian translations [#11886](https://github.com/statamic/cms/issues/11886) by @karlromets + +### What's fixed +- Render markdown after antlers when smartypants is enabled [#11814](https://github.com/statamic/cms/issues/11814) by @ryanmitchell +- Fix read-only state of roles and groups fields [#11867](https://github.com/statamic/cms/issues/11867) by @aerni +- Fix files not being removed after cache has been cleared [#11873](https://github.com/statamic/cms/issues/11873) by @indykoning +- Ensure nav blueprint graphql types are registered [#11881](https://github.com/statamic/cms/issues/11881) by @ryanmitchell +- Fix issues with Blade nav tag compiler [#11872](https://github.com/statamic/cms/issues/11872) by @JohnathonKoster +- Ensure propagating entries respects saveQuietly [#11875](https://github.com/statamic/cms/issues/11875) by @ryanmitchell +- Fix authorization error when creating globals [#11883](https://github.com/statamic/cms/issues/11883) by @duncanmcclean +- Fixes typo [#11876](https://github.com/statamic/cms/issues/11876) by @adampatterson +- Updated `AddonServiceProvider::shouldBootRootItems()` to support trailing slashes [#11861](https://github.com/statamic/cms/issues/11861) by @simonworkhouse +- Prevent null in strtolower() [#11869](https://github.com/statamic/cms/issues/11869) by @martinoak +- Ensure Glide treats asset urls starting with the app url as internal assets [#11839](https://github.com/statamic/cms/issues/11839) by @marcorieser +- Remove single quote in Asset upload [#11858](https://github.com/statamic/cms/issues/11858) by @adampatterson + + + +## 5.57.0 (2025-06-04) + +### What's new +- Added the option to add renderers to markdown parsers [#11827](https://github.com/statamic/cms/issues/11827) by @CapitaineToinon +- Add renderer methods to `Markdown` facade docblock [#11845](https://github.com/statamic/cms/issues/11845) by @duncanmcclean +- Add `not_in` parameter to Assets tag [#11820](https://github.com/statamic/cms/issues/11820) by @nopticon +- Added `X-Statamic-Uncacheable` header to prevent responses being statically cached [#11817](https://github.com/statamic/cms/issues/11817) by @macaws + +### What's fixed +- Throw 404 response when OAuth provider doesn't exist [#11844](https://github.com/statamic/cms/issues/11844) by @duncanmcclean +- Fix edition check in outpost [#11843](https://github.com/statamic/cms/issues/11843) by @duncanmcclean +- Updated Statamic references in language files [#11835](https://github.com/statamic/cms/issues/11835) by @tommulroy +- Fix create/edit CP nav descendants not properly triggering active status [#11832](https://github.com/statamic/cms/issues/11832) by @jesseleite +- Fix editability of nav items without content reference [#11822](https://github.com/statamic/cms/issues/11822) by @duncanmcclean +- Entries fieldtype: Only show "Allow Creating" option when using stack selector [#11816](https://github.com/statamic/cms/issues/11816) by @duncanmcclean +- French translations [#11826](https://github.com/statamic/cms/issues/11826) by @ebeauchamps +- Added null check for fieldActions within replicator [#11828](https://github.com/statamic/cms/issues/11828) by @martyf +- Perform null check on data in video fieldtype [#11821](https://github.com/statamic/cms/issues/11821) by @martyf +- Added checks for `Closure` instances instead of `is_callable` inside `Route::statamic(...)` [#11809](https://github.com/statamic/cms/issues/11809) by @JohnathonKoster +- Further increase trackDirtyState timeout [#11811](https://github.com/statamic/cms/issues/11811) by @simonerd + + + +## 5.56.0 (2025-05-20) + +### What's new +- Add `moveQuietly` method to `Asset` class [#11804](https://github.com/statamic/cms/issues/11804) by @duncanmcclean +- Add --header option to static warm command [#11763](https://github.com/statamic/cms/issues/11763) by @ChristianPraiss + +### What's fixed +- Fix values being wrapped in arrays causing multiple selected options [#11630](https://github.com/statamic/cms/issues/11630) by @simonworkhouse +- Hide read only and computed fields in user creation wizard [#11635](https://github.com/statamic/cms/issues/11635) by @duncanmcclean +- Fix storing submissions of forms with 'files' fieldtypes even when disabled [#11794](https://github.com/statamic/cms/issues/11794) by @andjsch +- Corrects Antlers error logging with PHP nodes [#11800](https://github.com/statamic/cms/issues/11800) by @JohnathonKoster +- Fix facade PhpDocs for better understanding by Laravel Idea [#11798](https://github.com/statamic/cms/issues/11798) by @adelf +- Correct issue with nested noparse and partials [#11801](https://github.com/statamic/cms/issues/11801) by @JohnathonKoster +- Prepare value & operator before passing to Eloquent Query Builder [#11805](https://github.com/statamic/cms/issues/11805) by @duncanmcclean + + + +## 5.55.0 (2025-05-14) + +### What's new +- Middleware to redirect absolute domains ending in dot [#11782](https://github.com/statamic/cms/issues/11782) by @indykoning +- Add `increment`/`decrement` methods to `ContainsData` [#11786](https://github.com/statamic/cms/issues/11786) by @duncanmcclean +- Update GraphiQL [#11780](https://github.com/statamic/cms/issues/11780) by @duncanmcclean +- Allow selecting entries from different sites within the link fieldtype [#10546](https://github.com/statamic/cms/issues/10546) by @justkidding96 +- Ability to select entries from all sites from Bard link [#11768](https://github.com/statamic/cms/issues/11768) by @edalzell + +### What's fixed +- Ensure asset validation rules are returned as strings to GraphQL [#11781](https://github.com/statamic/cms/issues/11781) by @ryanmitchell +- Asset validation rules as string in GraphQL, part 2 [#11790](https://github.com/statamic/cms/issues/11790) by @jasonvarga +- Fix Term filter on entry listing not working when limiting to 1 term [#11735](https://github.com/statamic/cms/issues/11735) by @liucf +- Fix filtering group fieldtype null values [#11788](https://github.com/statamic/cms/issues/11788) by @jacksleight +- Clone internal data collections [#11777](https://github.com/statamic/cms/issues/11777) by @jacksleight +- Fix creating terms in non-default sites [#11746](https://github.com/statamic/cms/issues/11746) by @duncanmcclean +- Ensure `null` values are filtered out in dictionary field config [#11773](https://github.com/statamic/cms/issues/11773) by @duncanmcclean +- Use deep copy of set's data in bard field [#11766](https://github.com/statamic/cms/issues/11766) by @faltjo +- Dutch translations [#11783](https://github.com/statamic/cms/issues/11783) by @rinusvandam +- PHPUnit: Use `#[Test]` attribute instead of `/** @test */` [#11767](https://github.com/statamic/cms/issues/11767) by @duncanmcclean + + + +## 5.54.0 (2025-05-02) ### What's new -- The fork has been updated to [v5.42.0](https://github.com/statamic/cms/releases/tag/v5.42.0). +- Add `firstOrfail`, `firstOr`, `sole` and `exists` methods to base query builder [#9976](https://github.com/statamic/cms/issues/9976) by @duncanmcclean +- Allow custom nocache db connection [#11716](https://github.com/statamic/cms/issues/11716) by @macaws +- Make Live Preview force reload JS modules optional [#11715](https://github.com/statamic/cms/issues/11715) by @eminos + +### What's fixed +- Fix ensured author field when sidebar has empty section [#11747](https://github.com/statamic/cms/issues/11747) by @duncanmcclean +- Improve error handling in entries fieldtype [#11754](https://github.com/statamic/cms/issues/11754) by @duncanmcclean +- Hide "Edit" button on relationship fieldtypes when user is missing permissions [#11748](https://github.com/statamic/cms/issues/11748) by @duncanmcclean +- Fix wrong taxonomies count on multiple sites [#11741](https://github.com/statamic/cms/issues/11741) by @liucf +- GraphQL should return float fieldtype values as floats [#11742](https://github.com/statamic/cms/issues/11742) by @ryanmitchell +- Fix pop-up position of for Bard link inconsistent [#11739](https://github.com/statamic/cms/issues/11739) by @liucf +- Only apply the published filter when not in preview mode [#11652](https://github.com/statamic/cms/issues/11652) by @TheBnl +- Cleanup roles after running `SitesTest@gets_authorized_sites` [#11738](https://github.com/statamic/cms/issues/11738) by @duncanmcclean +- Update nocache map on response [#11650](https://github.com/statamic/cms/issues/11650) by @indykoning +- Fix Add truncate class to flex container in LinkFieldtype [#11689](https://github.com/statamic/cms/issues/11689) by @liucf +- Fix appended form config fields when user locale differs from app locale [#11704](https://github.com/statamic/cms/issues/11704) by @duncanmcclean +- Fix dirty state on preferences edit form [#11655](https://github.com/statamic/cms/issues/11655) by @duncanmcclean +- Fix static caching invalidation for multi-sites [#10669](https://github.com/statamic/cms/issues/10669) by @duncanmcclean +- Safer check on parent tag [#11717](https://github.com/statamic/cms/issues/11717) by @macaws +- Dutch translations [#11730](https://github.com/statamic/cms/issues/11730) by @jeroenpeters1986 +- Bump @babel/runtime from 7.21.0 to 7.27.0 [#11726](https://github.com/statamic/cms/issues/11726) by @dependabot +- Update caniuse-lite [#11725](https://github.com/statamic/cms/issues/11725) by @jasonvarga + + + +## 5.53.1 (2025-04-17) + +### What's fixed +- Fix validation of date field nested in a replicator [#11692](https://github.com/statamic/cms/issues/11692) by @liucf +- Fix collection index search when using a non-dedicated search index [#11711](https://github.com/statamic/cms/issues/11711) by @simonerd +- Handle translation issues in collection widget [#11693](https://github.com/statamic/cms/issues/11693) by @daun +- Remove `templates`/`themes` methods from `CpController` [#11706](https://github.com/statamic/cms/issues/11706) by @duncanmcclean +- Ensure asset references are updated correctly [#11705](https://github.com/statamic/cms/issues/11705) by @duncanmcclean + + + +## 5.53.0 (2025-04-10) + +### What's new +- Allow dynamic counter names in `increment` tag [#11671](https://github.com/statamic/cms/issues/11671) by @daun +- Expose field conditions from GraphQL API [#11607](https://github.com/statamic/cms/issues/11607) by @duncanmcclean +- Add Edit Blueprint links to create publish forms [#11625](https://github.com/statamic/cms/issues/11625) by @jacksleight + +### What's fixed +- Fix icon selector in nav builder [#11656](https://github.com/statamic/cms/issues/11656) by @duncanmcclean +- Fix docblock in AssetContainer facade [#11658](https://github.com/statamic/cms/issues/11658) by @duncanmcclean +- Revert "Escape start_page Preference to avoid invalid Redirect" [#11651](https://github.com/statamic/cms/issues/11651) by @duncanmcclean +- Restore error message on asset upload server errors [#11642](https://github.com/statamic/cms/issues/11642) by @daun +- Fix dates in localizations when duplicating entries [#11361](https://github.com/statamic/cms/issues/11361) by @duncanmcclean +- Use deep copy of objects in replicator set [#11621](https://github.com/statamic/cms/issues/11621) by @faltjo +- French translations [#11622](https://github.com/statamic/cms/issues/11622) by @ebeauchamps +- Dutch translations [#11686](https://github.com/statamic/cms/issues/11686) by @rogerthat-be + + + +## 5.52.0 (2025-03-25) + +### What's new +- Support query scopes in GraphQL [#11533](https://github.com/statamic/cms/issues/11533) by @ryanmitchell +- Support query scopes in REST API [#10893](https://github.com/statamic/cms/issues/10893) by @ryanmitchell +- Added option to exclude asset containers from generating presets [#11613](https://github.com/statamic/cms/issues/11613) by @kevinmeijer97 +- Handle collection instances in `first` modifier [#11608](https://github.com/statamic/cms/issues/11608) by @marcorieser +- Autoload scopes from `Query/Scopes` and `Query/Scopes/Filters` [#11601](https://github.com/statamic/cms/issues/11601) by @duncanmcclean +- Allow revisions path to be configurable with an .env [#11594](https://github.com/statamic/cms/issues/11594) by @ryanmitchell +- Ability to exclude parents from nav tag [#11597](https://github.com/statamic/cms/issues/11597) by @jasonvarga +- Allow a custom asset meta cache store to be specified [#11512](https://github.com/statamic/cms/issues/11512) by @ryanmitchell + +### What's fixed +- Fix icon fieldtype in nav builder [#11618](https://github.com/statamic/cms/issues/11618) by @jasonvarga +- Escape start_page Preference to avoid invalid Redirect [#11616](https://github.com/statamic/cms/issues/11616) by @naabster +- Fix issue with localization files named like fieldset handles [#11603](https://github.com/statamic/cms/issues/11603) by @ChristianPraiss +- Ensure toasts fired in an AssetUploaded event are delivered to front end [#11592](https://github.com/statamic/cms/issues/11592) by @ryanmitchell +- Prevent null data from being saved to eloquent users [#11591](https://github.com/statamic/cms/issues/11591) by @ryanmitchell +- Change default value of update command selection [#11581](https://github.com/statamic/cms/issues/11581) by @Jade-GG +- Adjust relationship field typeahead no-options message [#11590](https://github.com/statamic/cms/issues/11590) by @jasonvarga +- Bump axios from 1.7.4 to 1.8.2 [#11604](https://github.com/statamic/cms/issues/11604) by @dependabot +- Bump tj-actions/changed-files [#11602](https://github.com/statamic/cms/issues/11602) by @dependabot +- Spanish translations [#11617](https://github.com/statamic/cms/issues/11617) by @nopticon + + + +## 5.51.0 (2025-03-17) + +### What's new +- Allow passing computed fields via an associative array [#11528](https://github.com/statamic/cms/issues/11528) by @godismyjudge95 +- Enable unselecting fieldtypes for forms [#11559](https://github.com/statamic/cms/issues/11559) by @godismyjudge95 + +### What's fixed +- Fix implied route views [#11570](https://github.com/statamic/cms/issues/11570) by @duncanmcclean +- When NavPageInterface has no blueprint fields return something [#11537](https://github.com/statamic/cms/issues/11537) by @ryanmitchell +- Password reset action should use custom password reset notification [#11571](https://github.com/statamic/cms/issues/11571) by @duncanmcclean +- Fix escaped braces in concurrent requests not getting replaced [#11583](https://github.com/statamic/cms/issues/11583) by @o1y +- Fix control panel crashes when titles share name with existing translation file [#11578](https://github.com/statamic/cms/issues/11578) by @daun +- Fix carbon deprecation warning [#11561](https://github.com/statamic/cms/issues/11561) by @jasonvarga +- Fix icon fieldtype [#11560](https://github.com/statamic/cms/issues/11560) by @jasonvarga + + + +## 5.50.0 (2025-03-10) + +### What's new +- Support `as` on nav tag [#11522](https://github.com/statamic/cms/issues/11522) by @ryanmitchell + +### What's fixed +- Icon fieldtype performance [#11523](https://github.com/statamic/cms/issues/11523) by @jasonvarga +- Fix password protection on 404 pages [#11544](https://github.com/statamic/cms/issues/11544) by @duncanmcclean +- Return validation error when AllowedFile is not an UploadedFile [#11535](https://github.com/statamic/cms/issues/11535) by @ryanmitchell +- Italian translations [#11538](https://github.com/statamic/cms/issues/11538) by @ivanandre +- French translations [#11519](https://github.com/statamic/cms/issues/11519) by @ebeauchamps +- Use ubuntu-latest in GitHub Actions workflow [#11526](https://github.com/statamic/cms/issues/11526) by @jasonvarga + + + +## 5.49.1 (2025-02-27) + +### What's fixed +- Query for entry origin within the same collection [#11514](https://github.com/statamic/cms/issues/11514) by @jasonvarga +- Improve validation message when handle starts with a number [#11511](https://github.com/statamic/cms/issues/11511) by @duncanmcclean +- Fix target `.git` repo handling when exporting starter kit with `--clear` [#11509](https://github.com/statamic/cms/issues/11509) by @jesseleite +- Make active toolbar buttons of Bard more visible in dark mode [#11405](https://github.com/statamic/cms/issues/11405) by @carstenjaksch +- Alternate Laravel 12 token repository fix [#11505](https://github.com/statamic/cms/issues/11505) by @jasonvarga + + + +## 5.49.0 (2025-02-25) + +### What's new +- Laravel 12 support [#11433](https://github.com/statamic/cms/issues/11433) by @duncanmcclean + +### What's fixed +- Asset Container returns relative url for same site [#11372](https://github.com/statamic/cms/issues/11372) by @marcorieser + + + +## 5.48.1 (2025-02-25) + +### What's fixed +- Fix session expiry component [#11501](https://github.com/statamic/cms/issues/11501) by @jasonvarga +- Include port in CSP for Live Preview [#11498](https://github.com/statamic/cms/issues/11498) by @dmxmo +- Remove duplicate translation line from `translator` command [#11494](https://github.com/statamic/cms/issues/11494) by @duncanmcclean +- Fix carbon integer casting [#11496](https://github.com/statamic/cms/issues/11496) by @jasonvarga +- Only show spatie/fork prompt when pcntl extension is loaded [#11493](https://github.com/statamic/cms/issues/11493) by @duncanmcclean +- French translations [#11488](https://github.com/statamic/cms/issues/11488) by @ebeauchamps + + + +## 5.48.0 (2025-02-21) + +### What's new +- Carbon 3 support [#11348](https://github.com/statamic/cms/issues/11348) by @duncanmcclean +- Allow custom asset container contents cache store [#11481](https://github.com/statamic/cms/issues/11481) by @ryanmitchell +- Add support for `$view` parameter closure with `Route::statamic()` [#11452](https://github.com/statamic/cms/issues/11452) by @jesseleite +- Add Unlink All action to relationship fields [#11475](https://github.com/statamic/cms/issues/11475) by @jacksleight + +### What's fixed +- Fix handle dimensions of rotated videos [#11479](https://github.com/statamic/cms/issues/11479) by @grischaerbe +- Entries Fieldtype: Hide tree view when using query scopes [#11484](https://github.com/statamic/cms/issues/11484) by @duncanmcclean +- Fix issue with localization files named like handles [#11482](https://github.com/statamic/cms/issues/11482) by @ChristianPraiss +- Fix cannot use paginate/limit error when one is null [#11478](https://github.com/statamic/cms/issues/11478) by @jacksleight +- Fix primitive type hint handling in Statamic routes [#11476](https://github.com/statamic/cms/issues/11476) by @jesseleite + + + +## 5.47.0 (2025-02-18) + +### What's new +- Support Sections in form GraphQL queries [#11466](https://github.com/statamic/cms/issues/11466) by @ryanmitchell +- Add empty/not empty field filters [#11389](https://github.com/statamic/cms/issues/11389) by @jacksleight +- Support arguments in user can/cant tags [#11407](https://github.com/statamic/cms/issues/11407) by @jacksleight +- Add hook into the `multisite` command [#11458](https://github.com/statamic/cms/issues/11458) by @duncanmcclean +- Set the proper CSP headers for multi-domain iframe [#11447](https://github.com/statamic/cms/issues/11447) by @edalzell +- Allow custom render callback for overridden exceptions [#11408](https://github.com/statamic/cms/issues/11408) by @FrittenKeeZ +- Support the Group fieldtype in DataReferenceUpdater [#11410](https://github.com/statamic/cms/issues/11410) by @duncanmcclean + +### What's fixed +- Fix emptiness check on Value properties [#11402](https://github.com/statamic/cms/issues/11402) by @godismyjudge95 +- Change revision whereKey to return a collection [#11463](https://github.com/statamic/cms/issues/11463) by @ryanmitchell +- If nav:from references an invalid entry, ensure nothing is returned [#11464](https://github.com/statamic/cms/issues/11464) by @ryanmitchell +- Improve performance of the data reference updaters [#11442](https://github.com/statamic/cms/issues/11442) by @duncanmcclean +- Fix search results missing search_snippets [#11450](https://github.com/statamic/cms/issues/11450) by @ivang76 +- Ensure consistent order of `get_content` results [#11429](https://github.com/statamic/cms/issues/11429) by @daun +- Fixes empty asset alt element in AssetsFieldtype [#11460](https://github.com/statamic/cms/issues/11460) by @PatrickJunod +- Fix issue with AssetsFieldtype upload controls hidden on @sm size [#11459](https://github.com/statamic/cms/issues/11459) by @PatrickJunod +- Fix test state issues around sites cache [#11455](https://github.com/statamic/cms/issues/11455) by @jesseleite +- Fix InstallEloquentDriver command [#11425](https://github.com/statamic/cms/issues/11425) by @aerni +- Handle extra translation files in breadcrumbs [#11422](https://github.com/statamic/cms/issues/11422) by @jasonvarga +- Select specific repositories for Eloquent Driver install command [#11381](https://github.com/statamic/cms/issues/11381) by @kevinmeijer97 +- Adjust video fit in asset editor [#11414](https://github.com/statamic/cms/issues/11414) by @daun +- Dutch translations [#11448](https://github.com/statamic/cms/issues/11448) by @daronspence +- Use `ubuntu-latest` in GitHub Actions workflows [#11443](https://github.com/statamic/cms/issues/11443) by @duncanmcclean + + + +## 5.46.1 (2025-02-04) + +### What's fixed +- Fix search:results tag when offset and paginate are set [#11386](https://github.com/statamic/cms/issues/11386) by @nopticon +- Live Preview: Allow changing the position of "Responsive" device option [#11404](https://github.com/statamic/cms/issues/11404) by @duncanmcclean +- Fix additional url segments matching taxonomy terms [#11383](https://github.com/statamic/cms/issues/11383) by @jasonvarga +- Use constructor property promotion in events [#11380](https://github.com/statamic/cms/issues/11380) by @duncanmcclean +- Fix "Curaçao" item in countries dictionary [#11395](https://github.com/statamic/cms/issues/11395) by @duncanmcclean +- Remove duplicate strings from translation files [#11400](https://github.com/statamic/cms/issues/11400) by @j3ll3yfi5h +- German translations [#11399](https://github.com/statamic/cms/issues/11399) by @helloDanuk +- French translations [#11397](https://github.com/statamic/cms/issues/11397) by @ebeauchamps + + + +## 5.46.0 (2025-01-22) + +### What's new +- Add empty/not empty filters for replicator, bard and grid [#11354](https://github.com/statamic/cms/issues/11354) by @jacksleight +- Page children as value for field conditions [#11368](https://github.com/statamic/cms/issues/11368) by @heidkaemper +- Allow addons cache path to be set by an environment variable [#11365](https://github.com/statamic/cms/issues/11365) by @ryanmitchell + +### What's fixed +- Fix error with disallowed words in Comb search driver [#11336](https://github.com/statamic/cms/issues/11336) by @duncanmcclean +- Fixed ordering search results by origin value [#11334](https://github.com/statamic/cms/issues/11334) by @duncanmcclean +- Fix case insensitive Comb search for UTF-8 characters [#11363](https://github.com/statamic/cms/issues/11363) by @heidkaemper +- Translate name in user group fieldtype [#11343](https://github.com/statamic/cms/issues/11343) by @duncanmcclean +- Fix UI bugs in Safari 18.2 [#11335](https://github.com/statamic/cms/issues/11335) by @marcorieser + + + +## 5.45.2 (2025-01-21) + +### What's fixed +- Revert "Allow form fields view to be rendered with single tag" [#11374](https://github.com/statamic/cms/issues/11374) by @duncanmcclean +- Remove `type` attribute in nocache replacer [#11373](https://github.com/statamic/cms/issues/11373) by @marcorieser +- Fix deprecation warning from regex operator [#11337](https://github.com/statamic/cms/issues/11337) by @duncanmcclean +- Fix bug report link in Contribution Guide [#11367](https://github.com/statamic/cms/issues/11367) by @duncanmcclean +- Fix bard undefined href error [#11351](https://github.com/statamic/cms/issues/11351) by @jacksleight +- Suppress “packing” git message [#11326](https://github.com/statamic/cms/issues/11326) by @edalzell + + + +## 5.45.1 (2025-01-07) + +### What's fixed +- Throw better exception when asset isn't found [#11321](https://github.com/statamic/cms/issues/11321) by @edalzell +- Add url friendly base64 en/decoding for Glide [#11299](https://github.com/statamic/cms/issues/11299) by @marcorieser +- Update make:fieldtype console message [#11309](https://github.com/statamic/cms/issues/11309) by @Technobabble17 +- Make set button label clickable [#11313](https://github.com/statamic/cms/issues/11313) by @carstenjaksch +- French translations [#11297](https://github.com/statamic/cms/issues/11297) by @ebeauchamps +- Fix markdown test [#11315](https://github.com/statamic/cms/issues/11315) by @jasonvarga + + + +## 5.45.0 (2024-12-20) + +### What's new +- Allow form fields view to be rendered with single tag [#11293](https://github.com/statamic/cms/issues/11293) by @jasonvarga +- Improve form field accessibility [#10993](https://github.com/statamic/cms/issues/10993) by @daun + +### What's fixed +- Prevent duplicate roles & groups [#11270](https://github.com/statamic/cms/issues/11270) by @duncanmcclean +- Improve error handling when using entry publish actions [#11289](https://github.com/statamic/cms/issues/11289) by @ryanmitchell + + + +## 5.44.0 (2024-12-18) + +### What's new +- Static warm command will recheck whether page is cached when using queue [#11273](https://github.com/statamic/cms/issues/11273) by @arthurperton +- Add `--max-requests` option to the static warm command [#11278](https://github.com/statamic/cms/issues/11278) by @arthurperton +- Add formStackSize option for inline publish form stacks [#11274](https://github.com/statamic/cms/issues/11274) by @duncanmcclean + +### What's fixed +- Fix addon service provider autoloading [#11285](https://github.com/statamic/cms/issues/11285) by @jasonvarga +- Fix CP thumbnail placeholder [#11279](https://github.com/statamic/cms/issues/11279) by @duncanmcclean +- Fix replicator preview for Group fields [#11280](https://github.com/statamic/cms/issues/11280) by @duncanmcclean + + + +## 5.43.2 (2024-12-18) + +### What's fixed +- Fix static properties in addon providers [#11283](https://github.com/statamic/cms/issues/11283) by @jasonvarga + + + +## 5.43.1 (2024-12-18) + +### What's fixed +- Fix autoload error on Windows [#11282](https://github.com/statamic/cms/issues/11282) by @jasonvarga +- Improve starter kit installer error handling [#11281](https://github.com/statamic/cms/issues/11281) by @jesseleite + + + +## 5.43.0 (2024-12-17) + +### What's new +- Add filters from collection/taxonomy list to breadcrumb back link [#11243](https://github.com/statamic/cms/issues/11243) by @florianbrinkmann +- OAuth: option not to create or update user during authentication [#10853](https://github.com/statamic/cms/issues/10853) by @miloslavkostir +- Add some options to the static warm command to limit the number of requests [#11258](https://github.com/statamic/cms/issues/11258) by @arthurperton +- Table Fieldtype: Add `max_columns` and `max_rows` options [#11224](https://github.com/statamic/cms/issues/11224) by @duncanmcclean + +### What's fixed +- Handle hidden fields on nav page edit form [#11272](https://github.com/statamic/cms/issues/11272) by @duncanmcclean +- Support Laravel Prompts 0.3+ [#11267](https://github.com/statamic/cms/issues/11267) by @duncanmcclean +- Update `embed_url` and `trackable_embed_url` modifiers to be valid with additional query strings [#11265](https://github.com/statamic/cms/issues/11265) by @martyf +- Fix term filter on entries listing [#11268](https://github.com/statamic/cms/issues/11268) by @duncanmcclean +- Prevent "Set Alt" button from running Replace Asset action prematurely [#11269](https://github.com/statamic/cms/issues/11269) by @duncanmcclean +- Fix autoloading when addon has multiple service providers [#11128](https://github.com/statamic/cms/issues/11128) by @duncanmcclean +- Fix ButtonGroup not showing active state if value are numbers [#10916](https://github.com/statamic/cms/issues/10916) by @morhi +- Support glide urls with URL params [#11003](https://github.com/statamic/cms/issues/11003) by @ryanmitchell +- Throw 404 on collection routes if taxonomy isn’t assigned to collection [#10438](https://github.com/statamic/cms/issues/10438) by @aerni +- Move bard source button into field actions [#11250](https://github.com/statamic/cms/issues/11250) by @jasonvarga +- Fix collection title format when using translations [#11248](https://github.com/statamic/cms/issues/11248) by @ajnsn +- Bump nanoid from 3.3.6 to 3.3.8 [#11251](https://github.com/statamic/cms/issues/11251) by @dependabot + + + +## 5.42.1 (2024-12-11) + +### What's fixed +- Fix asset upload concurrency on folder upload [#11225](https://github.com/statamic/cms/issues/11225) by @daun +- Fix subdirectory autodiscovery on Windows [#11246](https://github.com/statamic/cms/issues/11246) by @jasonvarga +- Fix type error in `HandleEntrySchedule` job [#11244](https://github.com/statamic/cms/issues/11244) by @duncanmcclean +- Fix `no_results` cascade [#11234](https://github.com/statamic/cms/issues/11234) by @JohnathonKoster + ## 5.42.0 (2024-12-05) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdf80d6c65f..e4b018ba314 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ Next, please search through the [open issues](https://github.com/statamic/cms/is If you _do_ find a similar issue, upvote it by adding a :thumbsup: [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Only leave a comment if you have relevant information to add. -If no one has filed the issue yet, feel free to [submit a new one](https://github.com/statamic/cms/issues/new). Please include a clear description of the issue, follow along with the issue template, and provide and as much relevant information as possible. Code examples demonstrating the issue are the best way to ensure a timely solution to the issue. +If no one has filed the issue yet, feel free to [submit a new one](https://github.com/statamic/cms/issues/new?template=bug_report.yml). Please include a clear description of the issue, follow along with the issue template, and provide and as much relevant information as possible. Code examples demonstrating the issue are the best way to ensure a timely solution to the issue. ### Feature Requests diff --git a/SECURITY.md b/SECURITY.md index 16f6b62596e..bf1e938ed1e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ If you discover a security vulnerability in Statamic, please review the followin ## Guidelines While working to identify potential security vulnerabilities in Statamic, we ask that you: -- **Privately** share any issues that you discover with us via statamic.com/support as soon as possible. +- **Privately** share any issues that you discover with us via support@statamic.com as soon as possible. - Give us a reasonable amount of time to address any reported issues before publicizing them. - Only report issues that are in scope. - Provide a quality report with precise explanations and concrete attack scenarios. diff --git a/composer.json b/composer.json index 956020f7d0a..7c459f23a8c 100644 --- a/composer.json +++ b/composer.json @@ -14,27 +14,29 @@ "composer/semver": "^3.4", "guzzlehttp/guzzle": "^6.3 || ^7.0", "james-heinrich/getid3": "^1.9.21", - "laravel/framework": "^10.40 || ^11.34", - "laravel/prompts": "^0.1.16", + "laravel/framework": "^10.48.29 || ^11.44.1 || ^12.40.0", + "laravel/prompts": "^0.1.16 || ^0.2.0 || ^0.3.0", "league/commonmark": "^2.2", "league/csv": "^9.0", "league/glide": "^2.3", "maennchen/zipstream-php": "^3.1", "michelf/php-smartypants": "^1.8.1", - "nesbot/carbon": "^2.62.1", + "nesbot/carbon": "^2.62.1 || ^3.0", "pixelfear/composer-dist-plugin": "^0.1.4", - "rebing/graphql-laravel": "^9.7", - "rhukster/dom-sanitizer": "^1.0.6", + "rebing/graphql-laravel": "^9.8", + "rhukster/dom-sanitizer": "^1.0.7", "spatie/blink": "^1.3", - "spatie/ignition": "^1.15", + "spatie/error-solutions": "^1.0 || ^2.0", "statamic/stringy": "^3.1.2", - "stillat/blade-parser": "^1.10.1", + "stillat/blade-parser": "^1.10.1 || ^2.0", "symfony/lock": "^6.4", "symfony/var-exporter": "^6.0", "symfony/yaml": "^6.0 || ^7.0", "ueberdosis/tiptap-php": "^1.4", "voku/portable-ascii": "^2.0.2", - "wilderborn/partyline": "^1.0" + "wilderborn/partyline": "^1.0", + "symfony/http-foundation": "^6.4.29 || ^7.3.7", + "symfony/process": "^6.4.14 || ^7.1.7" }, "require-dev": { "doctrine/dbal": "^3.6", @@ -42,9 +44,10 @@ "google/cloud-translate": "^1.6", "laravel/pint": "1.16.0", "mockery/mockery": "^1.6.10", - "orchestra/testbench": "^8.14 || ^9.2", - "phpunit/phpunit": "^10.5.35", - "spatie/laravel-ray": "^1.37" + "orchestra/testbench": "^8.36 || ^9.15 || ^10.8", + "phpunit/phpunit": "^10.5.35 || ^11.5.3", + "sebastian/recursion-context": "^5.0.1 || ^6.0.3", + "spatie/laravel-ray": "^1.42" }, "config": { "optimize-autoloader": true, @@ -83,6 +86,13 @@ "src/helpers.php", "src/namespaced_helpers.php", "src/View/Blade/helpers.php" + ], + "exclude-from-classmap": [ + "tests/Auth/Eloquent/__migrations__/**", + "tests/StarterKits/__fixtures__/**", + "tests/Translator/__fixtures__/**", + "tests/Console/Kernel.php", + "src/Console/Please/app-kernel.php" ] }, "autoload-dev": { diff --git a/config/assets.php b/config/assets.php index 2ed388c0320..6d1967ef542 100644 --- a/config/assets.php +++ b/config/assets.php @@ -76,7 +76,7 @@ |-------------------------------------------------------------------------- | | You may define global defaults for all manipulation parameters, such as - | quality, format, and sharpness. These can and will be be overwritten + | quality, format, and sharpness. These can and will be overwritten | on the tag parameter level as well as the preset level. | */ @@ -223,4 +223,16 @@ 'svg_sanitization_on_upload' => true, + /* + |-------------------------------------------------------------------------- + | Use V6 Permissions + |-------------------------------------------------------------------------- + | + | This allows you to opt in to the asset permissions that will become the + | default behavior in Statamic 6. This will be removed in Statamic 6. + | + */ + + 'v6_permissions' => false, + ]; diff --git a/config/graphql.php b/config/graphql.php index 132e71f3555..02f8258ce31 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -42,6 +42,20 @@ // ], + /* + |-------------------------------------------------------------------------- + | Mutations + |-------------------------------------------------------------------------- + | + | Here you may list mutations to be added to the Statamic schema. + | + | https://statamic.dev/graphql#custom-mutations + */ + + 'mutations' => [ + // + ], + /* |-------------------------------------------------------------------------- | Middleware diff --git a/config/live_preview.php b/config/live_preview.php index 2464d516b2b..0c1b16ed609 100644 --- a/config/live_preview.php +++ b/config/live_preview.php @@ -33,4 +33,16 @@ // ], + /* + |-------------------------------------------------------------------------- + | Force Reload Javascript Modules + |-------------------------------------------------------------------------- + | + | To force a reload, Live Preview appends a timestamp to the URL on + | script tags of type 'module'. You may disable this behavior here. + | + */ + + 'force_reload_js_modules' => true, + ]; diff --git a/config/oauth.php b/config/oauth.php index 2b5b931b1ab..d7e3fd2488e 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -15,6 +15,44 @@ 'callback' => 'oauth/{provider}/callback', ], + /* + |-------------------------------------------------------------------------- + | Create User + |-------------------------------------------------------------------------- + | + | Whether or not a user account should be created upon authentication + | with an OAuth provider. If disabled, a user account will be need + | to be explicitly created ahead of time. + | + */ + + 'create_user' => true, + + /* + |-------------------------------------------------------------------------- + | Merge User Data + |-------------------------------------------------------------------------- + | + | When authenticating with an OAuth provider, the user data returned + | such as their name will be merged with the existing user account. + | + */ + + 'merge_user_data' => true, + + /* + |-------------------------------------------------------------------------- + | Unauthorized Redirect + |-------------------------------------------------------------------------- + | + | This controls where the user is taken after authenticating with + | an OAuth provider but their account is unauthorized. This may + | happen when the create_user option has been set to false. + | + */ + + 'unauthorized_redirect' => null, + /* |-------------------------------------------------------------------------- | Remember Me diff --git a/config/revisions.php b/config/revisions.php index 7ae76f05562..a755339bb14 100644 --- a/config/revisions.php +++ b/config/revisions.php @@ -25,6 +25,6 @@ | */ - 'path' => storage_path('statamic/revisions'), + 'path' => env('STATAMIC_REVISIONS_PATH', storage_path('statamic/revisions')), ]; diff --git a/config/stache.php b/config/stache.php index 2d2ec830d36..fa739311ece 100644 --- a/config/stache.php +++ b/config/stache.php @@ -140,4 +140,27 @@ 'timeout' => 30, ], + /* + |-------------------------------------------------------------------------- + | Warming Optimization + |-------------------------------------------------------------------------- + | + | These options control performance optimizations during Stache warming. + | + */ + + 'warming' => [ + // Enable parallel store processing for faster warming on multi-core systems + 'parallel_processing' => env('STATAMIC_STACHE_PARALLEL_WARMING', false), + + // Maximum number of parallel processes (0 = auto-detect CPU cores) + 'max_processes' => env('STATAMIC_STACHE_MAX_PROCESSES', 0), + + // Minimum number of stores required to enable parallel processing + 'min_stores_for_parallel' => env('STATAMIC_STACHE_MIN_STORES_PARALLEL', 3), + + // Concurrency driver: 'process', 'fork', or 'sync' + 'concurrency_driver' => env('STATAMIC_STACHE_CONCURRENCY_DRIVER', 'process'), + ], + ]; diff --git a/config/static_caching.php b/config/static_caching.php index e190b9729d6..758c7f633b9 100644 --- a/config/static_caching.php +++ b/config/static_caching.php @@ -125,6 +125,8 @@ 'nocache' => 'cache', + 'nocache_db_connection' => env('STATAMIC_NOCACHE_DB_CONNECTION'), + 'nocache_js_position' => 'body', /* @@ -148,7 +150,8 @@ |-------------------------------------------------------------------------- | | Here you may define the queue name and connection - | that will be used when warming the static cache. + | that will be used when warming the static cache and + | optionally set the "--insecure" flag by default. | */ @@ -156,6 +159,8 @@ 'warm_queue_connection' => env('STATAMIC_STATIC_WARM_QUEUE_CONNECTION'), + 'warm_insecure' => env('STATAMIC_STATIC_WARM_INSECURE', false), + /* |-------------------------------------------------------------------------- | Shared Error Pages diff --git a/config/system.php b/config/system.php index fc04ee4b024..87a5bfef245 100644 --- a/config/system.php +++ b/config/system.php @@ -45,6 +45,28 @@ 'addons_path' => base_path('addons'), + /* + |-------------------------------------------------------------------------- + | Blueprints Path + |-------------------------------------------------------------------------- + | + | Where your blueprint YAML files are stored. + | + */ + + 'blueprints_path' => resource_path('blueprints'), + + /* + |-------------------------------------------------------------------------- + | Fieldsets Path + |-------------------------------------------------------------------------- + | + | Where your fieldset YAML files are stored. + | + */ + + 'fieldsets_path' => resource_path('fieldsets'), + /* |-------------------------------------------------------------------------- | Send the Powered-By Header diff --git a/package-lock.json b/package-lock.json index cb4fd1f3bcb..609d0a4ed5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,10 +42,11 @@ "@tiptap/vue-2": "^2.0.2", "alpinejs": "^3.1.1", "autosize": "~3.0.12", - "axios": "^1.7.4", + "axios": "^1.12.0", "body-scroll-lock": "^4.0.0-beta.0", "codemirror": "^5.58.2", "cookies-js": "^1.2.2", + "dompurify": "^3.3.1", "floating-vue": "^1.0.0-beta.19", "fuse.js": "^7.0.0", "highlight.js": "^11.7.0", @@ -57,11 +58,11 @@ "moment": "^2.29.4", "mousetrap": "~1.5.3", "nprogress": "^0.2.0", - "pdfobject": "^2.2.7", + "pdfjs-dist": "^5.4.624", "portal-vue": "^1.5.1", "pretty": "^2.0.0", "pusher-js": "^4.4.0", - "qs": "^6.9.7", + "qs": "^6.14.1", "read-time-estimate": "0.0.2", "resize-observer-polyfill": "^1.5.1", "speakingurl": "^14.0.1", @@ -71,7 +72,7 @@ "uniqid": "^5.2.0", "upload": "^1.3.2", "v-calendar": "^2.3.0", - "validator": "^13.7.0", + "validator": "^13.15.22", "vue": "^2.7.14", "vue-clickaway": "~2.2.2", "vue-draggable-nested-tree": "^2.3.0-beta.1", @@ -1697,11 +1698,12 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", - "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -2833,6 +2835,312 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", + "integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.95", + "@napi-rs/canvas-darwin-arm64": "0.1.95", + "@napi-rs/canvas-darwin-x64": "0.1.95", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.95", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.95", + "@napi-rs/canvas-linux-arm64-musl": "0.1.95", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.95", + "@napi-rs/canvas-linux-x64-gnu": "0.1.95", + "@napi-rs/canvas-linux-x64-musl": "0.1.95", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.95", + "@napi-rs/canvas-win32-x64-msvc": "0.1.95" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.95.tgz", + "integrity": "sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.95.tgz", + "integrity": "sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.95.tgz", + "integrity": "sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.95.tgz", + "integrity": "sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.95.tgz", + "integrity": "sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.95.tgz", + "integrity": "sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.95.tgz", + "integrity": "sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.95.tgz", + "integrity": "sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.95.tgz", + "integrity": "sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.95.tgz", + "integrity": "sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.95", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.95.tgz", + "integrity": "sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3660,6 +3968,13 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -3833,6 +4148,32 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3892,12 +4233,13 @@ "integrity": "sha512-xGFj5jTV4up6+fxRwtnAWiCIx/5N0tEnFn5rdhAkK1Lq2mliLMuGJgP5Bf4phck3sHGYrVKpYwugfJ61MSz9nA==" }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -4126,10 +4468,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4190,13 +4533,30 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/call-bind": { + "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4230,9 +4590,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001472", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001472.tgz", - "integrity": "sha512-xWC/0+hHHQgj3/vrKYY0AAzeIUgr7L9wlELIcAvZdDUHlhL/kNxMdnQLOSOQfP8R51ZzPhmHdyMkI0MMpmxCfg==", + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", "dev": true, "funding": [ { @@ -4247,7 +4607,26 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } }, "node_modules/chalk": { "version": "2.4.2", @@ -4311,6 +4690,18 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -4382,6 +4773,18 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4426,6 +4829,15 @@ "proto-list": "~1.2.1" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -4673,6 +5085,21 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -4702,6 +5129,27 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4782,6 +5230,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", @@ -4812,6 +5269,20 @@ "helper-js": "^1.3.7" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/editorconfig": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", @@ -4892,6 +5363,51 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -5275,12 +5791,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -5308,6 +5827,45 @@ "url": "https://www.patreon.com/infusion" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5328,9 +5886,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/fuse.js": { "version": "7.0.0", @@ -5341,6 +5903,30 @@ "node": ">=10" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5360,13 +5946,24 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5381,6 +5978,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -5434,6 +6044,18 @@ "node": ">=4" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5444,6 +6066,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -5461,14 +6084,51 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, "node_modules/helper-js": { @@ -7459,9 +8119,10 @@ } }, "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -7501,10 +8162,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -7691,9 +8353,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.castarray": { "version": "4.4.0", @@ -7828,6 +8491,15 @@ "resolved": "https://registry.npmjs.org/marked-plaintext/-/marked-plaintext-0.0.2.tgz", "integrity": "sha512-6u/EfbyqTV8p9CqxFUOK3RUGo1KGULVUaiacKRjb+9VzXpGYYQlgff76xjlfxOGYpsDkzYg+l28CtGd86I6c0w==" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -7895,6 +8567,21 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7907,6 +8594,73 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -7937,10 +8691,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -7966,12 +8729,73 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -8031,6 +8855,22 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -8072,9 +8912,13 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8239,10 +9083,18 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/pdfobject": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/pdfobject/-/pdfobject-2.2.8.tgz", - "integrity": "sha512-dB/soWNMLtVGHfXERXnAtsKm0XwC6lyGVYegQcZxL4rw07rNOKvawc9kddBzlGr7TbiBZuGf4Drb3kyRbTf/QA==" + "node_modules/pdfjs-dist": { + "version": "5.4.624", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", + "integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.88", + "node-readable-to-web-readable-stream": "^0.4.2" + } }, "node_modules/picocolors": { "version": "1.0.0", @@ -8761,11 +9613,12 @@ } }, "node_modules/qs": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz", - "integrity": "sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -8832,6 +9685,23 @@ "resolved": "https://registry.npmjs.org/read-time-estimate/-/read-time-estimate-0.0.2.tgz", "integrity": "sha512-0CG+Xmg7jafu078m5umjD7t7SpODuHwVKhGlXKHfQemEniWNDyouFAEYzMFUGilTr3fyvXB1ogNUKg0s8ybmlg==" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8863,9 +9733,10 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" }, "node_modules/regenerator-transform": { "version": "0.15.1", @@ -8991,6 +9862,25 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "3.29.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", @@ -9082,6 +9972,15 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -9104,13 +10003,72 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9127,6 +10085,43 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9203,6 +10198,18 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9449,6 +10456,36 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9746,9 +10783,10 @@ } }, "node_modules/validator": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", - "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", + "version": "13.15.22", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.22.tgz", + "integrity": "sha512-uT/YQjiyLJP7HSrv/dPZqK9L28xf8hsNca01HSz1dfmI0DgMfjopp1rO/z13NeGF1tVystF0Ejx3y4rUKPw+bQ==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -10017,6 +11055,18 @@ "which": "bin/which" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", diff --git a/package.json b/package.json index 7abe2d80ab5..c3c1d1748d8 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,11 @@ "@tiptap/vue-2": "^2.0.2", "alpinejs": "^3.1.1", "autosize": "~3.0.12", - "axios": "^1.7.4", + "axios": "^1.12.0", "body-scroll-lock": "^4.0.0-beta.0", "codemirror": "^5.58.2", "cookies-js": "^1.2.2", + "dompurify": "^3.3.1", "floating-vue": "^1.0.0-beta.19", "fuse.js": "^7.0.0", "highlight.js": "^11.7.0", @@ -62,11 +63,11 @@ "moment": "^2.29.4", "mousetrap": "~1.5.3", "nprogress": "^0.2.0", - "pdfobject": "^2.2.7", + "pdfjs-dist": "^5.4.624", "portal-vue": "^1.5.1", "pretty": "^2.0.0", "pusher-js": "^4.4.0", - "qs": "^6.9.7", + "qs": "^6.14.1", "read-time-estimate": "0.0.2", "resize-observer-polyfill": "^1.5.1", "speakingurl": "^14.0.1", @@ -76,7 +77,7 @@ "uniqid": "^5.2.0", "upload": "^1.3.2", "v-calendar": "^2.3.0", - "validator": "^13.7.0", + "validator": "^13.15.22", "vue": "^2.7.14", "vue-clickaway": "~2.2.2", "vue-draggable-nested-tree": "^2.3.0-beta.1", diff --git a/resources/css/components/fieldtypes/assets.css b/resources/css/components/fieldtypes/assets.css index 3ba77ac311f..b7c5191876e 100644 --- a/resources/css/components/fieldtypes/assets.css +++ b/resources/css/components/fieldtypes/assets.css @@ -7,14 +7,14 @@ } .assets-fieldtype .assets-fieldtype-picker { - @apply flex items-center px-4 py-2 bg-gray-200 dark:bg-dark-650 border dark:border-dark-900 rounded; + @apply flex flex-wrap items-center px-4 py-2 bg-gray-200 dark:bg-dark-650 border dark:border-dark-900 rounded; &.is-expanded { @apply border-b-0 rounded-b-none; } .asset-upload-control { - @apply mt-2 @sm:rtl:mr-4 @sm:ltr:ml-4 @sm:mt-0 text-xs text-gray-600 leading-tight; + @apply text-xs text-gray-600 leading-tight; } .upload-text-button { @@ -30,7 +30,7 @@ } .assets-fieldtype .asset-upload-control { - @apply hidden @xs:inline-block; + @apply inline-block; } diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css index be75f358ebd..24ea624a1c9 100644 --- a/resources/css/components/fieldtypes/bard.css +++ b/resources/css/components/fieldtypes/bard.css @@ -49,11 +49,11 @@ } .bard-toolbar-button:hover { - @apply bg-gray-200 dark:bg-dark-700; + @apply bg-gray-200 dark:bg-dark-650; } .bard-toolbar-button.active { - @apply bg-gray-300 dark:bg-dark-800 text-black dark:text-dark-100; + @apply bg-gray-300 dark:bg-dark-600 text-black dark:text-white; } .bard-toolbar-button:focus { @@ -163,7 +163,7 @@ } .bard-add-set-button { - @apply flex items-center justify-center absolute rtl:-right-4 ltr:-left-4 top-[-6px] z-1; + @apply flex items-center justify-center absolute rtl:-right-6 ltr:-left-6 @lg/bard:rtl:-right-4 @lg/bard:ltr:-left-4 top-[-6px] z-1; } .bard-footer-toolbar { diff --git a/resources/css/components/nav-main.css b/resources/css/components/nav-main.css index 66e2d63e9a4..cd74efb73ea 100644 --- a/resources/css/components/nav-main.css +++ b/resources/css/components/nav-main.css @@ -3,10 +3,14 @@ ========================================================================== */ .nav-main { - @apply hidden select-none bg-white shadow h-screen absolute rtl:right-0 ltr:left-0 overflow-scroll w-56; + @apply hidden select-none bg-white shadow absolute rtl:right-0 ltr:left-0 overflow-scroll w-56; @apply dark:bg-dark-800 dark:shadow-dark; transition: all .3s; + height: calc(100dvh - 52px); + .showing-license-banner & { + height: calc(100dvh - 105px); + } h6 { @apply mt-6; } @@ -14,6 +18,10 @@ @apply list-none p-0 mt-0; } + .nav-main-inner > ul:last-child { + @apply pb-8; + } + li { @apply p-0 text-sm; margin-top: 6px; @@ -70,10 +78,6 @@ @screen md { .nav-main { @apply fixed flex bg-transparent shadow-none overflow-auto rtl:border-l ltr:border-r dark:border-dark-900; - height: calc(100% - 52px); - .showing-license-banner & { - height: calc(100% - 105px); - } .nav-closed & { @apply border-0 shadow-none; diff --git a/resources/css/elements/buttons.css b/resources/css/elements/buttons.css index fd69a9d6648..75006e7964a 100644 --- a/resources/css/elements/buttons.css +++ b/resources/css/elements/buttons.css @@ -34,12 +34,14 @@ button { .btn, .btn-default { @apply text-gray-800 dark:text-dark-150 shadow-button; background: linear-gradient(180deg, #fff, #f9fafb); + background-clip: padding-box; border: 1px solid #D3DDE7; border-bottom: 1px solid #c4cdd6; box-shadow: inset 0 1px 0 0 #fff, 0 1px 0 0 rgba(0, 0, 0,.05), 0 2px 1px 0 theme(colors.gray.600 / .15), 0 0 0 0 transparent; .dark & { background: linear-gradient(180deg, theme(colors.dark.550), theme(colors.dark.600)); + background-clip: padding-box; border-color: theme(colors.dark.700); box-shadow: inset 0 1px 0 0 theme(colors.dark.300), 0 1px 0 0 rgba(200, 200, 200,.05), 0 2px 1px 0 theme(colors.dark.900 / .15), 0 0 0 0 transparent; } @@ -47,10 +49,12 @@ button { &:hover:not(:disabled), &:active:not(:disabled) { @apply text-gray-800 dark:text-dark-150; background: linear-gradient(180deg, #f9fafb, #f4f6f8); + background-clip: padding-box; border-bottom: 1px solid #BFC7D0; .dark & { background: linear-gradient(180deg, theme(colors.dark.600), theme(colors.dark.700)); + background-clip: padding-box; border-color: theme(colors.dark.800); } } @@ -78,7 +82,7 @@ button { /* Primary action button */ .btn-primary { - @apply text-white bg-gradient-to-b from-blue-500 to-blue-600 dark:from-dark-blue-100 dark:to-dark-blue-150 border border-blue-700 dark:border-blue-900 border-b-blue-800 dark:border-b-dark-blue-200 !important; + @apply text-white bg-gradient-to-b from-blue-500 to-blue-600 dark:from-dark-blue-100 dark:to-dark-blue-150 bg-clip-padding border border-blue-700 dark:border-blue-900 border-b-blue-800 dark:border-b-dark-blue-200 !important; box-shadow: inset 0 1px 0 0 theme('colors.blue.400'), 0 1px 0 0 rgba(25,30,35,.05), 0 3px 2px -1px theme(colors.blue.900 / .15), 0 0 0 0 transparent; &:hover:not(:disabled), :active:not(:disabled) { @@ -93,11 +97,11 @@ button { /* Danger/delete button */ .btn-danger { - @apply text-white bg-gradient-to-b from-red-500 to-red-600 border border-red-600 border-b-red-900; + @apply text-white bg-gradient-to-b from-red-500 to-red-600 bg-clip-padding border border-red-600 border-b-red-900; box-shadow: inset 0 1px 0 0 theme('colors.red.400'), 0 1px 0 0 rgba(0, 0, 0,.05), 0 3px 2px -1px theme(colors.red.900 / .15), 0 0 0 0 transparent; &:hover:not(:disabled), &:active:not(:disabled) { - @apply bg-gradient-to-b from-red-600 to-red-700 border border-red-700 border-b-red-900; + @apply bg-gradient-to-b from-red-600 to-red-700 bg-clip-padding border border-red-700 border-b-red-900; } &:disabled { @@ -159,6 +163,7 @@ button { .btn-round { @apply rounded-full flex items-center text-center p-0; background: linear-gradient(180deg, #fff, #f9fafb); + background-clip: padding-box; border: 1px solid #c4cdd6; box-shadow: 0 1px 0 0 rgba(25,30,35,.05); height: 32px; @@ -166,15 +171,18 @@ button { .dark & { background: linear-gradient(180deg, theme(colors.dark.500), theme(colors.dark.550)); + background-clip: padding-box; border-color: theme(colors.dark.400); } &:hover:not(:disabled), &:active:not(:disabled) { background: linear-gradient(180deg, #f9fafb, #f4f6f8); + background-clip: padding-box; border-color: #c4cdd5; .dark & { background: linear-gradient(180deg, theme(colors.dark.550), theme(colors.dark.600)); + background-clip: padding-box; border-color: theme(colors.dark.500); } } diff --git a/resources/css/elements/forms.css b/resources/css/elements/forms.css index 49780b6b6fb..f4dcbda8c45 100644 --- a/resources/css/elements/forms.css +++ b/resources/css/elements/forms.css @@ -93,6 +93,7 @@ input.input-text-minimal:read-only, .input-group-prepend, .input-group-append, .input-group-item { @apply rtl:rounded-r ltr:rounded-l px-2 border dark:border-dark-900 text-sm text-gray-800 dark:text-dark-150 select-none; background: linear-gradient(180deg, #fff, #f9fafb); + background-clip: padding-box; border: 1px solid #c4cdd6; box-shadow: 0 1px 0 0 rgba(25,30,35,.05); height: 2.375rem; @@ -100,6 +101,7 @@ input.input-text-minimal:read-only, .dark & { background: linear-gradient(180deg, theme(colors.dark.500), theme(colors.dark.600)); + background-clip: padding-box; border-color: theme(colors.dark.900); box-shadow: 0 1px 0 0 rgba(0,0,0,.05); } @@ -133,10 +135,12 @@ input.input-text-minimal:read-only, button.input-group-append:hover:not(:disabled), button.input-group-append:active:not(:disabled) { background: linear-gradient(180deg, #f9fafb, #f4f6f8); + background-clip: padding-box; border-color: #c4cdd5; .dark & { background: linear-gradient(180deg, theme(colors.dark.550), theme(colors.dark.600)); + background-clip: padding-box; border-color: theme(colors.dark.900); } } @@ -213,6 +217,7 @@ input.input-text-minimal:read-only, @apply select-none rounded leading-normal align-middle whitespace-nowrap appearance-none subpixel-antialiased; background: linear-gradient(180deg, #fff, #f9fafb); + background-clip: padding-box; border: 1px solid #c4cdd6; height: 2.375rem; /* 38px */ letter-spacing: -0.01em; @@ -220,15 +225,18 @@ input.input-text-minimal:read-only, .dark & { background: linear-gradient(180deg, theme(colors.dark.500), theme(colors.dark.600)); + background-clip: padding-box; border-color: theme(colors.dark.800); } &:hover:not(:disabled), &:active:not(:disabled) { background: linear-gradient(180deg, #f9fafb, #f4f6f8); + background-clip: padding-box; border-color: #c4cdd5; .dark & { background: linear-gradient(180deg, theme(colors.dark.700), theme(colors.dark.750)); + background-clip: padding-box; border-color: theme(colors.dark.900); } } diff --git a/resources/css/vendors/vue-select.css b/resources/css/vendors/vue-select.css index 4f2222c6370..0a39ea9862a 100644 --- a/resources/css/vendors/vue-select.css +++ b/resources/css/vendors/vue-select.css @@ -104,7 +104,7 @@ .vs__open-indicator { @apply clickable; - @apply flex items-center rounded-e px-2 text-sm shrink-0 h-full border-e-0 dark:border-dark-800; + @apply flex items-center rounded-e px-2 text-sm shrink-0 h-full border-e-0 bg-clip-padding dark:border-dark-800; /* height: 2.375rem; */ } diff --git a/resources/js/bootstrap/globals.js b/resources/js/bootstrap/globals.js index aca3362eb72..2f95f4bbe69 100644 --- a/resources/js/bootstrap/globals.js +++ b/resources/js/bootstrap/globals.js @@ -1,4 +1,5 @@ import { marked } from 'marked'; +import DOMPurify from 'dompurify'; import { translate, translateChoice } from '../translations/translator'; import uid from 'uniqid'; import PreviewHtml from '../components/fieldtypes/replicator/PreviewHtml'; @@ -68,7 +69,7 @@ export function tailwind_width_class(width) { } export function markdown(value) { - return marked(value); + return DOMPurify.sanitize(marked(value)); }; export function __(string, replacements) { diff --git a/resources/js/components/AddonDetails.vue b/resources/js/components/AddonDetails.vue index a455ae0eab8..fb486a139d8 100644 --- a/resources/js/components/AddonDetails.vue +++ b/resources/js/components/AddonDetails.vue @@ -50,6 +50,7 @@ + + diff --git a/resources/js/components/assets/Uploader.vue b/resources/js/components/assets/Uploader.vue index 816c97f956c..cb704d712e1 100644 --- a/resources/js/components/assets/Uploader.vue +++ b/resources/js/components/assets/Uploader.vue @@ -68,6 +68,15 @@ export default { }, + computed: { + + activeUploads() { + return this.uploads.filter(u => u.instance.state === 'started'); + } + + }, + + methods: { browse() { @@ -230,6 +239,9 @@ export default { }, processUploadQueue() { + // If we're already uploading, don't start another + if (this.activeUploads.length) return; + // Make sure we're not grabbing a running or failed upload const upload = this.uploads.find(u => u.instance.state === 'new' && !u.errorMessage); if (!upload) return; @@ -248,12 +260,16 @@ export default { response.status === 200 ? this.handleUploadSuccess(id, json) : this.handleUploadError(id, response.status, json); + + this.processUploadQueue(); }); }, handleUploadSuccess(id, response) { this.$emit('upload-complete', response.data, this.uploads); this.uploads.splice(this.findUploadIndex(id), 1); + + this.handleToasts(response._toasts ?? []); }, handleUploadError(id, status, response) { @@ -270,12 +286,19 @@ export default { msg = Object.values(response.errors)[0][0]; // Get first validation message. } } + + this.handleToasts(response?._toasts ?? []); + upload.errorMessage = msg; upload.errorStatus = status; this.$emit('error', upload, this.uploads); this.processUploadQueue(); }, + handleToasts(toasts) { + toasts.forEach(toast => Statamic.$toast[toast.type](toast.message, {duration: toast.duration})); + }, + retry(id, args) { let file = this.findUpload(id).instance.form.get('file'); this.addFile(file, args); diff --git a/resources/js/components/blueprints/Section.vue b/resources/js/components/blueprints/Section.vue index 54e3d3291e0..ecf6d3f2e1b 100644 --- a/resources/js/components/blueprints/Section.vue +++ b/resources/js/components/blueprints/Section.vue @@ -44,9 +44,9 @@ - +
diff --git a/resources/js/components/blueprints/Tab.vue b/resources/js/components/blueprints/Tab.vue index 95cf01b7164..e875b5377e3 100644 --- a/resources/js/components/blueprints/Tab.vue +++ b/resources/js/components/blueprints/Tab.vue @@ -60,9 +60,9 @@ - +
diff --git a/resources/js/components/configure/Tabs.vue b/resources/js/components/configure/Tabs.vue index 55e9bf5d13f..2dcc7ce4aa9 100644 --- a/resources/js/components/configure/Tabs.vue +++ b/resources/js/components/configure/Tabs.vue @@ -4,7 +4,7 @@

-

+

diff --git a/resources/js/components/fieldtypes/IconFieldtype.vue b/resources/js/components/fieldtypes/IconFieldtype.vue index 272d5276013..b678b075bab 100644 --- a/resources/js/components/fieldtypes/IconFieldtype.vue +++ b/resources/js/components/fieldtypes/IconFieldtype.vue @@ -1,6 +1,7 @@ + + - - - - - - - -
Loading...
- + + + +
+
Loading…
+
- + \ No newline at end of file diff --git a/resources/views/navigation/show.blade.php b/resources/views/navigation/show.blade.php index 277d06323dd..3e3130743ff 100644 --- a/resources/views/navigation/show.blade.php +++ b/resources/views/navigation/show.blade.php @@ -14,6 +14,7 @@ site="{{ $site }}" :sites="{{ json_encode($sites) }}" :collections="{{ json_encode($collections) }}" + :entry-query-scopes='@json($collections_query_scopes)' :max-depth="{{ $nav->maxDepth() ?? 'Infinity' }}" :expects-root="{{ $str::bool($expectsRoot) }}" :blueprint="{{ json_encode($blueprint) }}" diff --git a/resources/views/partials/licensing-alerts.blade.php b/resources/views/partials/licensing-alerts.blade.php index f5ff86034a4..8e8af0baa04 100644 --- a/resources/views/partials/licensing-alerts.blade.php +++ b/resources/views/partials/licensing-alerts.blade.php @@ -1,7 +1,9 @@ @php use function Statamic\trans as __; @endphp @inject('licenses', 'Statamic\Licensing\LicenseManager') -@if ($licenses->requestFailed()) +@if ($licenses->outpostIsOffline()) + {{-- Do nothing. --}} +@elseif ($licenses->requestFailed())
@if ($licenses->usingLicenseKeyFile()) diff --git a/resources/views/terms/create.blade.php b/resources/views/terms/create.blade.php index 4e2d9d5796c..cb0a0917b2c 100644 --- a/resources/views/terms/create.blade.php +++ b/resources/views/terms/create.blade.php @@ -1,3 +1,4 @@ +@inject('str', 'Statamic\Support\Str') @extends('statamic::layout') @section('title', $breadcrumbs->title($taxonomyCreateLabel)) @section('wrapper_class', 'max-w-3xl') @@ -14,6 +15,7 @@ :published="{{ json_encode($published) }}" :localizations="{{ json_encode($localizations) }}" site="{{ $locale }}" + :can-edit-blueprint="{{ $str::bool($user->can('configure fields')) }}" create-another-url="{{ cp_route('taxonomies.terms.create', [$taxonomy, $locale]) }}" listing-url="{{ cp_route('taxonomies.show', $taxonomy) }}" :preview-targets="{{ json_encode($previewTargets) }}" diff --git a/resources/views/widgets/collection.blade.php b/resources/views/widgets/collection.blade.php index 6da2090228f..78e5b177666 100644 --- a/resources/views/widgets/collection.blade.php +++ b/resources/views/widgets/collection.blade.php @@ -1,4 +1,5 @@ @php use Statamic\Facades\Site; @endphp +@php use function Statamic\trans as __; @endphp
diff --git a/routes/cp.php b/routes/cp.php index 34dac73184d..15c26304afc 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -50,6 +50,7 @@ use Statamic\Http\Controllers\CP\Fields\MetaController; use Statamic\Http\Controllers\CP\Fieldtypes\DictionaryFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\FilesFieldtypeController; +use Statamic\Http\Controllers\CP\Fieldtypes\IconFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController; use Statamic\Http\Controllers\CP\Forms\ActionController as FormActionController; @@ -228,7 +229,6 @@ Route::resource('asset-containers', AssetContainersController::class); Route::post('asset-containers/{asset_container}/folders', [FoldersController::class, 'store']); - Route::patch('asset-containers/{asset_container}/folders/{path}', [FoldersController::class, 'update'])->where('path', '.*'); Route::get('asset-containers/{asset_container}/blueprint', [AssetContainerBlueprintController::class, 'edit'])->name('asset-containers.blueprint.edit'); Route::patch('asset-containers/{asset_container}/blueprint', [AssetContainerBlueprintController::class, 'update'])->name('asset-containers.blueprint.update'); Route::post('assets/actions', [AssetActionController::class, 'run'])->name('assets.actions.run'); @@ -318,6 +318,7 @@ Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview'); Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); + Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); }); Route::group(['prefix' => 'field-action-modal'], function () { diff --git a/routes/web.php b/routes/web.php index 5f2755063fa..801705361eb 100755 --- a/routes/web.php +++ b/routes/web.php @@ -55,7 +55,8 @@ Route::prefix(config('statamic.routes.action')) ->post('nocache', NoCacheController::class) ->middleware(NoCacheLocalize::class) - ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']); + ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']) + ->name('nocache'); if (OAuth::enabled()) { Route::get(config('statamic.oauth.routes.login'), [OAuthController::class, 'redirectToProvider'])->name('oauth.login'); diff --git a/src/API/AbstractCacher.php b/src/API/AbstractCacher.php index 12f964c6db4..65297750598 100644 --- a/src/API/AbstractCacher.php +++ b/src/API/AbstractCacher.php @@ -55,6 +55,6 @@ protected function normalizeKey($key) */ public function cacheExpiry() { - return Carbon::now()->addMinutes($this->config('expiry')); + return Carbon::now()->addMinutes((int) $this->config('expiry')); } } diff --git a/src/API/FilterAuthorizer.php b/src/API/FilterAuthorizer.php index 2a7f6e90078..c66f77dce6f 100644 --- a/src/API/FilterAuthorizer.php +++ b/src/API/FilterAuthorizer.php @@ -6,6 +6,8 @@ class FilterAuthorizer extends AbstractAuthorizer { + protected $configKey = 'allowed_filters'; + /** * Get allowed filters for resource. * @@ -17,7 +19,7 @@ class FilterAuthorizer extends AbstractAuthorizer */ public function allowedForResource($configFile, $queriedResource) { - $config = config("statamic.{$configFile}.resources.{$queriedResource}.allowed_filters"); + $config = config("statamic.{$configFile}.resources.{$queriedResource}.{$this->configKey}"); // Use explicitly configured `allowed_filters` array, otherwise no filters should be allowed. return is_array($config) @@ -54,7 +56,7 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine if any of our queried resources have filters explicitly disabled. $disabled = $resources - ->filter(fn ($resource) => Arr::get($config, "{$resource}.allowed_filters") === false) + ->filter(fn ($resource) => Arr::get($config, "{$resource}.{$this->configKey}") === false) ->isNotEmpty(); // If any queried resource is explicitly disabled, then no filters should be allowed. @@ -65,10 +67,10 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine `allowed_filters` by filtering out any that don't appear in all of them. // A resource named `*` will apply to all enabled resources at once. return $resources - ->map(fn ($resource) => $config[$resource]['allowed_filters'] ?? []) + ->map(fn ($resource) => $config[$resource][$this->configKey] ?? []) ->reduce(function ($carry, $allowedFilters) use ($config) { - return $carry->intersect($allowedFilters)->merge($config['*']['allowed_filters'] ?? []); - }, collect($config[$resources[0] ?? '']['allowed_filters'] ?? [])) + return $carry->intersect($allowedFilters)->merge($config['*'][$this->configKey] ?? []); + }, collect($config[$resources[0] ?? ''][$this->configKey] ?? [])) ->all(); } } diff --git a/src/API/QueryScopeAuthorizer.php b/src/API/QueryScopeAuthorizer.php new file mode 100644 index 00000000000..91b5c985046 --- /dev/null +++ b/src/API/QueryScopeAuthorizer.php @@ -0,0 +1,8 @@ +suspendPropagation($original); + $originalParent = $this->getEntryParentFromStructure($original); [$title, $slug] = $this->generateTitleAndSlug($original); @@ -82,13 +84,14 @@ private function duplicateEntry(Entry $original, ?string $origin = null) ->blueprint($original->blueprint()->handle()) ->published(false) ->data($data) - ->origin($origin); + ->origin($origin) + ->updateLastModified(User::current()); if ($original->collection()->requiresSlugs()) { $entry->slug($slug); } - if ($original->hasDate()) { + if ($original->hasExplicitDate()) { $entry->date($original->date()); } @@ -175,4 +178,9 @@ public function redirect($items, $values) return $this->newItems->first()->editUrl(); } + + private function suspendPropagation(Entry $original): void + { + $original->collection()->propagate(false); + } } diff --git a/src/Actions/Impersonate.php b/src/Actions/Impersonate.php index dacea00dcd1..7db0de495a1 100644 --- a/src/Actions/Impersonate.php +++ b/src/Actions/Impersonate.php @@ -51,7 +51,7 @@ public function run($users, $values) $guard->login($users->first()); session()->put('statamic_impersonated_by', $impersonator->getKey()); - Toast::success(__('You are now impersonating').' '.$impersonated->name()); + Toast::success(__('You are now impersonating').' '.($impersonated->name() ?? $impersonated->email())); ImpersonationStarted::dispatch($impersonator, $impersonated); } finally { diff --git a/src/Actions/RenameAssetFolder.php b/src/Actions/RenameAssetFolder.php index 0b7c61c767f..550830fe494 100644 --- a/src/Actions/RenameAssetFolder.php +++ b/src/Actions/RenameAssetFolder.php @@ -9,7 +9,7 @@ class RenameAssetFolder extends Action { public static function title() { - return __('Rename Folder'); + return __('Rename'); } public function visibleTo($item) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index c700ed7d7d1..de31d71fed5 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -116,6 +116,12 @@ public function __construct() $this->supplements = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; + } + public function id($id = null) { if ($id) { @@ -248,7 +254,7 @@ public function meta($key = null) return $meta; } - return $this->meta = Cache::rememberForever($this->metaCacheKey(), function () { + return $this->meta = $this->cacheStore()->rememberForever($this->metaCacheKey(), function () { if ($contents = $this->disk()->get($path = $this->metaPath())) { return YAML::file($path)->parse($contents); } @@ -267,7 +273,7 @@ private function metaValue($key) return $value; } - Cache::forget($this->metaCacheKey()); + $this->cacheStore()->forget($this->metaCacheKey()); $this->writeMeta($meta = $this->generateMeta()); @@ -589,7 +595,7 @@ public function mimeType() */ public function lastModified() { - return Carbon::createFromTimestamp($this->meta('last_modified')); + return Carbon::createFromTimestamp($this->meta('last_modified'), config('app.timezone')); } /** @@ -689,7 +695,7 @@ public function delete() protected function clearCaches() { $this->meta = null; - Cache::forget($this->metaCacheKey()); + $this->cacheStore()->forget($this->metaCacheKey()); } /** @@ -769,6 +775,13 @@ public function move($folder, $filename = null) return $this; } + public function moveQuietly($folder, $filename = null) + { + $this->withEvents = false; + + return $this->move(...func_get_args()); + } + /** * Replace an asset and/or its references where necessary. * @@ -781,13 +794,13 @@ public function replace(Asset $originalAsset, $deleteOriginal = false) // until after the `AssetReplaced` event is fired. We still want to fire events // like `AssetDeleted` and `AssetSaved` though, so that other listeners will // get triggered (for cache invalidation, clearing of glide cache, etc.) - UpdateAssetReferencesSubscriber::disable(); + app(UpdateAssetReferencesSubscriber::class)::disable(); if ($deleteOriginal) { $originalAsset->delete(); } - UpdateAssetReferencesSubscriber::enable(); + app(UpdateAssetReferencesSubscriber::class)::enable(); AssetReplaced::dispatch($originalAsset, $this); @@ -917,7 +930,7 @@ public function upload(UploadedFile $file) ->syncOriginal() ->save(); - AssetUploaded::dispatch($this); + AssetUploaded::dispatch($this, $file->getClientOriginalName()); AssetCreated::dispatch($this); @@ -935,7 +948,7 @@ public function reupload(ReplacementFile $file) $this->clearCaches(); $this->writeMeta($this->generateMeta()); - AssetReuploaded::dispatch($this); + AssetReuploaded::dispatch($this, $file->basename()); return $this; } @@ -1129,4 +1142,14 @@ public function warmPresets() return array_merge($this->container->warmPresets(), $cpPresets); } + + public function cacheStore() + { + return Cache::store($this->hasCustomStore() ? 'asset_meta' : null); + } + + private function hasCustomStore(): bool + { + return config()->has('cache.stores.asset_meta'); + } } diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index 29765fce499..e73fc1d3377 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -507,6 +507,8 @@ public function private() * * @param bool|null $allowDownloading * @return bool|$this + * + * @deprecated */ public function allowDownloading($allowDownloading = null) { @@ -523,6 +525,8 @@ public function allowDownloading($allowDownloading = null) * * @param bool|null $allowMoving * @return bool|$this + * + * @deprecated */ public function allowMoving($allowMoving = null) { @@ -539,6 +543,8 @@ public function allowMoving($allowMoving = null) * * @param bool|null $allowRenaming * @return bool|$this + * + * @deprecated */ public function allowRenaming($allowRenaming = null) { @@ -555,6 +561,8 @@ public function allowRenaming($allowRenaming = null) * * @param bool|null $allowUploads * @return bool|$this + * + * @deprecated */ public function allowUploads($allowUploads = null) { @@ -571,6 +579,8 @@ public function allowUploads($allowUploads = null) * * @param bool|null $createFolders * @return bool|$this + * + * @deprecated */ public function createFolders($createFolders = null) { @@ -614,7 +624,10 @@ public function warmPresets($preset = null) return $presets; } - $presets = Image::userManipulationPresets(); + $presets = [ + ...Image::userManipulationPresets(), + ...Image::customManipulationPresets(), + ]; $presets = Arr::except($presets, $this->sourcePreset); diff --git a/src/Assets/AssetContainerContents.php b/src/Assets/AssetContainerContents.php index 270fa1cf7f2..fa9b33e4278 100644 --- a/src/Assets/AssetContainerContents.php +++ b/src/Assets/AssetContainerContents.php @@ -35,7 +35,7 @@ public function all() return $this->files; } - return $this->files = Cache::remember($this->key(), $this->ttl(), function () { + return $this->files = $this->cacheStore()->remember($this->key(), $this->ttl(), function () { return collect($this->getRawFlysystemDirectoryListing()) ->keyBy('path') ->map(fn ($file) => $this->normalizeFlysystemAttributes($file)) @@ -164,7 +164,7 @@ private function getNormalizedFlysystemMetadata($path) public function cached() { - return Cache::get($this->key()); + return $this->cacheStore()->get($this->key()); } public function files() @@ -271,7 +271,7 @@ private function filesystem() public function save() { - Cache::put($this->key(), $this->all(), $this->ttl()); + $this->cacheStore()->put($this->key(), $this->all(), $this->ttl()); } public function forget($path) @@ -298,7 +298,7 @@ public function add($path) $files = $this->all()->put($path, $metadata); if (Statamic::isWorker()) { - Cache::put($this->key(), $files, $this->ttl()); + $this->cacheStore()->put($this->key(), $files, $this->ttl()); } $this->filteredFiles = null; @@ -316,4 +316,14 @@ private function ttl() { return Stache::isWatcherEnabled() ? 0 : null; } + + public function cacheStore() + { + return Cache::store($this->hasCustomStore() ? 'asset_container_contents' : null); + } + + private function hasCustomStore(): bool + { + return config()->has('cache.stores.asset_container_contents'); + } } diff --git a/src/Assets/AssetUploader.php b/src/Assets/AssetUploader.php index dc3ce3936c8..731e5693045 100644 --- a/src/Assets/AssetUploader.php +++ b/src/Assets/AssetUploader.php @@ -89,6 +89,8 @@ public static function getSafeFilename($string) '?' => '-', '*' => '-', '%' => '-', + "'" => '-', + '--' => '-', ]; return (string) Str::of(urldecode($string)) diff --git a/src/Assets/Attributes.php b/src/Assets/Attributes.php index 010de336ccd..fba9435e2f5 100644 --- a/src/Assets/Attributes.php +++ b/src/Assets/Attributes.php @@ -88,9 +88,18 @@ private function getVideoAttributes() { $id3 = ExtractInfo::fromAsset($this->asset); + $width = Arr::get($id3, 'video.resolution_x'); + $height = Arr::get($id3, 'video.resolution_y'); + $rotate = Arr::get($id3, 'video.rotate', 0); + + // Adjust width and height if the video is rotated + if (in_array($rotate, [90, 270, -90, -270])) { + [$width, $height] = [$height, $width]; + } + return [ - 'width' => Arr::get($id3, 'video.resolution_x'), - 'height' => Arr::get($id3, 'video.resolution_y'), + 'width' => $width, + 'height' => $height, 'duration' => Arr::get($id3, 'playtime_seconds'), ]; } diff --git a/src/Assets/ReplacementFile.php b/src/Assets/ReplacementFile.php index 2fdc8ece853..aa6fc3804c1 100644 --- a/src/Assets/ReplacementFile.php +++ b/src/Assets/ReplacementFile.php @@ -24,6 +24,11 @@ public function extension() return pathinfo($this->path, PATHINFO_EXTENSION); } + public function basename() + { + return pathinfo($this->path, PATHINFO_BASENAME); + } + public function writeTo(Filesystem $disk, $path) { $disk->put( diff --git a/src/Auth/CorePermissions.php b/src/Auth/CorePermissions.php index 8bbf8c8a122..bfd4d74f2df 100644 --- a/src/Auth/CorePermissions.php +++ b/src/Auth/CorePermissions.php @@ -12,6 +12,8 @@ use Statamic\Facades\Taxonomy; use Statamic\Facades\Utility; +use function Statamic\trans as __; + class CorePermissions { public function boot() @@ -159,14 +161,20 @@ protected function registerAssets() $this->register('configure asset containers'); $this->register('view {container} assets', function ($permission) { - $this->permission($permission)->children([ + $childPermissions = [ $this->permission('upload {container} assets'), $this->permission('edit {container} assets')->children([ $this->permission('move {container} assets'), $this->permission('rename {container} assets'), $this->permission('delete {container} assets'), ]), - ])->replacements('container', function () { + ]; + + if (config('statamic.assets.v6_permissions')) { + $childPermissions[] = $this->permission('edit {container} folders'); + } + + $this->permission($permission)->children($childPermissions)->replacements('container', function () { return AssetContainer::all()->map(function ($container) { return ['value' => $container->handle(), 'label' => __($container->title())]; }); diff --git a/src/Auth/Eloquent/User.php b/src/Auth/Eloquent/User.php index 50c6d28cff4..3df06431409 100644 --- a/src/Auth/Eloquent/User.php +++ b/src/Auth/Eloquent/User.php @@ -282,6 +282,12 @@ public function set($key, $value) $value = Hash::make($value); } + if ($value === null) { + unset($this->model()->$key); + + return $this; + } + $this->model()->$key = $value; return $this; @@ -296,7 +302,7 @@ public function remove($key) public function merge($data) { - $this->data($this->data()->merge($data)); + $this->data($this->data()->merge(collect($data)->filter(fn ($v) => $v !== null)->all())); return $this; } @@ -316,6 +322,24 @@ public function getRememberTokenName() return $this->model()->getRememberTokenName(); } + public function sendPasswordResetNotification($token) + { + if (method_exists($this->model(), 'sendPasswordResetNotification')) { + return $this->model()->sendPasswordResetNotification($token); + } + + parent::sendPasswordResetNotification($token); + } + + public function sendActivateAccountNotification($token) + { + if (method_exists($this->model(), 'sendActivateAccountNotification')) { + return $this->model()->sendActivateAccountNotification($token); + } + + parent::sendActivateAccountNotification($token); + } + public function lastLogin() { if (! $date = $this->model()->last_login) { diff --git a/src/Auth/Eloquent/UserGroup.php b/src/Auth/Eloquent/UserGroup.php index e2ef88ea887..e75268b5c83 100644 --- a/src/Auth/Eloquent/UserGroup.php +++ b/src/Auth/Eloquent/UserGroup.php @@ -2,6 +2,7 @@ namespace Statamic\Auth\Eloquent; +use Illuminate\Support\Facades\DB; use Statamic\Auth\File\UserGroup as FileUserGroup; use Statamic\Facades\User; @@ -50,7 +51,7 @@ public function queryUsers() protected function getUserIds() { - return \DB::connection(config('statamic.users.database')) + return DB::connection(config('statamic.users.database')) ->table(config('statamic.users.tables.group_user', 'group_user')) ->where('group_id', $this->id()) ->pluck('user_id'); diff --git a/src/Auth/File/User.php b/src/Auth/File/User.php index 8935121f334..b848fb21371 100644 --- a/src/Auth/File/User.php +++ b/src/Auth/File/User.php @@ -39,6 +39,12 @@ public function __construct() $this->supplements = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; + } + public function data($data = null) { if (func_num_args() === 0) { @@ -116,7 +122,7 @@ public function lastModified() ? File::disk('users')->lastModified($path) : time(); - return Carbon::createFromTimestamp($timestamp); + return Carbon::createFromTimestamp($timestamp, config('app.timezone')); } /** @@ -171,11 +177,14 @@ public function explicitRoles($roles = null) public function assignRole($role) { - $roles = collect(Arr::wrap($role))->map(function ($role) { - return is_string($role) ? $role : $role->handle(); - })->all(); + $roles = collect($this->get('roles', [])) + ->merge(Arr::wrap($role)) + ->map(fn ($role) => is_string($role) ? $role : $role->handle()) + ->unique() + ->values() + ->all(); - $this->set('roles', array_merge($this->get('roles', []), $roles)); + $this->set('roles', $roles); return $this; } @@ -198,11 +207,14 @@ public function removeRole($role) public function addToGroup($group) { - $groups = collect(Arr::wrap($group))->map(function ($group) { - return is_string($group) ? $group : $group->handle(); - })->all(); + $groups = collect($this->get('groups', [])) + ->merge(Arr::wrap($group)) + ->map(fn ($group) => is_string($group) ? $group : $group->handle()) + ->unique() + ->values() + ->all(); - $this->set('groups', array_merge($this->get('groups', []), $groups)); + $this->set('groups', $groups); return $this; } @@ -292,7 +304,7 @@ public function lastLogin() { $last_login = $this->getMeta('last_login'); - return $last_login ? Carbon::createFromTimestamp($last_login) : $last_login; + return $last_login ? Carbon::createFromTimestamp($last_login, config('app.timezone')) : $last_login; } public function setLastLogin($carbon) diff --git a/src/Auth/Passwords/TokenRepository.php b/src/Auth/Passwords/TokenRepository.php index 9b226c8b5fc..3ba24434f5e 100644 --- a/src/Auth/Passwords/TokenRepository.php +++ b/src/Auth/Passwords/TokenRepository.php @@ -12,9 +12,6 @@ class TokenRepository extends DatabaseTokenRepository { protected $files; - protected $hasher; - protected $hashKey; - protected $expires; protected $path; public function __construct(Filesystem $files, HasherContract $hasher, $table, $hashKey, $expires = 60, $throttle = 60) @@ -70,7 +67,7 @@ public function exists(CanResetPasswordContract $user, $token) $record = $this->getResets()->get($user->email()); return $record && - ! $this->tokenExpired(Carbon::createFromTimestamp($record['created_at'])) + ! $this->tokenExpired(Carbon::createFromTimestamp($record['created_at'], config('app.timezone'))) && $this->hasher->check($token, $record['token']); } diff --git a/src/Auth/Permission.php b/src/Auth/Permission.php index b06731a9f62..905ca852c4d 100644 --- a/src/Auth/Permission.php +++ b/src/Auth/Permission.php @@ -4,6 +4,8 @@ use Statamic\Support\Traits\FluentlyGetsAndSets; +use function Statamic\trans as __; + class Permission { use FluentlyGetsAndSets; diff --git a/src/Auth/Protect/Protection.php b/src/Auth/Protect/Protection.php index ba56c52714d..75031ca3a7c 100644 --- a/src/Auth/Protect/Protection.php +++ b/src/Auth/Protect/Protection.php @@ -69,6 +69,12 @@ public function protect() ->protect(); } + public function cacheable() + { + return $this->driver() + ->cacheable(); + } + protected function url() { return URL::tidy(request()->fullUrl()); diff --git a/src/Auth/Protect/Protectors/Password/PasswordProtector.php b/src/Auth/Protect/Protectors/Password/PasswordProtector.php index bafab411533..e137cf4db32 100644 --- a/src/Auth/Protect/Protectors/Password/PasswordProtector.php +++ b/src/Auth/Protect/Protectors/Password/PasswordProtector.php @@ -57,7 +57,8 @@ public function hasEnteredValidPassword() } if ( - ($password = session("statamic:protect:password.passwords.ref.{$this->data->reference()}")) + $this->data + && ($password = session("statamic:protect:password.passwords.ref.{$this->data->reference()}")) && $this->isValidLocalPassword($password) ) { return true; @@ -105,7 +106,7 @@ protected function generateToken() session()->put("statamic:protect:password.tokens.$token", [ 'scheme' => $this->scheme, 'url' => $this->url, - 'reference' => $this->data->reference(), + 'reference' => $this->data?->reference(), ]); return $token; diff --git a/src/Auth/Protect/Protectors/Protector.php b/src/Auth/Protect/Protectors/Protector.php index cd06d2c6e34..96cd5732285 100644 --- a/src/Auth/Protect/Protectors/Protector.php +++ b/src/Auth/Protect/Protectors/Protector.php @@ -36,4 +36,9 @@ public function setConfig($config) return $this; } + + public function cacheable() + { + return false; + } } diff --git a/src/Auth/ResetsPasswords.php b/src/Auth/ResetsPasswords.php index 800867991ef..3716aecb789 100644 --- a/src/Auth/ResetsPasswords.php +++ b/src/Auth/ResetsPasswords.php @@ -12,6 +12,7 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password as PasswordRules; use Illuminate\Validation\ValidationException; +use Statamic\Facades\URL; /** * A copy of Illuminate\Auth\ResetsPasswords. @@ -49,8 +50,10 @@ public function reset(Request $request) $validator = Validator::make($request->all(), $this->rules(), $this->validationErrorMessages()); if (! $validator->passes()) { - $redirect = $request->has('_error_redirect') - ? redirect($request->input('_error_redirect')) + $errorRedirect = $request->input('_error_redirect'); + + $redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) : back(); return $redirect @@ -173,8 +176,10 @@ protected function sendResetFailedResponse(Request $request, $response) ]); } - $redirect = $request->has('_error_redirect') - ? redirect($request->input('_error_redirect')) + $errorRedirect = $request->input('_error_redirect'); + + $redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) : back(); return $redirect diff --git a/src/Auth/SendsPasswordResetEmails.php b/src/Auth/SendsPasswordResetEmails.php index bf44788c84a..e9d3b1d604f 100644 --- a/src/Auth/SendsPasswordResetEmails.php +++ b/src/Auth/SendsPasswordResetEmails.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; use Illuminate\Validation\ValidationException; +use Statamic\Facades\URL; /** * A copy of Illuminate\Auth\SendsPasswordResetEmails. @@ -85,8 +86,10 @@ protected function sendResetLinkResponse(Request $request, $response) { session()->flash('user.forgot_password.success', __(Password::RESET_LINK_SENT)); - $redirect = $request->has('_redirect') - ? redirect($request->input('_redirect')) + $successRedirect = $request->input('_redirect'); + + $redirect = $successRedirect && ! URL::isExternalToApplication($successRedirect) + ? redirect($successRedirect) : back(); return $request->wantsJson() @@ -102,8 +105,10 @@ protected function sendResetLinkResponse(Request $request, $response) */ protected function sendResetLinkFailedResponse(Request $request, $response) { - $redirect = $request->has('_error_redirect') - ? redirect($request->input('_error_redirect')) + $errorRedirect = $request->input('_error_redirect'); + + $redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) : back(); if ($request->wantsJson()) { diff --git a/src/Auth/User.php b/src/Auth/User.php index 9390a4c1cda..86d935d6cff 100644 --- a/src/Auth/User.php +++ b/src/Auth/User.php @@ -77,13 +77,13 @@ public function title() public function initials() { + if (! $name = $this->name()) { + return '?'; + } + $surname = ''; - if ($name = $this->get('name')) { - if (Str::contains($name, ' ')) { - [$name, $surname] = explode(' ', $name); - } - } else { - $name = (string) $this->email(); + if (Str::contains($name, ' ')) { + [$name, $surname] = explode(' ', $name, 2); } return strtoupper(mb_substr($name, 0, 1).mb_substr($surname, 0, 1)); @@ -320,7 +320,7 @@ public function name() return $name; } - return $this->email(); + return null; } public function defaultAugmentedArrayKeys() diff --git a/src/Auth/UserGroup.php b/src/Auth/UserGroup.php index f24ed4b36f4..75cc314fabe 100644 --- a/src/Auth/UserGroup.php +++ b/src/Auth/UserGroup.php @@ -27,6 +27,14 @@ public function __construct() { $this->roles = collect(); $this->data = collect(); + $this->supplements = collect(); + } + + public function __clone() + { + $this->roles = clone $this->roles; + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; } public function title(?string $title = null) diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index f1adc46646c..61f34cd36b0 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -196,7 +196,9 @@ public function profileForm() $data = $this->getFormSession('user.profile'); - $data['fields'] = $this->getProfileFields(); + $data['tabs'] = $this->getProfileTabs(); + $data['sections'] = collect($data['tabs'])->flatMap->sections->all(); + $data['fields'] = collect($data['sections'])->flatMap->fields->all(); $knownParams = ['redirect', 'error_redirect', 'allow_request_redirect']; @@ -453,9 +455,10 @@ public function can() } $permissions = Arr::wrap($this->params->explode(['permission', 'do'])); + $arguments = $this->params->except(['permission', 'do'])->all(); foreach ($permissions as $permission) { - if ($user->can($permission)) { + if ($user->can($permission, $arguments)) { return $this->parser ? $this->parse() : true; } } @@ -477,11 +480,12 @@ public function cant() } $permissions = Arr::wrap($this->params->explode(['permission', 'do'])); + $arguments = $this->params->except(['permission', 'do'])->all(); $can = false; foreach ($permissions as $permission) { - if ($user->can($permission)) { + if ($user->can($permission, $arguments)) { $can = true; break; } @@ -706,10 +710,46 @@ protected function getAdditionalRegistrationFields() ->all(); } + /** + * Get tabs, sections, and fields with extra data for looping over and rendering. + * + * @return array + */ + protected function getProfileTabs() + { + $user = User::current(); + + $values = $user + ? $user->data()->merge(['email' => $user->email()])->all() + : []; + + return User::blueprint()->tabs() + ->map(fn ($tab) => [ + 'display' => $tab->display(), + 'sections' => $tab->sections() + ->map(fn ($section) => [ + 'display' => $section->display(), + 'instructions' => $section->instructions(), + 'fields' => $section->fields()->addValues($values)->preProcess()->all() + ->reject(fn ($field) => in_array($field->handle(), ['password', 'password_confirmation', 'roles', 'groups']) + || $field->fieldtype()->handle() === 'assets' + ) + ->map(fn ($field) => $this->getRenderableField($field, 'user.profile')) + ->values() + ->all(), + ]) + ->all(), + ]) + ->values() + ->all(); + } + /** * Get fields with extra data for looping over and rendering. * * @return array + * + * @deprecated */ protected function getProfileFields() { diff --git a/src/CP/Breadcrumbs.php b/src/CP/Breadcrumbs.php index e27b81b1840..be889c66ca8 100644 --- a/src/CP/Breadcrumbs.php +++ b/src/CP/Breadcrumbs.php @@ -6,6 +6,8 @@ use JsonSerializable; use Statamic\Statamic; +use function Statamic\trans as __; + class Breadcrumbs implements Arrayable, JsonSerializable { protected $crumbs; diff --git a/src/CP/Navigation/NavBuilder.php b/src/CP/Navigation/NavBuilder.php index 4219e56c2e5..17d3c3ad492 100644 --- a/src/CP/Navigation/NavBuilder.php +++ b/src/CP/Navigation/NavBuilder.php @@ -57,13 +57,13 @@ public function build($preferences = true) ->resolveChildrenClosures() ->validateNesting() ->validateViews() - ->authorizeItems() - ->authorizeChildren() ->syncOriginal() ->trackCoreSections() ->trackOriginalSectionItems() ->trackUrls() ->applyPreferenceOverrides($preferences) + ->authorizeItems() + ->authorizeChildren() ->buildSections() ->blinkUrls() ->get(); @@ -156,7 +156,10 @@ protected function authorizeChildren() { collect($this->items) ->reject(fn ($item) => is_callable($item->children())) - ->each(fn ($item) => $item->children($this->filterAuthorizedNavItems($item->children()))); + ->each(fn ($item) => $item->children( + items: $this->filterAuthorizedNavItems($item->children()), + generateNewIds: false, + )); return $this; } @@ -707,7 +710,10 @@ protected function userModifyItemChildren($item, $childrenOverrides, $section, $ $newChildren->each(fn ($item, $index) => $item->order($index + 1)); - $item->children($newChildren, false); + $item->children( + items: $newChildren, + generateNewIds: false, + ); return $newChildren; } diff --git a/src/CP/Navigation/NavItem.php b/src/CP/Navigation/NavItem.php index 3d0878a6b52..83e55bc7225 100644 --- a/src/CP/Navigation/NavItem.php +++ b/src/CP/Navigation/NavItem.php @@ -3,6 +3,7 @@ namespace Statamic\CP\Navigation; use Illuminate\Support\Collection; +use Rhukster\DomSanitizer\DOMSanitizer; use Statamic\Facades\CP\Nav; use Statamic\Facades\URL; use Statamic\Statamic; @@ -200,7 +201,22 @@ public function svg() { $value = $this->icon() ?? 'entries'; - return Str::startsWith($value, 'sanitizeSvg($svg); + } + + private function sanitizeSvg(string $svg): string + { + try { + $sanitizer = new DOMSanitizer(DOMSanitizer::SVG); + + return $sanitizer->sanitize($svg, [ + 'remove-xml-tags' => ! Str::startsWith($svg, 'value($isChild); } + /** + * Check if current url is a restful descendant. + */ + protected function currentUrlIsRestfulDescendant(): bool + { + return (bool) Str::endsWith(request()->url(), [ + '/create', + '/edit', + ]); + } + + /** + * Check if we should assume nested URL conventions for active state on children. + */ + protected function doesntHaveExplicitChildren(): bool + { + return (bool) ! $this->children; + } + /** * Check if this nav item was ever a child before user preferences were applied. - * - * @param bool|null $isChild - * @return mixed */ - protected function wasOriginallyChild() + protected function wasOriginallyChild(): bool { return (bool) $this->wasOriginallyChild; } @@ -382,8 +414,13 @@ public function isActive() // If the current URL is not explicitly referenced in the CP nav, // and if this item is/was ever a child nav item, // then check against URL heirarchy conventions using regex pattern. - if ($this->currentUrlIsNotExplicitlyReferencedInNav() && $this->wasOriginallyChild()) { - return $this->isActiveByPattern($this->active); + if ($this->currentUrlIsNotExplicitlyReferencedInNav()) { + switch (true) { + case $this->currentUrlIsRestfulDescendant(): + case $this->doesntHaveExplicitChildren(): + case $this->wasOriginallyChild(): + return $this->isActiveByPattern($this->active); + } } return request()->url() === URL::removeQueryAndFragment($this->url); diff --git a/src/Console/Commands/AssetsCacheClear.php b/src/Console/Commands/AssetsCacheClear.php new file mode 100644 index 00000000000..1c7e4cbf416 --- /dev/null +++ b/src/Console/Commands/AssetsCacheClear.php @@ -0,0 +1,55 @@ +has('cache.stores.asset_meta') && ! config()->has('cache.stores.asset_container_contents')) { + $this->components->error('You do not have any custom asset cache stores.'); + + return 0; + } + + if (config()->has('cache.stores.asset_meta')) { + spin(callback: fn () => Asset::make()->cacheStore()->flush(), message: 'Clearing the asset meta cache...'); + + $this->components->info('Your asset meta cache is now so very, very empty.'); + } + + if (config()->has('cache.stores.asset_container_contents')) { + spin(callback: fn () => (new AssetContainerContents)->cacheStore()->flush(), message: 'Clearing the asset folder cache...'); + + $this->components->info('Your asset folder cache is now so very, very empty.'); + } + } +} diff --git a/src/Console/Commands/AssetsGeneratePresets.php b/src/Console/Commands/AssetsGeneratePresets.php index aba0a593f9b..6621fcc17ab 100644 --- a/src/Console/Commands/AssetsGeneratePresets.php +++ b/src/Console/Commands/AssetsGeneratePresets.php @@ -24,7 +24,9 @@ class AssetsGeneratePresets extends Command * * @var string */ - protected $signature = 'statamic:assets:generate-presets {--queue : Queue the image generation.}'; + protected $signature = 'statamic:assets:generate-presets + {--queue : Queue the image generation.} + {--excluded-containers= : Comma separated list of container handles to exclude.}'; /** * The console command description. @@ -55,7 +57,15 @@ public function handle() $this->shouldQueue = false; } - AssetContainer::all()->sortBy('title')->each(function ($container) { + $excludedContainers = $this->option('excluded-containers'); + + if ($excludedContainers) { + $excludedContainers = explode(',', $excludedContainers); + } + + AssetContainer::all()->filter(function ($container) use ($excludedContainers) { + return ! in_array($container->handle(), $excludedContainers ?? []); + })->sortBy('title')->each(function ($container) { note('Generating presets for '.$container->title().'...'); $this->generatePresets($container); $this->newLine(); @@ -116,7 +126,7 @@ private function generatePresets($container) if (property_exists($this, 'components')) { $errors = Arr::pull($counts, 'errors'); collect($counts) - ->put('errors', $errors) + ->when($errors, fn ($counts) => $counts->put('errors', $errors)) ->each(function ($count, $preset) { $preset = $preset === 'errors' ? 'errors' : $preset; $this->components->twoColumnDetail($preset, $count); diff --git a/src/Console/Commands/ImportUsers.php b/src/Console/Commands/ImportUsers.php index 75060d02693..8bc8e2b33b0 100644 --- a/src/Console/Commands/ImportUsers.php +++ b/src/Console/Commands/ImportUsers.php @@ -73,6 +73,8 @@ private function importUsers() app()->bind(UserContract::class, FileUser::class); app()->bind(UserRepositoryContract::class, FileRepository::class); + User::clearResolvedInstances(); + $users = User::all(); if ($users->isEmpty()) { diff --git a/src/Console/Commands/InstallEloquentDriver.php b/src/Console/Commands/InstallEloquentDriver.php index 31c4f85fc24..368a39a7b8d 100644 --- a/src/Console/Commands/InstallEloquentDriver.php +++ b/src/Console/Commands/InstallEloquentDriver.php @@ -30,6 +30,7 @@ class InstallEloquentDriver extends Command */ protected $signature = 'statamic:install:eloquent-driver { --all : Configures all repositories to use the database } + { --repositories= : Comma separated list of repositories to migrate } { --import : Whether existing data should be imported } { --without-messages : Disables output messages }'; @@ -68,13 +69,19 @@ public function handle() return $this->components->error('Failed to connect to the configured database. Please check your database configuration and try again.'); } - if ($this->availableRepositories()->isEmpty()) { + if ($this->allRepositories()->reject(fn ($value, $key) => $this->repositoryHasBeenMigrated($key))->isEmpty()) { return $this->components->warn("No repositories left to migrate. You're already using the Eloquent Driver for all repositories."); } $repositories = $this->repositories(); foreach ($repositories as $repository) { + if ($this->repositoryHasBeenMigrated($repository)) { + $this->components->warn("Skipping. The {$repository} repository is already using the Eloquent Driver."); + + continue; + } + $method = 'migrate'.Str::studly($repository); $this->$method(); } @@ -83,12 +90,33 @@ public function handle() protected function repositories(): array { if ($this->option('all')) { - return $this->availableRepositories()->keys()->all(); + return $this->allRepositories()->keys()->all(); + } + + if ($repositories = $this->option('repositories')) { + $repositories = collect(explode(',', $repositories)) + ->map(fn ($repo) => trim(strtolower($repo))) + ->unique(); + + $invalidRepositories = $repositories->reject(fn ($repo) => $this->allRepositories()->has($repo)); + + if ($invalidRepositories->isNotEmpty()) { + $this->components->error("Some of the repositories you provided are invalid: {$invalidRepositories->implode(', ')}"); + + exit(1); + } + + return $repositories + ->filter(fn ($repo) => $this->allRepositories()->has($repo)) + ->values() + ->all(); } return multiselect( label: 'Which repositories would you like to migrate?', - options: $this->availableRepositories()->all(), + options: $this->allRepositories() + ->reject(fn ($value, $key) => $this->repositoryHasBeenMigrated($key)) + ->all(), validate: fn (array $values) => count($values) === 0 ? 'You must select at least one repository to migrate.' : null, @@ -96,7 +124,7 @@ protected function repositories(): array ); } - protected function availableRepositories(): Collection + protected function allRepositories(): Collection { return collect([ 'asset_containers' => 'Asset Containers', @@ -117,64 +145,67 @@ protected function availableRepositories(): Collection 'taxonomies' => 'Taxonomies', 'terms' => 'Terms', 'tokens' => 'Tokens', - ])->reject(function ($value, $key) { - switch ($key) { - case 'asset_containers': - return config('statamic.eloquent-driver.asset_containers.driver') === 'eloquent'; + ]); + } + + protected function repositoryHasBeenMigrated(string $repository): bool + { + switch ($repository) { + case 'asset_containers': + return config('statamic.eloquent-driver.asset_containers.driver') === 'eloquent'; - case 'assets': - return config('statamic.eloquent-driver.assets.driver') === 'eloquent'; + case 'assets': + return config('statamic.eloquent-driver.assets.driver') === 'eloquent'; - case 'blueprints': - return config('statamic.eloquent-driver.blueprints.driver') === 'eloquent'; + case 'blueprints': + return config('statamic.eloquent-driver.blueprints.driver') === 'eloquent'; - case 'collections': - return config('statamic.eloquent-driver.collections.driver') === 'eloquent'; + case 'collections': + return config('statamic.eloquent-driver.collections.driver') === 'eloquent'; - case 'collection_trees': - return config('statamic.eloquent-driver.collection_trees.driver') === 'eloquent'; + case 'collection_trees': + return config('statamic.eloquent-driver.collection_trees.driver') === 'eloquent'; - case 'entries': - return config('statamic.eloquent-driver.entries.driver') === 'eloquent'; + case 'entries': + return config('statamic.eloquent-driver.entries.driver') === 'eloquent'; - case 'fieldsets': - return config('statamic.eloquent-driver.fieldsets.driver') === 'eloquent'; + case 'fieldsets': + return config('statamic.eloquent-driver.fieldsets.driver') === 'eloquent'; - case 'forms': - return config('statamic.eloquent-driver.forms.driver') === 'eloquent'; + case 'forms': + return config('statamic.eloquent-driver.forms.driver') === 'eloquent'; - case 'form_submissions': - return config('statamic.eloquent-driver.form_submissions.driver') === 'eloquent'; + case 'form_submissions': + return config('statamic.eloquent-driver.form_submissions.driver') === 'eloquent'; - case 'globals': - return config('statamic.eloquent-driver.global_sets.driver') === 'eloquent'; + case 'globals': + return config('statamic.eloquent-driver.global_sets.driver') === 'eloquent'; - case 'global_variables': - return config('statamic.eloquent-driver.global_set_variables.driver') === 'eloquent'; + case 'global_variables': + return config('statamic.eloquent-driver.global_set_variables.driver') === 'eloquent'; - case 'navs': - return config('statamic.eloquent-driver.navigations.driver') === 'eloquent'; + case 'navs': + return config('statamic.eloquent-driver.navigations.driver') === 'eloquent'; - case 'nav_trees': - return config('statamic.eloquent-driver.navigation_trees.driver') === 'eloquent'; + case 'nav_trees': + return config('statamic.eloquent-driver.navigation_trees.driver') === 'eloquent'; - case 'revisions': - return ! config('statamic.revisions.enabled') - || config('statamic.eloquent-driver.revisions.driver') === 'eloquent'; + case 'revisions': + return ! config('statamic.revisions.enabled') + || config('statamic.eloquent-driver.revisions.driver') === 'eloquent'; - case 'sites': - return config('statamic.eloquent-driver.sites.driver') === 'eloquent'; + case 'sites': + return config('statamic.eloquent-driver.sites.driver') === 'eloquent'; - case 'taxonomies': - return config('statamic.eloquent-driver.taxonomies.driver') === 'eloquent'; + case 'taxonomies': + return config('statamic.eloquent-driver.taxonomies.driver') === 'eloquent'; - case 'terms': - return config('statamic.eloquent-driver.terms.driver') === 'eloquent'; + case 'terms': + return config('statamic.eloquent-driver.terms.driver') === 'eloquent'; - case 'tokens': - return config('statamic.eloquent-driver.tokens.driver') === 'eloquent'; - } - }); + case 'tokens': + return config('statamic.eloquent-driver.tokens.driver') === 'eloquent'; + } } protected function migrateAssetContainers(): void @@ -626,7 +657,7 @@ private function infoMessage(string $message): void return; } - $this->components->info('Configured asset containers'); + $this->components->info($message); } private function switchToEloquentDriver(string $repository): void diff --git a/src/Console/Commands/InstallSsg.php b/src/Console/Commands/InstallSsg.php index 0f8455dafa9..404e5b1b453 100644 --- a/src/Console/Commands/InstallSsg.php +++ b/src/Console/Commands/InstallSsg.php @@ -68,6 +68,7 @@ function () { if ( ! Composer::isInstalled('spatie/fork') + && extension_loaded('pcntl') && confirm('Would you like to install spatie/fork? It allows for running multiple workers at once.') ) { spin( diff --git a/src/Console/Commands/MakeFieldtype.php b/src/Console/Commands/MakeFieldtype.php index 1ceacb2b0d1..6a703586cc0 100644 --- a/src/Console/Commands/MakeFieldtype.php +++ b/src/Console/Commands/MakeFieldtype.php @@ -78,7 +78,7 @@ protected function generateVueComponent() $this->components->info("Fieldtype Vue component [{$relativePath}] created successfully."); $this->components->bulletList([ - "Don't forget to import and register your fieldtype's Vue component in resources/js/addon.js", + "Don't forget to import and register your fieldtype's Vue component in resources/js/cp.js", 'For more information, see the documentation: https://statamic.dev/fieldtypes#vue-components', ]); diff --git a/src/Console/Commands/Multisite.php b/src/Console/Commands/Multisite.php index 71a3e5f296b..64a771bde76 100644 --- a/src/Console/Commands/Multisite.php +++ b/src/Console/Commands/Multisite.php @@ -19,6 +19,7 @@ use Statamic\Facades\YAML; use Statamic\Rules\Handle; use Statamic\Statamic; +use Statamic\Support\Traits\Hookable; use Wilderborn\Partyline\Facade as Partyline; use function Laravel\Prompts\confirm; @@ -26,7 +27,7 @@ class Multisite extends Command { - use ConfirmableTrait, EnhancesCommands, RunsInPlease, ValidatesInput; + use ConfirmableTrait, EnhancesCommands, Hookable, RunsInPlease, ValidatesInput; protected $signature = 'statamic:multisite'; @@ -56,6 +57,8 @@ public function handle() ->addPermissions() ->clearCache(); + $this->runHooks('after'); + $this->components->info('Successfully converted from single to multisite installation!'); $this->line('You may now manage your sites in the Control Panel at [/cp/sites].'); diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 3dc63df0887..6d9bbd772b2 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -32,7 +32,8 @@ class StarterKitInstall extends Command { --without-user : Install without creating user } { --force : Force install and allow dependency errors } { --cli-install : Installing from CLI Tool } - { --clear-site : Clear site before installing }'; + { --clear-site : Clear site before installing } + { --update-search : Update search index(es) after installing }'; /** * The console command description. @@ -80,6 +81,10 @@ public function handle() return 1; } + if ($this->shouldUpdateSearchIndex()) { + $this->updateSearchIndex(); + } + // Temporary prompt to inform user of updated CLI tool. The newest version has better messaging // around paid starter kit licenses, so we want to push users to upgrade to minimize support // requests around expired licenses. The newer version of the CLI tool will also notify @@ -139,6 +144,35 @@ protected function clearSite(): void Prompt::interactive($this->input->isInteractive()); } + /** + * Check if should update search index. + */ + protected function shouldUpdateSearchIndex(): bool + { + if ($this->option('update-search')) { + return true; + } elseif ($this->input->isInteractive()) { + return confirm('Would you like to update your search index(es) as well?', false); + } + + return false; + } + + /** + * Update search index, and re-set prompt interactivity for future prompts. + * + * See: https://github.com/statamic/cli/issues/62 + */ + protected function updateSearchIndex(): void + { + $this->call('statamic:search:update', [ + '--all' => true, + '--no-interaction' => true, + ]); + + Prompt::interactive($this->input->isInteractive()); + } + /** * Detect older Statamic CLI installation. */ diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index 7bb26f59b67..c64ea2f861b 100644 --- a/src/Console/Commands/StaticWarm.php +++ b/src/Console/Commands/StaticWarm.php @@ -38,6 +38,11 @@ class StaticWarm extends Command {--p|password= : HTTP authentication password} {--insecure : Skip SSL verification} {--uncached : Only warm uncached URLs} + {--max-depth= : Maximum depth of URLs to warm} + {--include= : Only warm specific URLs} + {--exclude= : Exclude specific URLs} + {--max-requests= : Maximum number of requests to warm} + {--header=* : Set custom header (e.g. "Authorization: Bearer your_token")} '; protected $description = 'Warms the static cache by visiting all URLs'; @@ -91,8 +96,12 @@ private function warm(): void $queue = config('statamic.static_caching.warm_queue'); $this->line(sprintf('Adding %s requests onto %squeue...', count($requests), $queue ? $queue.' ' : '')); + $jobClass = $this->option('uncached') + ? StaticWarmUncachedJob::class + : StaticWarmJob::class; + foreach ($requests as $request) { - StaticWarmJob::dispatch($request, $this->clientConfig()) + $jobClass::dispatch($request, $this->clientConfig()) ->onConnection($this->queueConnection) ->onQueue($queue); } @@ -159,8 +168,10 @@ private function getRelativeUri(int $index): string private function requests() { - return $this->uris()->map(function ($uri) { - return new Request('GET', $uri); + $headers = $this->parseHeaders($this->option('header')); + + return $this->uris()->map(function ($uri) use ($headers) { + return new Request('GET', $uri, $headers); })->all(); } @@ -179,6 +190,9 @@ private function uris(): Collection ->merge($this->customRouteUris()) ->merge($this->additionalUris()) ->unique() + ->filter(fn ($uri) => $this->shouldInclude($uri)) + ->reject(fn ($uri) => $this->shouldExclude($uri)) + ->reject(fn ($uri) => $this->exceedsMaxDepth($uri)) ->reject(function ($uri) use ($cacher) { if ($this->option('uncached') && $cacher->hasCachedPage(HttpRequest::create($uri))) { return true; @@ -189,12 +203,61 @@ private function uris(): Collection return $cacher->isExcluded($uri); }) ->sort() - ->values(); + ->values() + ->when($this->option('max-requests'), fn ($uris, $max) => $uris->take($max)); + } + + private function shouldInclude($uri): bool + { + if (! $inclusions = $this->option('include')) { + return true; + } + + $inclusions = explode(',', $inclusions); + + return collect($inclusions)->contains(fn ($included) => $this->uriMatches($uri, $included)); + } + + private function shouldExclude($uri): bool + { + if (! $exclusions = $this->option('exclude')) { + return false; + } + + $exclusions = explode(',', $exclusions); + + return collect($exclusions)->contains(fn ($excluded) => $this->uriMatches($uri, $excluded)); + } + + private function uriMatches($uri, $pattern): bool + { + $uri = URL::makeRelative($uri); + + if (Str::endsWith($pattern, '*')) { + $prefix = Str::removeRight($pattern, '*'); + + if (Str::startsWith($uri, $prefix) && ! (Str::endsWith($prefix, '/') && $uri === $prefix)) { + return true; + } + } elseif (Str::removeRight($uri, '/') === Str::removeRight($pattern, '/')) { + return true; + } + + return false; + } + + private function exceedsMaxDepth($uri): bool + { + if (! $max = $this->option('max-depth')) { + return false; + } + + return count(explode('/', trim(URL::makeRelative($uri), '/'))) > $max; } private function shouldVerifySsl(): bool { - if ($this->option('insecure')) { + if ($this->option('insecure') || config('statamic.static_caching.warm_insecure')) { return false; } @@ -314,4 +377,25 @@ protected function additionalUris(): Collection return $uris->map(fn ($uri) => URL::makeAbsolute($uri)); } + + private function parseHeaders($headerOptions): array + { + $headers = []; + if (empty($headerOptions)) { + return $headers; + } + if (! is_array($headerOptions)) { + $headerOptions = [$headerOptions]; + } + foreach ($headerOptions as $header) { + if (strpos($header, ':') !== false) { + [$key, $value] = explode(':', $header, 2); + $headers[trim($key)] = trim($value); + } else { + $this->line("Warning: Invalid header format: '$header'. Headers should be in 'Key: Value' format."); + } + } + + return $headers; + } } diff --git a/src/Console/Commands/StaticWarmUncachedJob.php b/src/Console/Commands/StaticWarmUncachedJob.php new file mode 100644 index 00000000000..11c0c246d6c --- /dev/null +++ b/src/Console/Commands/StaticWarmUncachedJob.php @@ -0,0 +1,20 @@ +hasCachedPage(Request::create($this->request->getUri()))) { + return; + } + + parent::handle(); + } +} diff --git a/src/Console/Commands/stubs/statamic_nocache_tables.php.stub b/src/Console/Commands/stubs/statamic_nocache_tables.php.stub index dc59f5a6c4e..4d17f65063a 100644 --- a/src/Console/Commands/stubs/statamic_nocache_tables.php.stub +++ b/src/Console/Commands/stubs/statamic_nocache_tables.php.stub @@ -8,7 +8,9 @@ class StatamicNocacheTables extends Migration { public function up() { - Schema::create('NOCACHE_TABLE', function (Blueprint $table) { + Schema::connection( + config('statamic.static_caching.nocache_db_connection') + )->create('NOCACHE_TABLE', function (Blueprint $table) { $table->string('key')->index()->primary(); $table->string('url')->index(); $table->longText('region'); @@ -18,6 +20,8 @@ class StatamicNocacheTables extends Migration public function down() { - Schema::dropIfExists('nocache_regions'); + Schema::connection( + config('statamic.static_caching.nocache_db_connection') + )->dropIfExists('NOCACHE_TABLE'); } } diff --git a/src/Console/Processes/Git.php b/src/Console/Processes/Git.php index 7d36b2cbe32..23a433b3540 100644 --- a/src/Console/Processes/Git.php +++ b/src/Console/Processes/Git.php @@ -89,6 +89,7 @@ protected function prepareErrorOutput($type, $buffer) 'remote: Resolving deltas', 'Permanently added the ECDSA host key for IP address', 'remote: Processed', + 'Auto packing the repository', ]; if (Str::contains($buffer, $ignore)) { diff --git a/src/Contracts/Entries/EntryRepository.php b/src/Contracts/Entries/EntryRepository.php index c306eba1a51..8aaafd8b7e2 100644 --- a/src/Contracts/Entries/EntryRepository.php +++ b/src/Contracts/Entries/EntryRepository.php @@ -10,6 +10,8 @@ public function whereCollection(string $handle); public function whereInCollection(array $handles); + // public function whereInId(array $ids); + public function find($id); public function findOrFail($id); diff --git a/src/Contracts/GraphQL/CastableToValidationString.php b/src/Contracts/GraphQL/CastableToValidationString.php new file mode 100644 index 00000000000..2324206b663 --- /dev/null +++ b/src/Contracts/GraphQL/CastableToValidationString.php @@ -0,0 +1,8 @@ +data[$key] = ($this->data[$key] ?? 0) + $amount; + + return $this; + } + + public function decrement($key, $amount = 1) + { + return $this->increment($key, -$amount); + } + public function remove($key) { unset($this->data[$key]); diff --git a/src/Data/DataCollection.php b/src/Data/DataCollection.php index 092a1c2977d..f6ce3d81311 100644 --- a/src/Data/DataCollection.php +++ b/src/Data/DataCollection.php @@ -96,17 +96,19 @@ protected function getSortableValue($sort, $item) return $this->normalizeSortableValue($item[$sort] ?? null); } - $method = Str::camel($sort); - if ($item instanceof Result && ! $item instanceof PlainResult) { $item = $item->getSearchable() ?? $item; } - $value = (method_exists($item, $method)) - ? call_user_func([$item, $method]) - : $item->get($sort); + if (method_exists($item, $method = Str::camel($sort))) { + return $this->normalizeSortableValue(call_user_func([$item, $method])); + } + + if (method_exists($item, 'value')) { + return $this->normalizeSortableValue($item->value($sort)); + } - return $this->normalizeSortableValue($value); + return $this->normalizeSortableValue($item->get($sort)); } /** diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index f1fc28ce989..8c5cc343a5b 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -98,7 +98,7 @@ protected function updateNestedFieldValues($fields, $dottedPrefix) { $fields ->filter(function ($field) { - return in_array($field->type(), ['replicator', 'grid', 'bard']); + return in_array($field->type(), ['replicator', 'grid', 'group', 'bard']); }) ->each(function ($field) use ($dottedPrefix) { $method = 'update'.ucfirst($field->type()).'Children'; @@ -155,6 +155,24 @@ protected function updateGridChildren($field, $dottedKey) }); } + /** + * Update group field children. + * + * @param \Statamic\Fields\Field $field + * @param string $dottedKey + */ + protected function updateGroupChildren($field, $dottedKey) + { + $data = $this->item->data(); + + $dottedPrefix = "{$dottedKey}."; + $fields = Arr::get($field->config(), 'fields'); + + if ($fields) { + $this->recursivelyUpdateFields((new Fields($fields))->all(), $dottedPrefix); + } + } + /** * Update bard field children. * diff --git a/src/Data/ExistsAsFile.php b/src/Data/ExistsAsFile.php index 7a549641c01..bc1efb2e1d4 100644 --- a/src/Data/ExistsAsFile.php +++ b/src/Data/ExistsAsFile.php @@ -74,7 +74,7 @@ public function fileLastModified() return Carbon::now(); } - return Carbon::createFromTimestamp(File::lastModified($this->path())); + return Carbon::createFromTimestamp(File::lastModified($this->path()), config('app.timezone')); } public function fileExtension() diff --git a/src/Data/StoresComputedFieldCallbacks.php b/src/Data/StoresComputedFieldCallbacks.php index 52763c73d99..4b73e97f1e7 100644 --- a/src/Data/StoresComputedFieldCallbacks.php +++ b/src/Data/StoresComputedFieldCallbacks.php @@ -9,8 +9,19 @@ trait StoresComputedFieldCallbacks { protected $computedFieldCallbacks; - public function computed(string $field, Closure $callback) + /** + * @param string|array $field + */ + public function computed($field, ?Closure $callback = null) { + if (is_array($field)) { + foreach ($field as $field_name => $field_callback) { + $this->computedFieldCallbacks[$field_name] = $field_callback; + } + + return; + } + $this->computedFieldCallbacks[$field] = $callback; } diff --git a/src/Data/StoresScopedComputedFieldCallbacks.php b/src/Data/StoresScopedComputedFieldCallbacks.php index 808d1bf106d..cd586cb5ba0 100644 --- a/src/Data/StoresScopedComputedFieldCallbacks.php +++ b/src/Data/StoresScopedComputedFieldCallbacks.php @@ -14,10 +14,19 @@ trait StoresScopedComputedFieldCallbacks /** * @param string|array $scopes + * @param string|array $field */ - public function computed($scopes, string $field, Closure $callback) + public function computed($scopes, $field, ?Closure $callback = null) { foreach (Arr::wrap($scopes) as $scope) { + if (is_array($field)) { + foreach ($field as $field_name => $field_callback) { + $this->computedFieldCallbacks["$scope.$field_name"] = $field_callback; + } + + continue; + } + $this->computedFieldCallbacks["$scope.$field"] = $callback; } } diff --git a/src/Data/TracksLastModified.php b/src/Data/TracksLastModified.php index 76b4585c198..276060c6066 100644 --- a/src/Data/TracksLastModified.php +++ b/src/Data/TracksLastModified.php @@ -9,14 +9,14 @@ trait TracksLastModified { public function lastModified() { - return $this->has('updated_at') - ? Carbon::createFromTimestamp($this->get('updated_at')) + return $this->get('updated_at') + ? Carbon::createFromTimestamp($this->get('updated_at'), config('app.timezone')) : $this->fileLastModified(); } public function lastModifiedBy() { - return $this->has('updated_by') + return $this->get('updated_by') ? User::find($this->get('updated_by')) : null; } diff --git a/src/Dictionaries/BasicDictionary.php b/src/Dictionaries/BasicDictionary.php index 52e2f7363c3..77cb0d672a5 100644 --- a/src/Dictionaries/BasicDictionary.php +++ b/src/Dictionaries/BasicDictionary.php @@ -58,8 +58,11 @@ protected function matchesSearchQuery(string $query, Item $item): bool { $query = strtolower($query); + // Pre-compute searchable lookup for O(1) access instead of O(n) in_array() + $searchableLookup = empty($this->searchable) ? null : array_flip($this->searchable); + foreach ($item->extra() as $key => $value) { - if (! empty($this->searchable) && ! in_array($key, $this->searchable)) { + if ($searchableLookup !== null && ! isset($searchableLookup[$key])) { continue; } diff --git a/src/Dictionaries/Currencies.php b/src/Dictionaries/Currencies.php index f18918ab1b5..bfee3a26bae 100644 --- a/src/Dictionaries/Currencies.php +++ b/src/Dictionaries/Currencies.php @@ -16,15 +16,14 @@ protected function getItems(): array { return [ ['code' => 'AED', 'name' => __('statamic::dictionary-currencies.AED'), 'symbol' => 'د.إ.‏', 'decimals' => 2], - ['code' => 'AFN', 'name' => __('statamic::dictionary-currencies.AFN'), 'symbol' => '؋', 'decimals' => 0], - ['code' => 'ALL', 'name' => __('statamic::dictionary-currencies.ALL'), 'symbol' => 'Lek', 'decimals' => 0], - ['code' => 'AMD', 'name' => __('statamic::dictionary-currencies.AMD'), 'symbol' => 'դր.', 'decimals' => 0], + ['code' => 'AFN', 'name' => __('statamic::dictionary-currencies.AFN'), 'symbol' => '؋', 'decimals' => 2], + ['code' => 'ALL', 'name' => __('statamic::dictionary-currencies.ALL'), 'symbol' => 'Lek', 'decimals' => 2], + ['code' => 'AMD', 'name' => __('statamic::dictionary-currencies.AMD'), 'symbol' => 'դր.', 'decimals' => 2], ['code' => 'ARS', 'name' => __('statamic::dictionary-currencies.ARS'), 'symbol' => '$', 'decimals' => 2], ['code' => 'AUD', 'name' => __('statamic::dictionary-currencies.AUD'), 'symbol' => '$', 'decimals' => 2], ['code' => 'AZN', 'name' => __('statamic::dictionary-currencies.AZN'), 'symbol' => 'ман.', 'decimals' => 2], ['code' => 'BAM', 'name' => __('statamic::dictionary-currencies.BAM'), 'symbol' => 'KM', 'decimals' => 2], ['code' => 'BDT', 'name' => __('statamic::dictionary-currencies.BDT'), 'symbol' => '৳', 'decimals' => 2], - ['code' => 'BGN', 'name' => __('statamic::dictionary-currencies.BGN'), 'symbol' => 'лв.', 'decimals' => 2], ['code' => 'BHD', 'name' => __('statamic::dictionary-currencies.BHD'), 'symbol' => 'د.ب.‏', 'decimals' => 3], ['code' => 'BIF', 'name' => __('statamic::dictionary-currencies.BIF'), 'symbol' => 'FBu', 'decimals' => 0], ['code' => 'BND', 'name' => __('statamic::dictionary-currencies.BND'), 'symbol' => '$', 'decimals' => 2], @@ -38,15 +37,14 @@ protected function getItems(): array ['code' => 'CHF', 'name' => __('statamic::dictionary-currencies.CHF'), 'symbol' => 'CHF', 'decimals' => 2], ['code' => 'CLP', 'name' => __('statamic::dictionary-currencies.CLP'), 'symbol' => '$', 'decimals' => 0], ['code' => 'CNY', 'name' => __('statamic::dictionary-currencies.CNY'), 'symbol' => 'CN¥', 'decimals' => 2], - ['code' => 'COP', 'name' => __('statamic::dictionary-currencies.COP'), 'symbol' => '$', 'decimals' => 0], - ['code' => 'CRC', 'name' => __('statamic::dictionary-currencies.CRC'), 'symbol' => '₡', 'decimals' => 0], + ['code' => 'COP', 'name' => __('statamic::dictionary-currencies.COP'), 'symbol' => '$', 'decimals' => 2], + ['code' => 'CRC', 'name' => __('statamic::dictionary-currencies.CRC'), 'symbol' => '₡', 'decimals' => 2], ['code' => 'CVE', 'name' => __('statamic::dictionary-currencies.CVE'), 'symbol' => 'CV$', 'decimals' => 2], ['code' => 'CZK', 'name' => __('statamic::dictionary-currencies.CZK'), 'symbol' => 'Kč', 'decimals' => 2], ['code' => 'DJF', 'name' => __('statamic::dictionary-currencies.DJF'), 'symbol' => 'Fdj', 'decimals' => 0], ['code' => 'DKK', 'name' => __('statamic::dictionary-currencies.DKK'), 'symbol' => 'kr', 'decimals' => 2], ['code' => 'DOP', 'name' => __('statamic::dictionary-currencies.DOP'), 'symbol' => 'RD$', 'decimals' => 2], ['code' => 'DZD', 'name' => __('statamic::dictionary-currencies.DZD'), 'symbol' => 'د.ج.‏', 'decimals' => 2], - ['code' => 'EEK', 'name' => __('statamic::dictionary-currencies.EEK'), 'symbol' => 'kr', 'decimals' => 2], ['code' => 'EGP', 'name' => __('statamic::dictionary-currencies.EGP'), 'symbol' => 'ج.م.‏', 'decimals' => 2], ['code' => 'ERN', 'name' => __('statamic::dictionary-currencies.ERN'), 'symbol' => 'Nfk', 'decimals' => 2], ['code' => 'ETB', 'name' => __('statamic::dictionary-currencies.ETB'), 'symbol' => 'Br', 'decimals' => 2], @@ -58,13 +56,12 @@ protected function getItems(): array ['code' => 'GTQ', 'name' => __('statamic::dictionary-currencies.GTQ'), 'symbol' => 'Q', 'decimals' => 2], ['code' => 'HKD', 'name' => __('statamic::dictionary-currencies.HKD'), 'symbol' => '$', 'decimals' => 2], ['code' => 'HNL', 'name' => __('statamic::dictionary-currencies.HNL'), 'symbol' => 'L', 'decimals' => 2], - ['code' => 'HRK', 'name' => __('statamic::dictionary-currencies.HRK'), 'symbol' => 'kn', 'decimals' => 2], - ['code' => 'HUF', 'name' => __('statamic::dictionary-currencies.HUF'), 'symbol' => 'Ft', 'decimals' => 0], - ['code' => 'IDR', 'name' => __('statamic::dictionary-currencies.IDR'), 'symbol' => 'Rp', 'decimals' => 0], + ['code' => 'HUF', 'name' => __('statamic::dictionary-currencies.HUF'), 'symbol' => 'Ft', 'decimals' => 2], + ['code' => 'IDR', 'name' => __('statamic::dictionary-currencies.IDR'), 'symbol' => 'Rp', 'decimals' => 2], ['code' => 'ILS', 'name' => __('statamic::dictionary-currencies.ILS'), 'symbol' => '₪', 'decimals' => 2], ['code' => 'INR', 'name' => __('statamic::dictionary-currencies.INR'), 'symbol' => 'টকা', 'decimals' => 2], - ['code' => 'IQD', 'name' => __('statamic::dictionary-currencies.IQD'), 'symbol' => 'د.ع.‏', 'decimals' => 0], - ['code' => 'IRR', 'name' => __('statamic::dictionary-currencies.IRR'), 'symbol' => '﷼', 'decimals' => 0], + ['code' => 'IQD', 'name' => __('statamic::dictionary-currencies.IQD'), 'symbol' => 'د.ع.‏', 'decimals' => 3], + ['code' => 'IRR', 'name' => __('statamic::dictionary-currencies.IRR'), 'symbol' => '﷼', 'decimals' => 2], ['code' => 'ISK', 'name' => __('statamic::dictionary-currencies.ISK'), 'symbol' => 'kr', 'decimals' => 0], ['code' => 'JMD', 'name' => __('statamic::dictionary-currencies.JMD'), 'symbol' => '$', 'decimals' => 2], ['code' => 'JOD', 'name' => __('statamic::dictionary-currencies.JOD'), 'symbol' => 'د.أ.‏', 'decimals' => 3], @@ -75,18 +72,16 @@ protected function getItems(): array ['code' => 'KRW', 'name' => __('statamic::dictionary-currencies.KRW'), 'symbol' => '₩', 'decimals' => 0], ['code' => 'KWD', 'name' => __('statamic::dictionary-currencies.KWD'), 'symbol' => 'د.ك.‏', 'decimals' => 3], ['code' => 'KZT', 'name' => __('statamic::dictionary-currencies.KZT'), 'symbol' => 'тңг.', 'decimals' => 2], - ['code' => 'LBP', 'name' => __('statamic::dictionary-currencies.LBP'), 'symbol' => 'ل.ل.‏', 'decimals' => 0], + ['code' => 'LBP', 'name' => __('statamic::dictionary-currencies.LBP'), 'symbol' => 'ل.ل.‏', 'decimals' => 2], ['code' => 'LKR', 'name' => __('statamic::dictionary-currencies.LKR'), 'symbol' => 'SL Re', 'decimals' => 2], - ['code' => 'LTL', 'name' => __('statamic::dictionary-currencies.LTL'), 'symbol' => 'Lt', 'decimals' => 2], - ['code' => 'LVL', 'name' => __('statamic::dictionary-currencies.LVL'), 'symbol' => 'Ls', 'decimals' => 2], ['code' => 'LYD', 'name' => __('statamic::dictionary-currencies.LYD'), 'symbol' => 'د.ل.‏', 'decimals' => 3], ['code' => 'MAD', 'name' => __('statamic::dictionary-currencies.MAD'), 'symbol' => 'د.م.‏', 'decimals' => 2], ['code' => 'MDL', 'name' => __('statamic::dictionary-currencies.MDL'), 'symbol' => 'MDL', 'decimals' => 2], - ['code' => 'MGA', 'name' => __('statamic::dictionary-currencies.MGA'), 'symbol' => 'MGA', 'decimals' => 0], + ['code' => 'MGA', 'name' => __('statamic::dictionary-currencies.MGA'), 'symbol' => 'MGA', 'decimals' => 2], ['code' => 'MKD', 'name' => __('statamic::dictionary-currencies.MKD'), 'symbol' => 'MKD', 'decimals' => 2], - ['code' => 'MMK', 'name' => __('statamic::dictionary-currencies.MMK'), 'symbol' => 'K', 'decimals' => 0], + ['code' => 'MMK', 'name' => __('statamic::dictionary-currencies.MMK'), 'symbol' => 'K', 'decimals' => 2], ['code' => 'MOP', 'name' => __('statamic::dictionary-currencies.MOP'), 'symbol' => 'MOP$', 'decimals' => 2], - ['code' => 'MUR', 'name' => __('statamic::dictionary-currencies.MUR'), 'symbol' => 'MURs', 'decimals' => 0], + ['code' => 'MUR', 'name' => __('statamic::dictionary-currencies.MUR'), 'symbol' => 'MURs', 'decimals' => 2], ['code' => 'MXN', 'name' => __('statamic::dictionary-currencies.MXN'), 'symbol' => '$', 'decimals' => 2], ['code' => 'MYR', 'name' => __('statamic::dictionary-currencies.MYR'), 'symbol' => 'RM', 'decimals' => 2], ['code' => 'MZN', 'name' => __('statamic::dictionary-currencies.MZN'), 'symbol' => 'MTn', 'decimals' => 2], @@ -100,40 +95,40 @@ protected function getItems(): array ['code' => 'PAB', 'name' => __('statamic::dictionary-currencies.PAB'), 'symbol' => 'B/.', 'decimals' => 2], ['code' => 'PEN', 'name' => __('statamic::dictionary-currencies.PEN'), 'symbol' => 'S/.', 'decimals' => 2], ['code' => 'PHP', 'name' => __('statamic::dictionary-currencies.PHP'), 'symbol' => '₱', 'decimals' => 2], - ['code' => 'PKR', 'name' => __('statamic::dictionary-currencies.PKR'), 'symbol' => '₨', 'decimals' => 0], + ['code' => 'PKR', 'name' => __('statamic::dictionary-currencies.PKR'), 'symbol' => '₨', 'decimals' => 2], ['code' => 'PLN', 'name' => __('statamic::dictionary-currencies.PLN'), 'symbol' => 'zł', 'decimals' => 2], ['code' => 'PYG', 'name' => __('statamic::dictionary-currencies.PYG'), 'symbol' => '₲', 'decimals' => 0], ['code' => 'QAR', 'name' => __('statamic::dictionary-currencies.QAR'), 'symbol' => 'ر.ق.‏', 'decimals' => 2], ['code' => 'RON', 'name' => __('statamic::dictionary-currencies.RON'), 'symbol' => 'RON', 'decimals' => 2], - ['code' => 'RSD', 'name' => __('statamic::dictionary-currencies.RSD'), 'symbol' => 'дин.', 'decimals' => 0], + ['code' => 'RSD', 'name' => __('statamic::dictionary-currencies.RSD'), 'symbol' => 'дин.', 'decimals' => 2], ['code' => 'RUB', 'name' => __('statamic::dictionary-currencies.RUB'), 'symbol' => '₽.', 'decimals' => 2], ['code' => 'RWF', 'name' => __('statamic::dictionary-currencies.RWF'), 'symbol' => 'FR', 'decimals' => 0], ['code' => 'SAR', 'name' => __('statamic::dictionary-currencies.SAR'), 'symbol' => 'ر.س.‏', 'decimals' => 2], ['code' => 'SDG', 'name' => __('statamic::dictionary-currencies.SDG'), 'symbol' => 'SDG', 'decimals' => 2], ['code' => 'SEK', 'name' => __('statamic::dictionary-currencies.SEK'), 'symbol' => 'kr', 'decimals' => 2], ['code' => 'SGD', 'name' => __('statamic::dictionary-currencies.SGD'), 'symbol' => '$', 'decimals' => 2], - ['code' => 'SOS', 'name' => __('statamic::dictionary-currencies.SOS'), 'symbol' => 'Ssh', 'decimals' => 0], - ['code' => 'SYP', 'name' => __('statamic::dictionary-currencies.SYP'), 'symbol' => 'ل.س.‏', 'decimals' => 0], + ['code' => 'SOS', 'name' => __('statamic::dictionary-currencies.SOS'), 'symbol' => 'Ssh', 'decimals' => 2], + ['code' => 'SYP', 'name' => __('statamic::dictionary-currencies.SYP'), 'symbol' => 'ل.س.‏', 'decimals' => 2], ['code' => 'THB', 'name' => __('statamic::dictionary-currencies.THB'), 'symbol' => '฿', 'decimals' => 2], ['code' => 'TND', 'name' => __('statamic::dictionary-currencies.TND'), 'symbol' => 'د.ت.‏', 'decimals' => 3], ['code' => 'TOP', 'name' => __('statamic::dictionary-currencies.TOP'), 'symbol' => 'T$', 'decimals' => 2], ['code' => 'TRY', 'name' => __('statamic::dictionary-currencies.TRY'), 'symbol' => 'TL', 'decimals' => 2], ['code' => 'TTD', 'name' => __('statamic::dictionary-currencies.TTD'), 'symbol' => '$', 'decimals' => 2], ['code' => 'TWD', 'name' => __('statamic::dictionary-currencies.TWD'), 'symbol' => 'NT$', 'decimals' => 2], - ['code' => 'TZS', 'name' => __('statamic::dictionary-currencies.TZS'), 'symbol' => 'TSh', 'decimals' => 0], + ['code' => 'TZS', 'name' => __('statamic::dictionary-currencies.TZS'), 'symbol' => 'TSh', 'decimals' => 2], ['code' => 'UAH', 'name' => __('statamic::dictionary-currencies.UAH'), 'symbol' => '₴', 'decimals' => 2], ['code' => 'UGX', 'name' => __('statamic::dictionary-currencies.UGX'), 'symbol' => 'USh', 'decimals' => 0], ['code' => 'USD', 'name' => __('statamic::dictionary-currencies.USD'), 'symbol' => '$', 'decimals' => 2], ['code' => 'UYU', 'name' => __('statamic::dictionary-currencies.UYU'), 'symbol' => '$', 'decimals' => 2], - ['code' => 'UZS', 'name' => __('statamic::dictionary-currencies.UZS'), 'symbol' => 'UZS', 'decimals' => 0], - ['code' => 'VEF', 'name' => __('statamic::dictionary-currencies.VEF'), 'symbol' => 'Bs.F.', 'decimals' => 2], + ['code' => 'UZS', 'name' => __('statamic::dictionary-currencies.UZS'), 'symbol' => 'UZS', 'decimals' => 2], + ['code' => 'VES', 'name' => __('statamic::dictionary-currencies.VES'), 'symbol' => 'Bs.S.', 'decimals' => 2], ['code' => 'VND', 'name' => __('statamic::dictionary-currencies.VND'), 'symbol' => '₫', 'decimals' => 0], ['code' => 'XAF', 'name' => __('statamic::dictionary-currencies.XAF'), 'symbol' => 'FCFA', 'decimals' => 0], ['code' => 'XOF', 'name' => __('statamic::dictionary-currencies.XOF'), 'symbol' => 'CFA', 'decimals' => 0], - ['code' => 'YER', 'name' => __('statamic::dictionary-currencies.YER'), 'symbol' => 'ر.ي.‏', 'decimals' => 0], + ['code' => 'YER', 'name' => __('statamic::dictionary-currencies.YER'), 'symbol' => 'ر.ي.‏', 'decimals' => 2], ['code' => 'ZAR', 'name' => __('statamic::dictionary-currencies.ZAR'), 'symbol' => 'R', 'decimals' => 2], - ['code' => 'ZMK', 'name' => __('statamic::dictionary-currencies.ZMK'), 'symbol' => 'ZK', 'decimals' => 0], - ['code' => 'ZWL', 'name' => __('statamic::dictionary-currencies.ZWL'), 'symbol' => 'ZWL$', 'decimals' => 0], + ['code' => 'ZMW', 'name' => __('statamic::dictionary-currencies.ZMW'), 'symbol' => 'ZK', 'decimals' => 2], + ['code' => 'ZWG', 'name' => __('statamic::dictionary-currencies.ZWG'), 'symbol' => '$', 'decimals' => 2], ]; } } diff --git a/src/Entries/Collection.php b/src/Entries/Collection.php index 8c89afe4be6..179389248e2 100644 --- a/src/Entries/Collection.php +++ b/src/Entries/Collection.php @@ -10,6 +10,7 @@ use Statamic\Data\ContainsCascadingData; use Statamic\Data\ExistsAsFile; use Statamic\Data\HasAugmentedData; +use Statamic\Data\HasDirtyState; use Statamic\Events\CollectionCreated; use Statamic\Events\CollectionCreating; use Statamic\Events\CollectionDeleted; @@ -32,9 +33,11 @@ use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; +use function Statamic\trans as __; + class Collection implements Arrayable, ArrayAccess, AugmentableContract, Contract { - use ContainsCascadingData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData; + use ContainsCascadingData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData, HasDirtyState; protected $handle; protected $routes = []; @@ -265,6 +268,14 @@ public function showUrl() return cp_route('collections.show', $this->handle()); } + public function breadcrumbUrl() + { + $referer = request()->header('referer'); + $showUrl = $this->showUrl(); + + return $referer && Str::before($referer, '?') === $showUrl ? $referer : $showUrl; + } + public function editUrl() { return cp_route('collections.edit', $this->handle()); @@ -471,6 +482,8 @@ public function save() { $isNew = ! Facades\Collection::handleExists($this->handle); + Blink::forget("collection-{$this->id()}-structure"); + $withEvents = $this->withEvents; $this->withEvents = true; @@ -486,6 +499,10 @@ public function save() Facades\Collection::save($this); + if ($this->isDirty('route')) { + Facades\Entry::updateUris($this); + } + Blink::forget('collection-handles'); Blink::forget('mounted-collections'); Blink::flushStartingWith("collection-{$this->id()}"); @@ -498,6 +515,8 @@ public function save() CollectionSaved::dispatch($this); } + $this->syncOriginal(); + return $this; } @@ -928,4 +947,9 @@ public function augmentedArrayData() 'handle' => $this->handle(), ]; } + + public function getCurrentDirtyStateAttributes(): array + { + return $this->fileData(); + } } diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 709e800770b..0c1c1f228d6 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -52,6 +52,7 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; +use Statamic\View\Cascade; class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Contract, Localization, Protectable, ResolvesValuesContract, Responsable, SearchableContract { @@ -88,6 +89,12 @@ public function __construct() $this->supplements = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; + } + public function id($id = null) { return $this->fluentlyGetOrSet('id')->args(func_get_args()); @@ -410,7 +417,7 @@ public function save() $this->taxonomize(); - if ($this->isDirty('slug')) { + if ($this->shouldUpdateUris()) { optional(Collection::findByMount($this))->updateEntryUris(); $this->updateChildPageUris(); } @@ -430,8 +437,8 @@ public function save() if ($isNew && ! $this->hasOrigin() && $this->collection()->propagate()) { $this->collection()->sites() ->reject($this->site()->handle()) - ->each(function ($siteHandle) { - $this->makeLocalization($siteHandle)->save(); + ->each(function ($siteHandle) use ($withEvents) { + $this->makeLocalization($siteHandle)->{$withEvents ? 'save' : 'saveQuietly'}(); }); } @@ -442,6 +449,21 @@ public function save() return true; } + private function shouldUpdateUris(): bool + { + if (! $this->route()) { + return false; + } + + $antlersRoute = preg_replace_callback('/(?route()); + + return collect(Antlers::identifiers($antlersRoute)) + ->filter(fn (string $identifier) => $this->isDirty($identifier)) + ->isNotEmpty(); + } + private function updateChildPageUris() { $collection = $this->collection(); @@ -610,7 +632,13 @@ public function hasTime() return false; } - return $this->blueprint()->field('date')->fieldtype()->timeEnabled(); + $timeEnabled = $this->blueprint()->field('date')->fieldtype()->timeEnabled(); + + if ($this->origin && ! $this->origin()) { + Blink::forget("entry-{$this->id()}-blueprint"); + } + + return $timeEnabled; } public function hasSeconds() @@ -622,6 +650,11 @@ public function hasSeconds() return $this->blueprint()->field('date')->fieldtype()->secondsEnabled(); } + public function hasExplicitDate(): bool + { + return $this->hasDate() && $this->date; + } + public function sites() { return $this->collection()->sites(); @@ -693,7 +726,7 @@ public function makeFromRevision($revision) ->slug($attrs['slug']); if ($this->collection()->dated() && ($date = Arr::get($attrs, 'date'))) { - $entry->date(Carbon::createFromTimestamp($date)); + $entry->date(Carbon::createFromTimestamp($date, config('app.timezone'))); } return $entry; @@ -947,7 +980,7 @@ public static function __callStatic($method, $parameters) protected function getOriginByString($origin) { - return Facades\Entry::find($origin); + return $this->collection()->queryEntries()->where('id', $origin)->first(); } protected function getOriginFallbackValues() @@ -1009,7 +1042,7 @@ public function autoGeneratedTitle() // Since the slug is generated from the title, we'll avoid augmenting // the slug which could result in an infinite loop in some cases. - $title = $this->withLocale($this->site()->locale(), fn () => (string) Antlers::parse($format, $this->augmented()->except('slug')->all())); + $title = $this->withLocale($this->site()->lang(), fn () => (string) Antlers::parseUserContent($format, $this->augmented()->except('slug')->all())); return trim($title); } @@ -1033,8 +1066,8 @@ private function resolvePreviewTargetUrl($format) }, $format); } - return (string) Antlers::parse($format, array_merge($this->routeData(), [ - 'config' => config()->all(), + return (string) Antlers::parseUserContent($format, array_merge($this->routeData(), [ + 'config' => Cascade::config(), 'site' => $this->site(), 'uri' => $this->uri(), 'url' => $this->url(), diff --git a/src/Entries/MinuteEntries.php b/src/Entries/MinuteEntries.php index 5a39f73f986..13a21d52f39 100644 --- a/src/Entries/MinuteEntries.php +++ b/src/Entries/MinuteEntries.php @@ -2,13 +2,13 @@ namespace Statamic\Entries; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Statamic\Facades\Collection; use Statamic\Facades\Entry; class MinuteEntries { - public function __construct(private readonly Carbon $minute) + public function __construct(private readonly CarbonInterface $minute) { } diff --git a/src/Events/AssetContainerBlueprintFound.php b/src/Events/AssetContainerBlueprintFound.php index 8bb6218d2c1..36374aad53a 100644 --- a/src/Events/AssetContainerBlueprintFound.php +++ b/src/Events/AssetContainerBlueprintFound.php @@ -4,14 +4,7 @@ class AssetContainerBlueprintFound extends Event { - public $blueprint; - public $container; - public $asset; - - public function __construct($blueprint, $container = null, $asset = null) + public function __construct(public $blueprint, public $container = null, public $asset = null) { - $this->blueprint = $blueprint; - $this->container = $container; - $this->asset = $asset; } } diff --git a/src/Events/AssetContainerCreated.php b/src/Events/AssetContainerCreated.php index 9d89f21e836..1254f6c1ecf 100644 --- a/src/Events/AssetContainerCreated.php +++ b/src/Events/AssetContainerCreated.php @@ -4,10 +4,7 @@ class AssetContainerCreated extends Event { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } } diff --git a/src/Events/AssetContainerCreating.php b/src/Events/AssetContainerCreating.php index 661015a402e..a621cdfd638 100644 --- a/src/Events/AssetContainerCreating.php +++ b/src/Events/AssetContainerCreating.php @@ -4,11 +4,8 @@ class AssetContainerCreating extends Event { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } /** diff --git a/src/Events/AssetContainerDeleted.php b/src/Events/AssetContainerDeleted.php index d622e31290f..fe14939ab60 100644 --- a/src/Events/AssetContainerDeleted.php +++ b/src/Events/AssetContainerDeleted.php @@ -6,11 +6,8 @@ class AssetContainerDeleted extends Event implements ProvidesCommitMessage { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } public function commitMessage() diff --git a/src/Events/AssetContainerDeleting.php b/src/Events/AssetContainerDeleting.php index 8a13dfcb567..2f0d7908096 100644 --- a/src/Events/AssetContainerDeleting.php +++ b/src/Events/AssetContainerDeleting.php @@ -4,11 +4,8 @@ class AssetContainerDeleting extends Event { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } /** diff --git a/src/Events/AssetContainerSaved.php b/src/Events/AssetContainerSaved.php index 4029cb10482..aacacad6921 100644 --- a/src/Events/AssetContainerSaved.php +++ b/src/Events/AssetContainerSaved.php @@ -6,11 +6,8 @@ class AssetContainerSaved extends Event implements ProvidesCommitMessage { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } public function commitMessage() diff --git a/src/Events/AssetContainerSaving.php b/src/Events/AssetContainerSaving.php index 481a98d1bd0..f71c9240741 100644 --- a/src/Events/AssetContainerSaving.php +++ b/src/Events/AssetContainerSaving.php @@ -4,11 +4,8 @@ class AssetContainerSaving extends Event { - public $container; - - public function __construct($container) + public function __construct(public $container) { - $this->container = $container; } /** diff --git a/src/Events/AssetCreated.php b/src/Events/AssetCreated.php index d01a0483787..0588dd148a3 100644 --- a/src/Events/AssetCreated.php +++ b/src/Events/AssetCreated.php @@ -4,10 +4,7 @@ class AssetCreated extends Event { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } } diff --git a/src/Events/AssetCreating.php b/src/Events/AssetCreating.php index 887d9d07a19..a375c80c9b0 100644 --- a/src/Events/AssetCreating.php +++ b/src/Events/AssetCreating.php @@ -4,11 +4,8 @@ class AssetCreating extends Event { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } /** diff --git a/src/Events/AssetDeleted.php b/src/Events/AssetDeleted.php index 707cb28f87b..9b5d33e1e64 100644 --- a/src/Events/AssetDeleted.php +++ b/src/Events/AssetDeleted.php @@ -6,11 +6,8 @@ class AssetDeleted extends Event implements ProvidesCommitMessage { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } public function commitMessage() diff --git a/src/Events/AssetDeleting.php b/src/Events/AssetDeleting.php index b278a86d146..2eadb47767a 100644 --- a/src/Events/AssetDeleting.php +++ b/src/Events/AssetDeleting.php @@ -4,11 +4,8 @@ class AssetDeleting extends Event { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } /** diff --git a/src/Events/AssetFolderDeleted.php b/src/Events/AssetFolderDeleted.php index 8b9b5656f3f..2e7503f5ca9 100644 --- a/src/Events/AssetFolderDeleted.php +++ b/src/Events/AssetFolderDeleted.php @@ -6,11 +6,8 @@ class AssetFolderDeleted extends Event implements ProvidesCommitMessage { - public $folder; - - public function __construct($folder) + public function __construct(public $folder) { - $this->folder = $folder; } public function commitMessage() diff --git a/src/Events/AssetFolderSaved.php b/src/Events/AssetFolderSaved.php index b7706ba6094..63ff5301e19 100644 --- a/src/Events/AssetFolderSaved.php +++ b/src/Events/AssetFolderSaved.php @@ -6,11 +6,8 @@ class AssetFolderSaved extends Event implements ProvidesCommitMessage { - public $folder; - - public function __construct($folder) + public function __construct(public $folder) { - $this->folder = $folder; } public function commitMessage() diff --git a/src/Events/AssetReferencesUpdated.php b/src/Events/AssetReferencesUpdated.php index 85ff63b9024..a9a57041fab 100644 --- a/src/Events/AssetReferencesUpdated.php +++ b/src/Events/AssetReferencesUpdated.php @@ -6,11 +6,8 @@ class AssetReferencesUpdated extends Event implements ProvidesCommitMessage { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } public function commitMessage() diff --git a/src/Events/AssetReplaced.php b/src/Events/AssetReplaced.php index 42eb05543a6..466053c619e 100644 --- a/src/Events/AssetReplaced.php +++ b/src/Events/AssetReplaced.php @@ -4,12 +4,7 @@ class AssetReplaced extends Event { - public $originalAsset; - public $newAsset; - - public function __construct($originalAsset, $newAsset) + public function __construct(public $originalAsset, public $newAsset) { - $this->originalAsset = $originalAsset; - $this->newAsset = $newAsset; } } diff --git a/src/Events/AssetReuploaded.php b/src/Events/AssetReuploaded.php index 0efc2f0d0b3..43046af1608 100644 --- a/src/Events/AssetReuploaded.php +++ b/src/Events/AssetReuploaded.php @@ -6,11 +6,8 @@ class AssetReuploaded extends Event implements ProvidesCommitMessage { - public $asset; - - public function __construct($asset) + public function __construct(public $asset, public $originalFilename) { - $this->asset = $asset; } public function commitMessage() diff --git a/src/Events/AssetSaved.php b/src/Events/AssetSaved.php index 44d0c7a6f86..8c6cd86707e 100644 --- a/src/Events/AssetSaved.php +++ b/src/Events/AssetSaved.php @@ -6,11 +6,8 @@ class AssetSaved extends Event implements ProvidesCommitMessage { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } public function commitMessage() diff --git a/src/Events/AssetSaving.php b/src/Events/AssetSaving.php index 4d766d799b6..535f75b9381 100644 --- a/src/Events/AssetSaving.php +++ b/src/Events/AssetSaving.php @@ -4,11 +4,8 @@ class AssetSaving extends Event { - public $asset; - - public function __construct($asset) + public function __construct(public $asset) { - $this->asset = $asset; } /** diff --git a/src/Events/AssetUploaded.php b/src/Events/AssetUploaded.php index 915d9ffb163..2337d4599f2 100644 --- a/src/Events/AssetUploaded.php +++ b/src/Events/AssetUploaded.php @@ -6,11 +6,8 @@ class AssetUploaded extends Event implements ProvidesCommitMessage { - public $asset; - - public function __construct($asset) + public function __construct(public $asset, public $originalFilename) { - $this->asset = $asset; } public function commitMessage() diff --git a/src/Events/BlueprintCreated.php b/src/Events/BlueprintCreated.php index 8d0920b3e22..0632c267ab7 100644 --- a/src/Events/BlueprintCreated.php +++ b/src/Events/BlueprintCreated.php @@ -4,10 +4,7 @@ class BlueprintCreated extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } } diff --git a/src/Events/BlueprintCreating.php b/src/Events/BlueprintCreating.php index 51b630bc9d6..44425253d5c 100644 --- a/src/Events/BlueprintCreating.php +++ b/src/Events/BlueprintCreating.php @@ -4,11 +4,8 @@ class BlueprintCreating extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } /** diff --git a/src/Events/BlueprintDeleted.php b/src/Events/BlueprintDeleted.php index befdcc604ee..81ad7727deb 100644 --- a/src/Events/BlueprintDeleted.php +++ b/src/Events/BlueprintDeleted.php @@ -6,11 +6,8 @@ class BlueprintDeleted extends Event implements ProvidesCommitMessage { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } public function commitMessage() diff --git a/src/Events/BlueprintDeleting.php b/src/Events/BlueprintDeleting.php index 5e9729424da..9d3b3f8330c 100644 --- a/src/Events/BlueprintDeleting.php +++ b/src/Events/BlueprintDeleting.php @@ -4,11 +4,8 @@ class BlueprintDeleting extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } /** diff --git a/src/Events/BlueprintReset.php b/src/Events/BlueprintReset.php index 2de760bb2e6..1a074e801b6 100644 --- a/src/Events/BlueprintReset.php +++ b/src/Events/BlueprintReset.php @@ -6,11 +6,8 @@ class BlueprintReset extends Event implements ProvidesCommitMessage { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } public function commitMessage() diff --git a/src/Events/BlueprintSaved.php b/src/Events/BlueprintSaved.php index 2380ba2b38d..9aa43ae0eb6 100644 --- a/src/Events/BlueprintSaved.php +++ b/src/Events/BlueprintSaved.php @@ -6,11 +6,8 @@ class BlueprintSaved extends Event implements ProvidesCommitMessage { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } public function commitMessage() diff --git a/src/Events/BlueprintSaving.php b/src/Events/BlueprintSaving.php index 9d1a6973a01..1129baa2b97 100644 --- a/src/Events/BlueprintSaving.php +++ b/src/Events/BlueprintSaving.php @@ -4,11 +4,8 @@ class BlueprintSaving extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } /** diff --git a/src/Events/CollectionCreated.php b/src/Events/CollectionCreated.php index 2665be37602..3b015b875f0 100644 --- a/src/Events/CollectionCreated.php +++ b/src/Events/CollectionCreated.php @@ -4,10 +4,7 @@ class CollectionCreated extends Event { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } } diff --git a/src/Events/CollectionCreating.php b/src/Events/CollectionCreating.php index ab464139264..f60b8588a26 100644 --- a/src/Events/CollectionCreating.php +++ b/src/Events/CollectionCreating.php @@ -4,11 +4,8 @@ class CollectionCreating extends Event { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } /** diff --git a/src/Events/CollectionDeleted.php b/src/Events/CollectionDeleted.php index e9f2944c846..20ac5bd0d89 100644 --- a/src/Events/CollectionDeleted.php +++ b/src/Events/CollectionDeleted.php @@ -6,11 +6,8 @@ class CollectionDeleted extends Event implements ProvidesCommitMessage { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } public function commitMessage() diff --git a/src/Events/CollectionDeleting.php b/src/Events/CollectionDeleting.php index 43df645dd1e..d216bf60431 100644 --- a/src/Events/CollectionDeleting.php +++ b/src/Events/CollectionDeleting.php @@ -4,11 +4,8 @@ class CollectionDeleting extends Event { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } /** diff --git a/src/Events/CollectionSaved.php b/src/Events/CollectionSaved.php index 48df90b6594..320c81cb35a 100644 --- a/src/Events/CollectionSaved.php +++ b/src/Events/CollectionSaved.php @@ -6,11 +6,8 @@ class CollectionSaved extends Event implements ProvidesCommitMessage { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } public function commitMessage() diff --git a/src/Events/CollectionSaving.php b/src/Events/CollectionSaving.php index b27e185d486..4d028036832 100644 --- a/src/Events/CollectionSaving.php +++ b/src/Events/CollectionSaving.php @@ -4,11 +4,8 @@ class CollectionSaving extends Event { - public $collection; - - public function __construct($collection) + public function __construct(public $collection) { - $this->collection = $collection; } /** diff --git a/src/Events/CollectionTreeDeleted.php b/src/Events/CollectionTreeDeleted.php index 31123d5b34e..0e49f041db5 100644 --- a/src/Events/CollectionTreeDeleted.php +++ b/src/Events/CollectionTreeDeleted.php @@ -6,11 +6,8 @@ class CollectionTreeDeleted extends Event implements ProvidesCommitMessage { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } public function commitMessage() diff --git a/src/Events/CollectionTreeSaved.php b/src/Events/CollectionTreeSaved.php index e8e537bf333..f8817e897e5 100644 --- a/src/Events/CollectionTreeSaved.php +++ b/src/Events/CollectionTreeSaved.php @@ -6,11 +6,8 @@ class CollectionTreeSaved extends Event implements ProvidesCommitMessage { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } public function commitMessage() diff --git a/src/Events/CollectionTreeSaving.php b/src/Events/CollectionTreeSaving.php index 02537e2f810..27be32b9d31 100644 --- a/src/Events/CollectionTreeSaving.php +++ b/src/Events/CollectionTreeSaving.php @@ -4,11 +4,8 @@ class CollectionTreeSaving extends Event { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } /** diff --git a/src/Events/EntryBlueprintFound.php b/src/Events/EntryBlueprintFound.php index 324b7a49814..823d4e564bd 100644 --- a/src/Events/EntryBlueprintFound.php +++ b/src/Events/EntryBlueprintFound.php @@ -4,12 +4,7 @@ class EntryBlueprintFound extends Event { - public $blueprint; - public $entry; - - public function __construct($blueprint, $entry = null) + public function __construct(public $blueprint, public $entry = null) { - $this->blueprint = $blueprint; - $this->entry = $entry; } } diff --git a/src/Events/EntryCreated.php b/src/Events/EntryCreated.php index 48a2b3ed4fb..8f064e7e932 100644 --- a/src/Events/EntryCreated.php +++ b/src/Events/EntryCreated.php @@ -4,10 +4,7 @@ class EntryCreated extends Event { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } } diff --git a/src/Events/EntryCreating.php b/src/Events/EntryCreating.php index 68b558b5bb6..9e6c782611a 100644 --- a/src/Events/EntryCreating.php +++ b/src/Events/EntryCreating.php @@ -4,11 +4,8 @@ class EntryCreating extends Event { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } /** diff --git a/src/Events/EntryDeleted.php b/src/Events/EntryDeleted.php index b95e13b1123..57a82e8985c 100644 --- a/src/Events/EntryDeleted.php +++ b/src/Events/EntryDeleted.php @@ -6,11 +6,8 @@ class EntryDeleted extends Event implements ProvidesCommitMessage { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } public function commitMessage() diff --git a/src/Events/EntryDeleting.php b/src/Events/EntryDeleting.php index 3b2e64ce8f4..8d62b8104cf 100644 --- a/src/Events/EntryDeleting.php +++ b/src/Events/EntryDeleting.php @@ -4,11 +4,8 @@ class EntryDeleting extends Event { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } /** diff --git a/src/Events/EntrySaved.php b/src/Events/EntrySaved.php index 423ce0ddf6a..50d28ca23c4 100644 --- a/src/Events/EntrySaved.php +++ b/src/Events/EntrySaved.php @@ -7,12 +7,10 @@ class EntrySaved extends Event implements ProvidesCommitMessage { - public $entry; public $initiator; - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; $this->initiator = InitiatorStack::entry($entry)->initiator(); } diff --git a/src/Events/EntrySaving.php b/src/Events/EntrySaving.php index 609a42fd159..06539c14a12 100644 --- a/src/Events/EntrySaving.php +++ b/src/Events/EntrySaving.php @@ -4,11 +4,8 @@ class EntrySaving extends Event { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } /** diff --git a/src/Events/EntryScheduleReached.php b/src/Events/EntryScheduleReached.php index 15196ca2388..421fbf81e22 100644 --- a/src/Events/EntryScheduleReached.php +++ b/src/Events/EntryScheduleReached.php @@ -6,11 +6,8 @@ class EntryScheduleReached extends Event implements ProvidesCommitMessage { - public $entry; - - public function __construct($entry) + public function __construct(public $entry) { - $this->entry = $entry; } public function commitMessage() diff --git a/src/Events/FieldsetCreated.php b/src/Events/FieldsetCreated.php index 691a4d7365d..fbc461ad847 100644 --- a/src/Events/FieldsetCreated.php +++ b/src/Events/FieldsetCreated.php @@ -4,10 +4,7 @@ class FieldsetCreated extends Event { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } } diff --git a/src/Events/FieldsetCreating.php b/src/Events/FieldsetCreating.php index 6685185a132..a26fd6d306c 100644 --- a/src/Events/FieldsetCreating.php +++ b/src/Events/FieldsetCreating.php @@ -4,11 +4,8 @@ class FieldsetCreating extends Event { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } /** diff --git a/src/Events/FieldsetDeleted.php b/src/Events/FieldsetDeleted.php index 15b9d3aca09..c09f766b646 100644 --- a/src/Events/FieldsetDeleted.php +++ b/src/Events/FieldsetDeleted.php @@ -6,11 +6,8 @@ class FieldsetDeleted extends Event implements ProvidesCommitMessage { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } public function commitMessage() diff --git a/src/Events/FieldsetDeleting.php b/src/Events/FieldsetDeleting.php index 86722807bd6..af09dcbfab0 100644 --- a/src/Events/FieldsetDeleting.php +++ b/src/Events/FieldsetDeleting.php @@ -4,11 +4,8 @@ class FieldsetDeleting extends Event { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } /** diff --git a/src/Events/FieldsetReset.php b/src/Events/FieldsetReset.php index c403389e2c3..a6aaba54bc6 100644 --- a/src/Events/FieldsetReset.php +++ b/src/Events/FieldsetReset.php @@ -6,11 +6,8 @@ class FieldsetReset extends Event implements ProvidesCommitMessage { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } public function commitMessage() diff --git a/src/Events/FieldsetSaved.php b/src/Events/FieldsetSaved.php index 166b8be70ad..40f157ca84c 100644 --- a/src/Events/FieldsetSaved.php +++ b/src/Events/FieldsetSaved.php @@ -6,11 +6,8 @@ class FieldsetSaved extends Event implements ProvidesCommitMessage { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } public function commitMessage() diff --git a/src/Events/FieldsetSaving.php b/src/Events/FieldsetSaving.php index bf5f863eec9..ef7ed931132 100644 --- a/src/Events/FieldsetSaving.php +++ b/src/Events/FieldsetSaving.php @@ -4,11 +4,8 @@ class FieldsetSaving extends Event { - public $fieldset; - - public function __construct($fieldset) + public function __construct(public $fieldset) { - $this->fieldset = $fieldset; } /** diff --git a/src/Events/FormBlueprintFound.php b/src/Events/FormBlueprintFound.php index 0603802a5a9..bc64c6e660e 100644 --- a/src/Events/FormBlueprintFound.php +++ b/src/Events/FormBlueprintFound.php @@ -4,12 +4,7 @@ class FormBlueprintFound extends Event { - public $blueprint; - public $form; - - public function __construct($blueprint, $form = null) + public function __construct(public $blueprint, public $form = null) { - $this->blueprint = $blueprint; - $this->form = $form; } } diff --git a/src/Events/FormCreated.php b/src/Events/FormCreated.php index 80248cf58f5..112c72fcb63 100644 --- a/src/Events/FormCreated.php +++ b/src/Events/FormCreated.php @@ -4,10 +4,7 @@ class FormCreated extends Event { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } } diff --git a/src/Events/FormCreating.php b/src/Events/FormCreating.php index e952b178472..e9a86eefdd4 100644 --- a/src/Events/FormCreating.php +++ b/src/Events/FormCreating.php @@ -4,11 +4,8 @@ class FormCreating extends Event { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } /** diff --git a/src/Events/FormDeleted.php b/src/Events/FormDeleted.php index 9625e9e4c18..88e0f2914bf 100644 --- a/src/Events/FormDeleted.php +++ b/src/Events/FormDeleted.php @@ -6,11 +6,8 @@ class FormDeleted extends Event implements ProvidesCommitMessage { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } public function commitMessage() diff --git a/src/Events/FormDeleting.php b/src/Events/FormDeleting.php index 9f004813679..35745cfd34d 100644 --- a/src/Events/FormDeleting.php +++ b/src/Events/FormDeleting.php @@ -4,11 +4,8 @@ class FormDeleting extends Event { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } /** diff --git a/src/Events/FormSaved.php b/src/Events/FormSaved.php index 0eb12a1a764..420b5df4f24 100644 --- a/src/Events/FormSaved.php +++ b/src/Events/FormSaved.php @@ -6,11 +6,8 @@ class FormSaved extends Event implements ProvidesCommitMessage { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } public function commitMessage() diff --git a/src/Events/FormSaving.php b/src/Events/FormSaving.php index 21c49b265dd..513f818485d 100644 --- a/src/Events/FormSaving.php +++ b/src/Events/FormSaving.php @@ -4,11 +4,8 @@ class FormSaving extends Event { - public $form; - - public function __construct($form) + public function __construct(public $form) { - $this->form = $form; } /** diff --git a/src/Events/FormSubmitted.php b/src/Events/FormSubmitted.php index 434887e2091..cb81358b3c1 100644 --- a/src/Events/FormSubmitted.php +++ b/src/Events/FormSubmitted.php @@ -6,11 +6,8 @@ class FormSubmitted extends Event { - public $submission; - - public function __construct(Submission $submission) + public function __construct(public Submission $submission) { - $this->submission = $submission; } /** diff --git a/src/Events/GlideAssetCacheCleared.php b/src/Events/GlideAssetCacheCleared.php new file mode 100644 index 00000000000..b0a837dbfd3 --- /dev/null +++ b/src/Events/GlideAssetCacheCleared.php @@ -0,0 +1,10 @@ +path = $path; - $this->params = $params; } } diff --git a/src/Events/GlobalSetCreated.php b/src/Events/GlobalSetCreated.php index 776bd78b348..ac58490b356 100644 --- a/src/Events/GlobalSetCreated.php +++ b/src/Events/GlobalSetCreated.php @@ -4,10 +4,7 @@ class GlobalSetCreated extends Event { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } } diff --git a/src/Events/GlobalSetCreating.php b/src/Events/GlobalSetCreating.php index 2d8600c38d2..5d535df8f6f 100644 --- a/src/Events/GlobalSetCreating.php +++ b/src/Events/GlobalSetCreating.php @@ -4,11 +4,8 @@ class GlobalSetCreating extends Event { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } /** diff --git a/src/Events/GlobalSetDeleted.php b/src/Events/GlobalSetDeleted.php index a87478b772e..743ff235404 100644 --- a/src/Events/GlobalSetDeleted.php +++ b/src/Events/GlobalSetDeleted.php @@ -6,11 +6,8 @@ class GlobalSetDeleted extends Event implements ProvidesCommitMessage { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } public function commitMessage() diff --git a/src/Events/GlobalSetDeleting.php b/src/Events/GlobalSetDeleting.php index bbbc5549def..570098c1f9d 100644 --- a/src/Events/GlobalSetDeleting.php +++ b/src/Events/GlobalSetDeleting.php @@ -4,11 +4,8 @@ class GlobalSetDeleting extends Event { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } /** diff --git a/src/Events/GlobalSetSaved.php b/src/Events/GlobalSetSaved.php index 3f9ff3afc73..728192644ea 100644 --- a/src/Events/GlobalSetSaved.php +++ b/src/Events/GlobalSetSaved.php @@ -6,11 +6,8 @@ class GlobalSetSaved extends Event implements ProvidesCommitMessage { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } public function commitMessage() diff --git a/src/Events/GlobalSetSaving.php b/src/Events/GlobalSetSaving.php index 6858dff1d1e..1a3b3e6352f 100644 --- a/src/Events/GlobalSetSaving.php +++ b/src/Events/GlobalSetSaving.php @@ -4,11 +4,8 @@ class GlobalSetSaving extends Event { - public $globals; - - public function __construct($globals) + public function __construct(public $globals) { - $this->globals = $globals; } /** diff --git a/src/Events/GlobalVariablesBlueprintFound.php b/src/Events/GlobalVariablesBlueprintFound.php index 7f43cb43188..ea645c1a550 100644 --- a/src/Events/GlobalVariablesBlueprintFound.php +++ b/src/Events/GlobalVariablesBlueprintFound.php @@ -4,12 +4,7 @@ class GlobalVariablesBlueprintFound extends Event { - public $blueprint; - public $globals; - - public function __construct($blueprint, $globals = null) + public function __construct(public $blueprint, public $globals = null) { - $this->blueprint = $blueprint; - $this->globals = $globals; } } diff --git a/src/Events/GlobalVariablesCreated.php b/src/Events/GlobalVariablesCreated.php index ff18949f6f1..a1f0e39c03f 100644 --- a/src/Events/GlobalVariablesCreated.php +++ b/src/Events/GlobalVariablesCreated.php @@ -4,10 +4,7 @@ class GlobalVariablesCreated extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } } diff --git a/src/Events/GlobalVariablesCreating.php b/src/Events/GlobalVariablesCreating.php index 3d224042c43..630a25633f9 100644 --- a/src/Events/GlobalVariablesCreating.php +++ b/src/Events/GlobalVariablesCreating.php @@ -4,11 +4,8 @@ class GlobalVariablesCreating extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } /** diff --git a/src/Events/GlobalVariablesDeleted.php b/src/Events/GlobalVariablesDeleted.php index 44afbf320c1..20df8d7ba76 100644 --- a/src/Events/GlobalVariablesDeleted.php +++ b/src/Events/GlobalVariablesDeleted.php @@ -4,10 +4,7 @@ class GlobalVariablesDeleted extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } } diff --git a/src/Events/GlobalVariablesDeleting.php b/src/Events/GlobalVariablesDeleting.php index 9db8c2e8b96..69c24b3f47b 100644 --- a/src/Events/GlobalVariablesDeleting.php +++ b/src/Events/GlobalVariablesDeleting.php @@ -4,11 +4,8 @@ class GlobalVariablesDeleting extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } /** diff --git a/src/Events/GlobalVariablesSaved.php b/src/Events/GlobalVariablesSaved.php index 93a3538bf6f..62f690f158f 100644 --- a/src/Events/GlobalVariablesSaved.php +++ b/src/Events/GlobalVariablesSaved.php @@ -4,10 +4,7 @@ class GlobalVariablesSaved extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } } diff --git a/src/Events/GlobalVariablesSaving.php b/src/Events/GlobalVariablesSaving.php index df2996f536f..594ea22bc22 100644 --- a/src/Events/GlobalVariablesSaving.php +++ b/src/Events/GlobalVariablesSaving.php @@ -4,11 +4,8 @@ class GlobalVariablesSaving extends Event { - public $variables; - - public function __construct($variables) + public function __construct(public $variables) { - $this->variables = $variables; } /** diff --git a/src/Events/LocalizedTermDeleted.php b/src/Events/LocalizedTermDeleted.php index 6964f4d5ebd..5f72e9210ee 100644 --- a/src/Events/LocalizedTermDeleted.php +++ b/src/Events/LocalizedTermDeleted.php @@ -4,10 +4,7 @@ class LocalizedTermDeleted extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } } diff --git a/src/Events/LocalizedTermSaved.php b/src/Events/LocalizedTermSaved.php index 77af1e0c884..0a50df39047 100644 --- a/src/Events/LocalizedTermSaved.php +++ b/src/Events/LocalizedTermSaved.php @@ -4,10 +4,7 @@ class LocalizedTermSaved extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } } diff --git a/src/Events/NavBlueprintFound.php b/src/Events/NavBlueprintFound.php index 02d69b55eb0..4a200fa5df1 100644 --- a/src/Events/NavBlueprintFound.php +++ b/src/Events/NavBlueprintFound.php @@ -4,12 +4,7 @@ class NavBlueprintFound extends Event { - public $blueprint; - public $nav; - - public function __construct($blueprint, $nav = null) + public function __construct(public $blueprint, public $nav = null) { - $this->blueprint = $blueprint; - $this->nav = $nav; } } diff --git a/src/Events/NavCreated.php b/src/Events/NavCreated.php index b9b29cdba5a..610583f86fe 100644 --- a/src/Events/NavCreated.php +++ b/src/Events/NavCreated.php @@ -4,10 +4,7 @@ class NavCreated extends Event { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } } diff --git a/src/Events/NavCreating.php b/src/Events/NavCreating.php index 5998cb07700..ada7d791e3d 100644 --- a/src/Events/NavCreating.php +++ b/src/Events/NavCreating.php @@ -4,11 +4,8 @@ class NavCreating extends Event { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } /** diff --git a/src/Events/NavDeleted.php b/src/Events/NavDeleted.php index 4e118c40586..b3f4cb2318b 100644 --- a/src/Events/NavDeleted.php +++ b/src/Events/NavDeleted.php @@ -6,11 +6,8 @@ class NavDeleted extends Event implements ProvidesCommitMessage { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } public function commitMessage() diff --git a/src/Events/NavDeleting.php b/src/Events/NavDeleting.php index be0f8d3e259..d64882ffc75 100644 --- a/src/Events/NavDeleting.php +++ b/src/Events/NavDeleting.php @@ -4,11 +4,8 @@ class NavDeleting extends Event { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } /** diff --git a/src/Events/NavSaved.php b/src/Events/NavSaved.php index 55778b9b163..a5f41dcefa7 100644 --- a/src/Events/NavSaved.php +++ b/src/Events/NavSaved.php @@ -6,11 +6,8 @@ class NavSaved extends Event implements ProvidesCommitMessage { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } public function commitMessage() diff --git a/src/Events/NavSaving.php b/src/Events/NavSaving.php index 44df656ba6a..8ae6accbefe 100644 --- a/src/Events/NavSaving.php +++ b/src/Events/NavSaving.php @@ -4,11 +4,8 @@ class NavSaving extends Event { - public $nav; - - public function __construct($nav) + public function __construct(public $nav) { - $this->nav = $nav; } /** diff --git a/src/Events/NavTreeDeleted.php b/src/Events/NavTreeDeleted.php index ea9e056d0a0..0c98073d839 100644 --- a/src/Events/NavTreeDeleted.php +++ b/src/Events/NavTreeDeleted.php @@ -6,11 +6,8 @@ class NavTreeDeleted extends Event implements ProvidesCommitMessage { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } public function commitMessage() diff --git a/src/Events/NavTreeSaved.php b/src/Events/NavTreeSaved.php index a86198f99b2..c96c1a90580 100644 --- a/src/Events/NavTreeSaved.php +++ b/src/Events/NavTreeSaved.php @@ -6,11 +6,8 @@ class NavTreeSaved extends Event implements ProvidesCommitMessage { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } public function commitMessage() diff --git a/src/Events/NavTreeSaving.php b/src/Events/NavTreeSaving.php index 8b1f05b7cb1..58b117e7ab4 100644 --- a/src/Events/NavTreeSaving.php +++ b/src/Events/NavTreeSaving.php @@ -4,11 +4,8 @@ class NavTreeSaving extends Event { - public $tree; - - public function __construct($tree) + public function __construct(public $tree) { - $this->tree = $tree; } /** diff --git a/src/Events/ResponseCreated.php b/src/Events/ResponseCreated.php index f1fd9fa659c..7908de4777a 100644 --- a/src/Events/ResponseCreated.php +++ b/src/Events/ResponseCreated.php @@ -6,16 +6,7 @@ class ResponseCreated extends Event { - /** - * @var Response - */ - public $response; - - public $data; - - public function __construct(Response $response, $data) + public function __construct(public Response $response, public $data) { - $this->response = $response; - $this->data = $data; } } diff --git a/src/Events/RevisionDeleted.php b/src/Events/RevisionDeleted.php index df6d9c5dce0..543c7e00c4d 100644 --- a/src/Events/RevisionDeleted.php +++ b/src/Events/RevisionDeleted.php @@ -6,11 +6,8 @@ class RevisionDeleted extends Event implements ProvidesCommitMessage { - public $revision; - - public function __construct($revision) + public function __construct(public $revision) { - $this->revision = $revision; } public function commitMessage() diff --git a/src/Events/RevisionSaved.php b/src/Events/RevisionSaved.php index bb570e5fc85..d3f7ed361db 100644 --- a/src/Events/RevisionSaved.php +++ b/src/Events/RevisionSaved.php @@ -6,11 +6,8 @@ class RevisionSaved extends Event implements ProvidesCommitMessage { - public $revision; - - public function __construct($revision) + public function __construct(public $revision) { - $this->revision = $revision; } public function commitMessage() diff --git a/src/Events/RevisionSaving.php b/src/Events/RevisionSaving.php index 0385891dcf0..8d295b2f00f 100644 --- a/src/Events/RevisionSaving.php +++ b/src/Events/RevisionSaving.php @@ -4,11 +4,8 @@ class RevisionSaving extends Event { - public $revision; - - public function __construct($revision) + public function __construct(public $revision) { - $this->revision = $revision; } /** diff --git a/src/Events/RoleDeleted.php b/src/Events/RoleDeleted.php index 5325a14426a..16c4651616d 100644 --- a/src/Events/RoleDeleted.php +++ b/src/Events/RoleDeleted.php @@ -6,11 +6,8 @@ class RoleDeleted extends Event implements ProvidesCommitMessage { - public $role; - - public function __construct($role) + public function __construct(public $role) { - $this->role = $role; } public function commitMessage() diff --git a/src/Events/RoleSaved.php b/src/Events/RoleSaved.php index 19da9809893..97e669f7e2b 100644 --- a/src/Events/RoleSaved.php +++ b/src/Events/RoleSaved.php @@ -6,11 +6,8 @@ class RoleSaved extends Event implements ProvidesCommitMessage { - public $role; - - public function __construct($role) + public function __construct(public $role) { - $this->role = $role; } public function commitMessage() diff --git a/src/Events/SiteCreated.php b/src/Events/SiteCreated.php index 19e76434dba..02fdf8ed5f5 100644 --- a/src/Events/SiteCreated.php +++ b/src/Events/SiteCreated.php @@ -8,6 +8,5 @@ class SiteCreated extends Event { public function __construct(public Site $site) { - // } } diff --git a/src/Events/SiteDeleted.php b/src/Events/SiteDeleted.php index eeaf9bebb89..18cbd2c3242 100644 --- a/src/Events/SiteDeleted.php +++ b/src/Events/SiteDeleted.php @@ -9,7 +9,6 @@ class SiteDeleted extends Event implements ProvidesCommitMessage { public function __construct(public Site $site) { - // } public function commitMessage() diff --git a/src/Events/SiteSaved.php b/src/Events/SiteSaved.php index 7507448e290..3032c71ee02 100644 --- a/src/Events/SiteSaved.php +++ b/src/Events/SiteSaved.php @@ -9,7 +9,6 @@ class SiteSaved extends Event implements ProvidesCommitMessage { public function __construct(public Site $site) { - // } public function commitMessage() diff --git a/src/Events/SubmissionCreated.php b/src/Events/SubmissionCreated.php index ec15c6f94fd..227e20f801d 100644 --- a/src/Events/SubmissionCreated.php +++ b/src/Events/SubmissionCreated.php @@ -4,10 +4,7 @@ class SubmissionCreated extends Event { - public $submission; - - public function __construct($submission) + public function __construct(public $submission) { - $this->submission = $submission; } } diff --git a/src/Events/SubmissionCreating.php b/src/Events/SubmissionCreating.php index a2e1dc6a126..a7ad5c930b0 100644 --- a/src/Events/SubmissionCreating.php +++ b/src/Events/SubmissionCreating.php @@ -4,11 +4,8 @@ class SubmissionCreating extends Event { - public $submission; - - public function __construct($submission) + public function __construct(public $submission) { - $this->submission = $submission; } /** diff --git a/src/Events/SubmissionDeleted.php b/src/Events/SubmissionDeleted.php index 6476e75486e..0f45be15563 100644 --- a/src/Events/SubmissionDeleted.php +++ b/src/Events/SubmissionDeleted.php @@ -6,11 +6,8 @@ class SubmissionDeleted extends Event implements ProvidesCommitMessage { - public $submission; - - public function __construct($submission) + public function __construct(public $submission) { - $this->submission = $submission; } public function commitMessage() diff --git a/src/Events/SubmissionSaved.php b/src/Events/SubmissionSaved.php index 47ee84b5cc9..b2c9eb9e6c3 100644 --- a/src/Events/SubmissionSaved.php +++ b/src/Events/SubmissionSaved.php @@ -6,11 +6,8 @@ class SubmissionSaved extends Event implements ProvidesCommitMessage { - public $submission; - - public function __construct($submission) + public function __construct(public $submission) { - $this->submission = $submission; } public function commitMessage() diff --git a/src/Events/SubmissionSaving.php b/src/Events/SubmissionSaving.php index 20e092dfbb5..e632a22fd48 100644 --- a/src/Events/SubmissionSaving.php +++ b/src/Events/SubmissionSaving.php @@ -4,11 +4,8 @@ class SubmissionSaving extends Event { - public $submission; - - public function __construct($submission) + public function __construct(public $submission) { - $this->submission = $submission; } /** diff --git a/src/Events/TaxonomyCreated.php b/src/Events/TaxonomyCreated.php index ef696c3446f..a2eca8569d5 100644 --- a/src/Events/TaxonomyCreated.php +++ b/src/Events/TaxonomyCreated.php @@ -4,10 +4,7 @@ class TaxonomyCreated extends Event { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } } diff --git a/src/Events/TaxonomyCreating.php b/src/Events/TaxonomyCreating.php index e21175a831a..2f869eb4bed 100644 --- a/src/Events/TaxonomyCreating.php +++ b/src/Events/TaxonomyCreating.php @@ -4,11 +4,8 @@ class TaxonomyCreating extends Event { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } /** diff --git a/src/Events/TaxonomyDeleted.php b/src/Events/TaxonomyDeleted.php index 87c9700cd25..e0b7e060a57 100644 --- a/src/Events/TaxonomyDeleted.php +++ b/src/Events/TaxonomyDeleted.php @@ -6,11 +6,8 @@ class TaxonomyDeleted extends Event implements ProvidesCommitMessage { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } public function commitMessage() diff --git a/src/Events/TaxonomyDeleting.php b/src/Events/TaxonomyDeleting.php index 8db9dee2613..b157a192e8c 100644 --- a/src/Events/TaxonomyDeleting.php +++ b/src/Events/TaxonomyDeleting.php @@ -4,11 +4,8 @@ class TaxonomyDeleting extends Event { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } /** diff --git a/src/Events/TaxonomySaved.php b/src/Events/TaxonomySaved.php index 1463e518676..1d459785048 100644 --- a/src/Events/TaxonomySaved.php +++ b/src/Events/TaxonomySaved.php @@ -6,11 +6,8 @@ class TaxonomySaved extends Event implements ProvidesCommitMessage { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } public function commitMessage() diff --git a/src/Events/TaxonomySaving.php b/src/Events/TaxonomySaving.php index e3a6853be6c..fc1ac3e6568 100644 --- a/src/Events/TaxonomySaving.php +++ b/src/Events/TaxonomySaving.php @@ -4,11 +4,8 @@ class TaxonomySaving extends Event { - public $taxonomy; - - public function __construct($taxonomy) + public function __construct(public $taxonomy) { - $this->taxonomy = $taxonomy; } /** diff --git a/src/Events/TermBlueprintFound.php b/src/Events/TermBlueprintFound.php index 2f35db5010a..a2202dcfc88 100644 --- a/src/Events/TermBlueprintFound.php +++ b/src/Events/TermBlueprintFound.php @@ -4,12 +4,7 @@ class TermBlueprintFound extends Event { - public $blueprint; - public $term; - - public function __construct($blueprint, $term = null) + public function __construct(public $blueprint, public $term = null) { - $this->blueprint = $blueprint; - $this->term = $term; } } diff --git a/src/Events/TermCreated.php b/src/Events/TermCreated.php index 50cd65ce830..566aab36aef 100644 --- a/src/Events/TermCreated.php +++ b/src/Events/TermCreated.php @@ -4,10 +4,7 @@ class TermCreated extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } } diff --git a/src/Events/TermCreating.php b/src/Events/TermCreating.php index 788c0ef8c3b..d8432b528a7 100644 --- a/src/Events/TermCreating.php +++ b/src/Events/TermCreating.php @@ -4,11 +4,8 @@ class TermCreating extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } /** diff --git a/src/Events/TermDeleted.php b/src/Events/TermDeleted.php index 6ffe9003cce..2e711b31af6 100644 --- a/src/Events/TermDeleted.php +++ b/src/Events/TermDeleted.php @@ -6,11 +6,8 @@ class TermDeleted extends Event implements ProvidesCommitMessage { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } public function commitMessage() diff --git a/src/Events/TermDeleting.php b/src/Events/TermDeleting.php index 4e29862b3d3..179a7017884 100644 --- a/src/Events/TermDeleting.php +++ b/src/Events/TermDeleting.php @@ -4,11 +4,8 @@ class TermDeleting extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } /** diff --git a/src/Events/TermReferencesUpdated.php b/src/Events/TermReferencesUpdated.php index 0c3f4dd8f1d..3bb6246298f 100644 --- a/src/Events/TermReferencesUpdated.php +++ b/src/Events/TermReferencesUpdated.php @@ -6,11 +6,8 @@ class TermReferencesUpdated extends Event implements ProvidesCommitMessage { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } public function commitMessage() diff --git a/src/Events/TermSaved.php b/src/Events/TermSaved.php index 4b53422532d..d1d8e14b788 100644 --- a/src/Events/TermSaved.php +++ b/src/Events/TermSaved.php @@ -6,11 +6,8 @@ class TermSaved extends Event implements ProvidesCommitMessage { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } public function commitMessage() diff --git a/src/Events/TermSaving.php b/src/Events/TermSaving.php index b13d479ef1c..da2d86d5a4c 100644 --- a/src/Events/TermSaving.php +++ b/src/Events/TermSaving.php @@ -4,11 +4,8 @@ class TermSaving extends Event { - public $term; - - public function __construct($term) + public function __construct(public $term) { - $this->term = $term; } /** diff --git a/src/Events/UserBlueprintFound.php b/src/Events/UserBlueprintFound.php index fca469473bc..95dd00ede6b 100644 --- a/src/Events/UserBlueprintFound.php +++ b/src/Events/UserBlueprintFound.php @@ -4,10 +4,7 @@ class UserBlueprintFound extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } } diff --git a/src/Events/UserCreated.php b/src/Events/UserCreated.php index 47c0233fd9d..0977c94ce46 100644 --- a/src/Events/UserCreated.php +++ b/src/Events/UserCreated.php @@ -4,10 +4,7 @@ class UserCreated extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } } diff --git a/src/Events/UserCreating.php b/src/Events/UserCreating.php index 69365b5612c..55989dc3e45 100644 --- a/src/Events/UserCreating.php +++ b/src/Events/UserCreating.php @@ -4,11 +4,8 @@ class UserCreating extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } /** diff --git a/src/Events/UserDeleted.php b/src/Events/UserDeleted.php index eff47d69399..c16d767f881 100644 --- a/src/Events/UserDeleted.php +++ b/src/Events/UserDeleted.php @@ -6,11 +6,8 @@ class UserDeleted extends Event implements ProvidesCommitMessage { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } public function commitMessage() diff --git a/src/Events/UserDeleting.php b/src/Events/UserDeleting.php index 56d823bcee0..40192e48ae7 100644 --- a/src/Events/UserDeleting.php +++ b/src/Events/UserDeleting.php @@ -4,11 +4,8 @@ class UserDeleting extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } /** diff --git a/src/Events/UserGroupBlueprintFound.php b/src/Events/UserGroupBlueprintFound.php index dd58861c45d..3cc7160c10d 100644 --- a/src/Events/UserGroupBlueprintFound.php +++ b/src/Events/UserGroupBlueprintFound.php @@ -4,10 +4,7 @@ class UserGroupBlueprintFound extends Event { - public $blueprint; - - public function __construct($blueprint) + public function __construct(public $blueprint) { - $this->blueprint = $blueprint; } } diff --git a/src/Events/UserGroupDeleted.php b/src/Events/UserGroupDeleted.php index 2b5ec4497ed..1a42af188ef 100644 --- a/src/Events/UserGroupDeleted.php +++ b/src/Events/UserGroupDeleted.php @@ -6,11 +6,8 @@ class UserGroupDeleted extends Event implements ProvidesCommitMessage { - public $group; - - public function __construct($group) + public function __construct(public $group) { - $this->group = $group; } public function commitMessage() diff --git a/src/Events/UserGroupSaved.php b/src/Events/UserGroupSaved.php index da1aca845e7..96391540a99 100644 --- a/src/Events/UserGroupSaved.php +++ b/src/Events/UserGroupSaved.php @@ -6,11 +6,8 @@ class UserGroupSaved extends Event implements ProvidesCommitMessage { - public $group; - - public function __construct($group) + public function __construct(public $group) { - $this->group = $group; } public function commitMessage() diff --git a/src/Events/UserRegistered.php b/src/Events/UserRegistered.php index 3ed7479c54f..f6b44955da8 100644 --- a/src/Events/UserRegistered.php +++ b/src/Events/UserRegistered.php @@ -4,10 +4,7 @@ class UserRegistered extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } } diff --git a/src/Events/UserRegistering.php b/src/Events/UserRegistering.php index 857f2d47da4..e9c8d513210 100644 --- a/src/Events/UserRegistering.php +++ b/src/Events/UserRegistering.php @@ -4,11 +4,8 @@ class UserRegistering extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } /** diff --git a/src/Events/UserSaved.php b/src/Events/UserSaved.php index bc5b464b79d..62d1dd971cb 100644 --- a/src/Events/UserSaved.php +++ b/src/Events/UserSaved.php @@ -6,11 +6,8 @@ class UserSaved extends Event implements ProvidesCommitMessage { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } public function commitMessage() diff --git a/src/Events/UserSaving.php b/src/Events/UserSaving.php index 51f5861b19e..b42a0631525 100644 --- a/src/Events/UserSaving.php +++ b/src/Events/UserSaving.php @@ -4,11 +4,8 @@ class UserSaving extends Event { - public $user; - - public function __construct($user) + public function __construct(public $user) { - $this->user = $user; } /** diff --git a/src/Exceptions/Concerns/RendersHttpExceptions.php b/src/Exceptions/Concerns/RendersHttpExceptions.php index e6489261dbf..ab316357e83 100644 --- a/src/Exceptions/Concerns/RendersHttpExceptions.php +++ b/src/Exceptions/Concerns/RendersHttpExceptions.php @@ -2,6 +2,7 @@ namespace Statamic\Exceptions\Concerns; +use Closure; use Illuminate\Http\Request; use Illuminate\Http\Response; use Statamic\Facades\Cascade; @@ -12,8 +13,14 @@ trait RendersHttpExceptions { - public function render() + private static ?Closure $renderCallback = null; + + public function render(Request $request) { + if (static::$renderCallback && ($response = Closure::fromCallable(static::$renderCallback)->call($this, $request))) { + return $response; + } + if (Statamic::isCpRoute()) { return response()->view('statamic::errors.'.$this->getStatusCode(), [], $this->getStatusCode()); } @@ -82,4 +89,9 @@ private function getCachedError(): ?Response ? $cacher->getCachedPage($request)->toResponse($request) : null; } + + public static function renderUsing(Closure $callback): void + { + static::$renderCallback = $callback; + } } diff --git a/src/Exceptions/ControlPanelExceptionHandler.php b/src/Exceptions/ControlPanelExceptionHandler.php index 0bdd5876c84..31a2a76af04 100644 --- a/src/Exceptions/ControlPanelExceptionHandler.php +++ b/src/Exceptions/ControlPanelExceptionHandler.php @@ -8,6 +8,8 @@ use Statamic\Exceptions\ValidationException as StatamicValidationException; use Throwable; +use function Statamic\trans as __; + class ControlPanelExceptionHandler extends Handler { use Concerns\RendersControlPanelExceptions; diff --git a/src/Extend/Addon.php b/src/Extend/Addon.php index 1b44943bb70..337c8782447 100644 --- a/src/Extend/Addon.php +++ b/src/Extend/Addon.php @@ -5,6 +5,7 @@ use Composer\Semver\VersionParser; use Facades\Statamic\Licensing\LicenseManager; use ReflectionClass; +use Statamic\Facades; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\Support\Arr; @@ -147,6 +148,13 @@ final class Addon */ protected $editions = []; + /** + * Whether the addon has marketplace data. + * + * @var bool + */ + protected $hasMarketplaceData = false; + /** * @param string $id */ @@ -184,6 +192,10 @@ public static function makeFromPackage(array $package) $instance->$method($value); } + if (array_key_exists('marketplaceId', $package)) { + $instance->hasMarketplaceData = true; + } + return $instance; } @@ -231,32 +243,6 @@ public function vendorName() return explode('/', $this->package())[0]; } - /** - * The marketplace variant ID of the addon. - * - * @param int $id - * @return int - */ - public function marketplaceId($id = null) - { - return $id - ? $this->marketplaceId = $id - : $this->marketplaceId; - } - - /** - * The marketplace slug of the addon. - * - * @param string $slug - * @return string - */ - public function marketplaceSlug($slug = null) - { - return $slug - ? $this->marketplaceSlug = $slug - : $this->marketplaceSlug; - } - /** * The handle of the addon. * @@ -375,14 +361,14 @@ public function isLatestVersion() return true; } - if (! $this->latestVersion) { + if (! $this->latestVersion()) { return true; } $versionParser = new VersionParser; $version = $versionParser->normalize($this->version); - $latestVersion = $versionParser->normalize($this->latestVersion); + $latestVersion = $versionParser->normalize($this->latestVersion()); return version_compare($version, $latestVersion, '='); } @@ -407,6 +393,12 @@ public function __call($method, $args) } if (empty($args)) { + if (! $this->hasMarketplaceData && in_array($method, ['marketplaceId', 'marketplaceSlug', 'marketplaceUrl', 'marketplaceSellerSlug', 'isCommercial', 'latestVersion'])) { + app(Manifest::class)->fetchPackageDataFromMarketplace(); + + return Facades\Addon::get($this->id)->$method(); + } + return $this->$method; } diff --git a/src/Extend/HasTitle.php b/src/Extend/HasTitle.php index ddfe294d91e..21fff9b2bfd 100644 --- a/src/Extend/HasTitle.php +++ b/src/Extend/HasTitle.php @@ -4,6 +4,8 @@ use Statamic\Support\Str; +use function Statamic\trans as __; + trait HasTitle { protected static $title; diff --git a/src/Extend/Manifest.php b/src/Extend/Manifest.php index 9be9273034d..f9e57664915 100644 --- a/src/Extend/Manifest.php +++ b/src/Extend/Manifest.php @@ -4,6 +4,7 @@ use Facades\Statamic\Marketplace\Marketplace; use Illuminate\Foundation\PackageManifest; +use Illuminate\Support\Facades\Facade; use ReflectionClass; use Statamic\Facades\File; use Statamic\Support\Arr; @@ -48,21 +49,12 @@ protected function formatPackage($package) $statamic = $json['extra']['statamic'] ?? []; $author = $json['authors'][0] ?? null; - $edition = config('statamic.editions.addons.'.$package['name']); - - $marketplaceData = Marketplace::package($package['name'], $package['version'], $edition); - return [ 'id' => $package['name'], 'slug' => $statamic['slug'] ?? null, 'editions' => $statamic['editions'] ?? [], - 'marketplaceId' => data_get($marketplaceData, 'id', null), - 'marketplaceSlug' => data_get($marketplaceData, 'slug', null), - 'marketplaceUrl' => data_get($marketplaceData, 'url', null), - 'marketplaceSellerSlug' => data_get($marketplaceData, 'seller', null), - 'isCommercial' => data_get($marketplaceData, 'is_commercial', false), - 'latestVersion' => data_get($marketplaceData, 'latest_version', null), 'version' => Str::removeLeft($package['version'], 'v'), + 'raw_version' => $package['version'], 'namespace' => $namespace, 'autoload' => $autoload, 'provider' => $provider, @@ -81,4 +73,36 @@ public function addons() { return collect($this->getManifest()); } + + public function fetchPackageDataFromMarketplace() + { + $packages = $this->addons() + ->map(function (array $package) { + return [ + 'package' => $package['id'], + 'version' => $package['raw_version'], + 'edition' => config('statamic.editions.addons.'.$package['id']), + ]; + }) + ->values() + ->all(); + + $marketplaceData = Marketplace::packages($packages); + + $this->write($this->manifest = $this->addons()->map(function (array $package) use ($marketplaceData) { + $marketplaceData = $marketplaceData->get($package['id']); + + return [ + ...$package, + 'marketplaceId' => data_get($marketplaceData, 'id'), + 'marketplaceSlug' => data_get($marketplaceData, 'slug'), + 'marketplaceUrl' => data_get($marketplaceData, 'url'), + 'marketplaceSellerSlug' => data_get($marketplaceData, 'seller'), + 'isCommercial' => data_get($marketplaceData, 'is_commercial', false), + 'latestVersion' => data_get($marketplaceData, 'latest_version'), + ]; + })->all()); + + Facade::clearResolvedInstance(AddonRepository::class); + } } diff --git a/src/Facades/Antlers.php b/src/Facades/Antlers.php index 565291af83d..e29d69889bd 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -10,6 +10,7 @@ * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) * @method static AntlersString parse(string $str, array $variables = []) + * @method static AntlersString parseUserContent(string $str, array $variables = []) * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = []) * @method static array identifiers(string $content) * diff --git a/src/Facades/AssetContainer.php b/src/Facades/AssetContainer.php index 1d0644ada07..6347d31e236 100644 --- a/src/Facades/AssetContainer.php +++ b/src/Facades/AssetContainer.php @@ -12,7 +12,7 @@ * @method static \Statamic\Contracts\Assets\AssetContainer findOrFail($id) * @method static \Statamic\Contracts\Assets\AssetContainer make(string $handle = null) * - * @see \Statamic\Assets\AssetRepository + * @see \Statamic\Stache\Repositories\AssetContainerRepository */ class AssetContainer extends Facade { diff --git a/src/Facades/Blink.php b/src/Facades/Blink.php index dcc8aec4d8d..9563cf79e51 100644 --- a/src/Facades/Blink.php +++ b/src/Facades/Blink.php @@ -24,7 +24,7 @@ * @method static mixed once($key, callable $callable) * @method static mixed|\Spatie\Blink\Blink store($name = 'default') * - * @see Statamic\Support\Blink + * @see \Statamic\Support\Blink */ class Blink extends Facade { diff --git a/src/Facades/Blueprint.php b/src/Facades/Blueprint.php index 39d105a70e9..72dcf3743e3 100644 --- a/src/Facades/Blueprint.php +++ b/src/Facades/Blueprint.php @@ -25,7 +25,7 @@ * @method static \Illuminate\Support\Collection getAdditionalNamespaces() * * @see \Statamic\Fields\BlueprintRepository - * @see \Statamic\Fields\Blueprint + * @link \Statamic\Fields\Blueprint */ class Blueprint extends Facade { diff --git a/src/Facades/Collection.php b/src/Facades/Collection.php index 2acda85d731..9d499b99cc2 100644 --- a/src/Facades/Collection.php +++ b/src/Facades/Collection.php @@ -18,11 +18,11 @@ * @method static void delete(\Statamic\Entries\Collection $collection) * @method static \Illuminate\Support\Collection whereStructured() * @method static \Illuminate\Support\Collection additionalPreviewTargets(string $handle) - * @method static void computed(string|array $scopes, string $field, \Closure $callback) + * @method static void computed(string|array $scopes, string|array $field, ?\Closure $callback = null) * @method static \Illuminate\Support\Collection getComputedCallbacks($collection) * * @see CollectionRepository - * @see \Statamic\Entries\Collection + * @link \Statamic\Entries\Collection */ class Collection extends Facade { diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 037a1bc7cfa..34d0c7c3edb 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -245,6 +245,29 @@ public function isExternal($url) return $isExternal; } + /** + * Check whether a URL is external to whole Statamic application. + */ + public function isExternalToApplication(?string $url): bool + { + if (Str::startsWith($url, '//')) { + return true; + } + + $urlDomain = parse_url($url, PHP_URL_HOST); + $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST); + + return $urlDomain + ? Site::all() + ->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST)) + ->push($currentRequestDomain) + ->filter(fn ($siteDomain) => ! is_null($siteDomain)) + ->unique() + ->filter(fn ($siteDomain) => $siteDomain === $urlDomain) + ->isEmpty() + : false; + } + public function clearExternalUrlCache() { self::$externalUriCache = []; diff --git a/src/Facades/Entry.php b/src/Facades/Entry.php index c5d05e54b17..6d3e7b8d037 100644 --- a/src/Facades/Entry.php +++ b/src/Facades/Entry.php @@ -26,9 +26,9 @@ * @method static void updateParents(\Statamic\Entries\Collection $collection, $ids = null) * * @see \Statamic\Stache\Repositories\EntryRepository - * @see \Statamic\Stache\Query\EntryQueryBuilder - * @see \Statamic\Entries\EntryCollection - * @see \Statamic\Entries\Entry + * @link \Statamic\Stache\Query\EntryQueryBuilder + * @link \Statamic\Entries\EntryCollection + * @link \Statamic\Entries\Entry */ class Entry extends Facade { diff --git a/src/Facades/Fieldset.php b/src/Facades/Fieldset.php index 07262a9f891..9afbe0f1e0f 100644 --- a/src/Facades/Fieldset.php +++ b/src/Facades/Fieldset.php @@ -19,7 +19,7 @@ * @method static void addNamespace(string $namespace, string $directory) * * @see \Statamic\Fields\FieldsetRepository - * @see \Statamic\Fields\Fieldset + * @link \Statamic\Fields\Fieldset */ class Fieldset extends Facade { diff --git a/src/Facades/Form.php b/src/Facades/Form.php index e7bf34a7a1a..6e102c2b56d 100644 --- a/src/Facades/Form.php +++ b/src/Facades/Form.php @@ -20,7 +20,7 @@ * @method static ExporterRepository exporters() * * @see \Statamic\Contracts\Forms\FormRepository - * @see \Statamic\Forms\Form + * @link \Statamic\Forms\Form */ class Form extends Facade { diff --git a/src/Facades/FormSubmission.php b/src/Facades/FormSubmission.php index 8a106b7b441..55ec36b6ee1 100644 --- a/src/Facades/FormSubmission.php +++ b/src/Facades/FormSubmission.php @@ -19,7 +19,7 @@ * @method static SubmissionContract make() * * @see \Statamic\Contracts\Forms\SubmissionRepository - * @see \Statamic\Forms\Submission + * @link \Statamic\Forms\Submission */ class FormSubmission extends Facade { diff --git a/src/Facades/GlobalSet.php b/src/Facades/GlobalSet.php index 2e7c1f0c34f..06dd92888cc 100644 --- a/src/Facades/GlobalSet.php +++ b/src/Facades/GlobalSet.php @@ -14,7 +14,7 @@ * @method static void delete(\Statamic\Contracts\Globals\GlobalSet $global) * * @see \Statamic\Stache\Repositories\GlobalRepository - * @see \Statamic\Globals\GlobalSet + * @link \Statamic\Globals\GlobalSet */ class GlobalSet extends Facade { diff --git a/src/Facades/GlobalVariables.php b/src/Facades/GlobalVariables.php index e9fddd3c9ad..cfd4dca66d1 100644 --- a/src/Facades/GlobalVariables.php +++ b/src/Facades/GlobalVariables.php @@ -14,7 +14,7 @@ * @method static void delete(\Statamic\Globals\Variables $variable) * * @see \Statamic\Stache\Repositories\GlobalVariablesRepository - * @see \Statamic\Globals\Variables + * @link \Statamic\Globals\Variables */ class GlobalVariables extends Facade { diff --git a/src/Facades/Markdown.php b/src/Facades/Markdown.php index 22d7b23c0ec..e2f99ab24bf 100644 --- a/src/Facades/Markdown.php +++ b/src/Facades/Markdown.php @@ -17,6 +17,9 @@ * @method static Parser addExtension(\Closure $closure) * @method static Parser addExtensions(\Closure $closure) * @method static array extensions() + * @method static Parser addRenderer(\Closure $closure) + * @method static Parser addRenderers(\Closure $closure) + * @method static array renderers() * @method static void withStatamicDefaults() * @method static Parser withAutoLinks() * @method static Parser withAutoLineBreaks() diff --git a/src/Facades/Revision.php b/src/Facades/Revision.php index 4ca25265f51..6826d1e90b0 100644 --- a/src/Facades/Revision.php +++ b/src/Facades/Revision.php @@ -14,7 +14,7 @@ * @method static void delete(\Statamic\Contracts\Revisions\Revision $revision) * * @see \Statamic\Revisions\RevisionRepository - * @see \Statamic\Revisions\Revision + * @link \Statamic\Revisions\Revision */ class Revision extends Facade { diff --git a/src/Facades/Role.php b/src/Facades/Role.php index 9a4cec914e3..fceb8227203 100644 --- a/src/Facades/Role.php +++ b/src/Facades/Role.php @@ -15,7 +15,7 @@ * @method static void delete(\Statamic\Contracts\Auth\Role $role) * * @see \Statamic\Contracts\Auth\RoleRepository - * @see \Statamic\Auth\Role + * @link \Statamic\Auth\Role */ class Role extends Facade { diff --git a/src/Facades/Search.php b/src/Facades/Search.php index 6fcb6370ddf..54c1eb9227b 100644 --- a/src/Facades/Search.php +++ b/src/Facades/Search.php @@ -15,7 +15,7 @@ * @method static void deleteFromIndexes(Searchable $searchable) * * @see \Statamic\Search\Search - * @see \Statamic\Search\Index + * @link \Statamic\Search\Index */ class Search extends Facade { diff --git a/src/Facades/StaticCache.php b/src/Facades/StaticCache.php index df7aadc73a8..d70550234a3 100644 --- a/src/Facades/StaticCache.php +++ b/src/Facades/StaticCache.php @@ -21,7 +21,7 @@ * @method static void includeJs() * * @see StaticCacheManager - * @see Cacher + * @link Cacher */ class StaticCache extends Facade { diff --git a/src/Facades/Structure.php b/src/Facades/Structure.php index a5923b1af70..f5cb2dd9b28 100644 --- a/src/Facades/Structure.php +++ b/src/Facades/Structure.php @@ -13,7 +13,7 @@ * @method static void delete(Structure $structure) * * @see \Statamic\Contracts\Structures\StructureRepository - * @see \Statamic\Structures\Structure + * @link \Statamic\Structures\Structure */ class Structure extends Facade { diff --git a/src/Facades/Taxonomy.php b/src/Facades/Taxonomy.php index 1b2c20966be..1abd25406a8 100644 --- a/src/Facades/Taxonomy.php +++ b/src/Facades/Taxonomy.php @@ -20,7 +20,7 @@ * @method static additionalPreviewTargets(string $handle) * * @see \Statamic\Stache\Repositories\TaxonomyRepository - * @see \Statamic\Taxonomies\Taxonomy + * @link \Statamic\Taxonomies\Taxonomy */ class Taxonomy extends Facade { diff --git a/src/Facades/Term.php b/src/Facades/Term.php index 11d366bb837..83ac310c9c1 100644 --- a/src/Facades/Term.php +++ b/src/Facades/Term.php @@ -24,7 +24,7 @@ * @method static \Illuminate\Support\Collection applySubstitutions($items) * * @see \Statamic\Contracts\Taxonomies\TermRepository - * @see \Statamic\Taxonomies\Term + * @link \Statamic\Taxonomies\Term */ class Term extends Facade { diff --git a/src/Facades/User.php b/src/Facades/User.php index bbdc0f9249d..2baae604780 100644 --- a/src/Facades/User.php +++ b/src/Facades/User.php @@ -21,10 +21,10 @@ * @method static void delete(\Statamic\Contracts\Auth\User $user); * @method static \Statamic\Fields\Blueprint blueprint(); * @method static \Illuminate\Support\Collection getComputedCallbacks() - * @method static void computed(string $field, \Closure $callback) + * @method static void computed(string|array $field, ?\Closure $callback = null) * * @see \Statamic\Contracts\Auth\UserRepository - * @see \Statamic\Auth\User + * @link \Statamic\Auth\User */ class User extends Facade { diff --git a/src/Facades/UserGroup.php b/src/Facades/UserGroup.php index 3cb41ce9b21..e9ee0305cd1 100644 --- a/src/Facades/UserGroup.php +++ b/src/Facades/UserGroup.php @@ -14,7 +14,7 @@ * @method static \Statamic\Fields\Blueprint blueprint() * * @see \Statamic\Contracts\Auth\UserGroupRepository - * @see \Statamic\Auth\UserGroup + * @link \Statamic\Auth\UserGroup */ class UserGroup extends Facade { diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php index 5235d27d2d9..c1e3b14c217 100644 --- a/src/Fields/Blueprint.php +++ b/src/Fields/Blueprint.php @@ -28,6 +28,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class Blueprint implements Arrayable, ArrayAccess, Augmentable, QueryableValue { use ExistsAsFile, HasAugmentedData; @@ -136,9 +138,8 @@ public function path() $namespace = 'vendor/'.$namespace; } - return Path::tidy(vsprintf('%s/%s/%s.yaml', [ - Facades\Blueprint::directory(), - $namespace, + return Path::tidy(vsprintf('%s/%s.yaml', [ + Facades\Blueprint::namespaceDirectory($namespace), $this->handle(), ])); } @@ -149,6 +150,7 @@ public function setContents(array $contents) return $this ->normalizeTabs() + ->resetBlueprintCache() ->resetFieldsCache(); } @@ -257,7 +259,9 @@ private function addEnsuredFieldToContents($contents, $ensured) // override array, but only keys that don't already exist in the actual partial field's config. $referencedField = FieldRepository::find($existingField['field']); $referencedFieldConfig = $referencedField->config(); - $config = array_merge($config, $referencedFieldConfig); + $fieldOverrides = $existingField['config'] ?? []; + + $config = array_merge($config, $referencedFieldConfig, $fieldOverrides); $config = Arr::except($config, array_keys($referencedFieldConfig)); $field = ['handle' => $handle, 'field' => $existingField['field'], 'config' => $config]; } else { @@ -626,7 +630,7 @@ public function removeFieldFromTab($handle, $tab) private function getTabFields($tab) { return collect($this->contents['tabs'][$tab]['sections'])->flatMap(function ($section, $sectionIndex) { - return collect($section['fields'])->map(function ($field, $fieldIndex) use ($sectionIndex) { + return collect($section['fields'] ?? [])->map(function ($field, $fieldIndex) use ($sectionIndex) { return $field + ['fieldIndex' => $fieldIndex, 'sectionIndex' => $sectionIndex]; }); })->keyBy('handle'); @@ -646,7 +650,8 @@ protected function ensureFieldInTabHasConfig($handle, $tab, $config) $field = $this->contents['tabs'][$tab]['sections'][$sectionKey]['fields'][$fieldKey]; - $isImportedField = Arr::has($field, 'config'); + $fieldValue = Arr::get($field, 'field'); + $isImportedField = is_string($fieldValue); if ($isImportedField) { $existingConfig = Arr::get($field, 'config', []); diff --git a/src/Fields/BlueprintRepository.php b/src/Fields/BlueprintRepository.php index 05a76d5f108..8021cda7840 100644 --- a/src/Fields/BlueprintRepository.php +++ b/src/Fields/BlueprintRepository.php @@ -3,6 +3,7 @@ namespace Statamic\Fields; use Closure; +use Exception; use Statamic\Exceptions\BlueprintNotFoundException; use Statamic\Facades\Blink; use Statamic\Facades\File; @@ -17,20 +18,43 @@ class BlueprintRepository protected const BLINK_FROM_FILE = 'blueprints.from-file'; protected const BLINK_NAMESPACE_PATHS = 'blueprints.paths-in-namespace'; - protected $directory; + protected $directories = ['default' => null]; protected $fallbacks = []; protected $additionalNamespaces = []; - public function setDirectory(string $directory) + public function setDirectories(string|array $directories) { - $this->directory = Path::tidy($directory); + if (is_string($directories)) { + $directories = ['default' => $directories]; + } + + $this->directories = collect($directories) + ->map(fn ($directory) => Path::tidy($directory)) + ->all(); + + if (! isset($this->directories['default'])) { + throw new Exception('Default blueprint directory not provided'); + } return $this; } + /** @deprecated */ + public function setDirectory(string $directory) + { + return $this->setDirectories($directory); + } + public function directory() { - return $this->directory; + return $this->directories['default']; + } + + public function namespaceDirectory($namespace) + { + return isset($this->directories[$namespace]) + ? $this->directories[$namespace] + : $this->directories['default'].'/'.$namespace; } public function find($blueprint): ?Blueprint @@ -69,7 +93,14 @@ public function findStandardBlueprintPath($handle) return null; } - return $this->directory.'/'.str_replace('.', '/', $handle).'.yaml'; + if (! Str::contains($handle, '.')) { + return $this->directory().'/'.str_replace('.', '/', $handle).'.yaml'; + } + + $namespace = Str::before($handle, '.'); + $handle = Str::after($handle, '.'); + + return $this->namespaceDirectory($namespace).'/'.str_replace('.', '/', $handle).'.yaml'; } public function findNamespacedBlueprintPath($handle) @@ -79,7 +110,7 @@ public function findNamespacedBlueprintPath($handle) $handle = str_replace('/', '.', $handle); $path = str_replace('.', '/', $handle); - $overridePath = "{$this->directory}/vendor/{$namespaceDir}/{$path}.yaml"; + $overridePath = "{$this->directory()}/vendor/{$namespaceDir}/{$path}.yaml"; if (File::exists($overridePath)) { return $overridePath; @@ -233,7 +264,7 @@ protected function filesIn($namespace) return Blink::store(self::BLINK_NAMESPACE_PATHS)->once($namespace, function () use ($namespace) { $namespace = str_replace('/', '.', $namespace); $namespaceDir = str_replace('.', '/', $namespace); - $directory = $this->directory.'/'.$namespaceDir; + $directory = $this->directory().'/'.$namespaceDir; if (isset($this->additionalNamespaces[$namespace])) { $directory = "{$this->additionalNamespaces[$namespace]}"; @@ -243,7 +274,7 @@ protected function filesIn($namespace) ->getFilesByType($directory, 'yaml') ->mapWithKeys(fn ($path) => [Str::after($path, $directory.'/') => $path]); - if (File::exists($directory = $this->directory.'/vendor/'.$namespaceDir)) { + if (File::exists($directory = $this->directory().'/vendor/'.$namespaceDir)) { $overrides = File::withAbsolutePaths() ->getFilesByType($directory, 'yaml') ->mapWithKeys(fn ($path) => [Str::after($path, $directory.'/') => $path]); @@ -259,9 +290,7 @@ protected function makeBlueprintFromFile($path, $namespace = null) { return Blink::store(self::BLINK_FROM_FILE)->once($path, function () use ($path, $namespace) { if (! $namespace || ! isset($this->additionalNamespaces[$namespace])) { - [$namespace, $handle] = $this->getNamespaceAndHandle( - Str::after(Str::before($path, '.yaml'), $this->directory.'/') - ); + [$namespace, $handle] = $this->getNamespaceAndHandleFromPath($path); } else { $handle = Str::of($path)->afterLast('/')->before('.'); } @@ -287,4 +316,21 @@ protected function getNamespaceAndHandle($blueprint) return [$namespace, $handle]; } + + public function getNamespaceAndHandleFromPath($path) + { + $namespace = collect($this->directories) + ->search(function ($directory) use ($path) { + return Str::startsWith($path, $directory); + }); + + if ($namespace === 'default') { + return $this->getNamespaceAndHandle(Str::after(Str::before($path, '.yaml'), $this->directory().'/')); + } + + $directory = $this->directories[$namespace]; + $handle = Str::after(Str::before($path, '.yaml'), $directory.'/'); + + return [$namespace, $handle]; + } } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index b7b9f4f17e9..1fdae179bd2 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -13,6 +13,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class Field implements Arrayable { protected $handle; @@ -175,6 +177,11 @@ public function isRequired() return collect($this->rules()[$this->handle])->contains('required'); } + private function hasSometimesRule() + { + return collect($this->rules()[$this->handle])->contains('sometimes'); + } + public function setValidationContext($context) { $this->validationContext = $context; @@ -435,7 +442,7 @@ public function toGql(): array $type = ['type' => $type]; } - if ($this->isRequired()) { + if ($this->isRequired() && ! $this->hasSometimesRule() && $this->type() !== 'assets') { $type['type'] = GraphQL::nonNull($type['type']); } diff --git a/src/Fields/Fieldset.php b/src/Fields/Fieldset.php index 98bfbb16aa7..44e43ddd9e7 100644 --- a/src/Fields/Fieldset.php +++ b/src/Fields/Fieldset.php @@ -108,6 +108,11 @@ public function field(string $handle): ?Field return $this->fields()->get($handle); } + public function hasField($field) + { + return $this->fields()->has($field); + } + public function isNamespaced(): bool { return Str::contains($this->handle(), '::'); diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php index 7fd86d79bfe..a1ff5a06297 100644 --- a/src/Fields/Fieldtype.php +++ b/src/Fields/Fieldtype.php @@ -12,6 +12,8 @@ use Statamic\Statamic; use Statamic\Support\Str; +use function Statamic\trans as __; + abstract class Fieldtype implements Arrayable { use HasHandle, RegistersItself { @@ -101,7 +103,11 @@ public function selectable(): bool public function selectableInForms(): bool { - return $this->selectableInForms ?: FieldtypeRepository::hasBeenMadeSelectableInForms($this->handle()); + if (FieldtypeRepository::selectableInFormIsOverriden($this->handle())) { + return FieldtypeRepository::hasBeenMadeSelectableInForms($this->handle()); + } + + return $this->selectableInForms; } public static function makeSelectableInForms() @@ -109,6 +115,11 @@ public static function makeSelectableInForms() FieldtypeRepository::makeSelectableInForms(self::handle()); } + public static function makeUnselectableInForms() + { + FieldtypeRepository::makeUnselectableInForms(self::handle()); + } + public function categories(): array { return $this->categories; @@ -370,6 +381,16 @@ public function isRelationship(): bool return $this->relationship; } + public function relationshipQueryBuilder() + { + return false; + } + + public function relationshipQueryIdMapFn(): ?\Closure + { + return null; + } + public function toQueryableValue($value) { return $value; @@ -379,4 +400,9 @@ public function extraRenderableFieldData(): array { return []; } + + public function shouldParseAntlersFromRawString(): bool + { + return false; + } } diff --git a/src/Fields/FieldtypeRepository.php b/src/Fields/FieldtypeRepository.php index 6f8311ed05c..0fe7eefa277 100644 --- a/src/Fields/FieldtypeRepository.php +++ b/src/Fields/FieldtypeRepository.php @@ -4,7 +4,7 @@ class FieldtypeRepository { - protected $madeSelectableInForms = []; + protected $selectableInForms = []; private $fieldtypes = []; public function preloadable() @@ -41,11 +41,21 @@ public function handles() public function makeSelectableInForms($handle) { - $this->madeSelectableInForms[] = $handle; + $this->selectableInForms[$handle] = true; + } + + public function makeUnselectableInForms($handle) + { + $this->selectableInForms[$handle] = false; } public function hasBeenMadeSelectableInForms($handle) { - return in_array($handle, $this->madeSelectableInForms); + return $this->selectableInForms[$handle] ?? false; + } + + public function selectableInFormIsOverriden($handle) + { + return array_key_exists($handle, $this->selectableInForms); } } diff --git a/src/Fields/Tab.php b/src/Fields/Tab.php index ef0d095e894..bbc50f2a2bc 100644 --- a/src/Fields/Tab.php +++ b/src/Fields/Tab.php @@ -5,6 +5,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class Tab { protected $handle; diff --git a/src/Fields/Validator.php b/src/Fields/Validator.php index 8fd8866a621..f4dec57d112 100644 --- a/src/Fields/Validator.php +++ b/src/Fields/Validator.php @@ -122,7 +122,7 @@ public function attributes() }, collect())->all(); } - private function parse($rule) + public function parse($rule) { if (is_string($rule) && Str::startsWith($rule, 'new ')) { return $this->parseClassBasedRule($rule); diff --git a/src/Fields/Value.php b/src/Fields/Value.php index b7823890f6a..8e17639a736 100644 --- a/src/Fields/Value.php +++ b/src/Fields/Value.php @@ -82,11 +82,14 @@ public function value() $raw = $this->fieldtype->field()?->defaultValue() ?? null; } - $value = $this->shallow + return $this->getAugmentedValue($raw); + } + + private function getAugmentedValue($raw) + { + return $this->shallow ? $this->fieldtype->shallowAugment($raw) : $this->fieldtype->augment($raw); - - return $value; } private function iteratorValue() @@ -150,9 +153,19 @@ public function antlersValue(Parser $parser, $variables) } if ($shouldParseAntlers) { + if ($parseFromRawString = $this->fieldtype?->shouldParseAntlersFromRawString()) { + $value = $this->raw(); + } + $value = (new DocumentTransformer())->correct($value); - return $parser->parse($value, $variables); + $parsed = $parser->parse($value, $variables); + + if (! $parseFromRawString) { + return $parsed; + } + + return $this->getAugmentedValue($parsed); } if (Str::contains($value, '{')) { @@ -224,6 +237,11 @@ public function __call(string $name, array $arguments) return $this->value()->{$name}(...$arguments); } + public function __isset($key) + { + return isset($this->value()?->{$key}); + } + public function __get($key) { return $this->value()?->{$key} ?? null; diff --git a/src/Fields/Values.php b/src/Fields/Values.php index 9956455bbbf..0e6a943dea5 100644 --- a/src/Fields/Values.php +++ b/src/Fields/Values.php @@ -79,6 +79,19 @@ public function raw($key) return $value instanceof Value ? $value->raw() : $value; } + public function toRawArray(): array + { + return + $this->getIterator() + ->mapWithKeys(fn ($value, $key) => [$key => $value instanceof Value ? $value->raw() : $value]) + ->toArray(); + } + + public function __isset($key) + { + return $this->offsetExists($key); + } + public function __get($key) { return $this->offsetGet($key); diff --git a/src/Fieldtypes/Arr.php b/src/Fieldtypes/Arr.php index 2b21c19645d..854551e4579 100644 --- a/src/Fieldtypes/Arr.php +++ b/src/Fieldtypes/Arr.php @@ -108,14 +108,14 @@ public function process($data) if ($this->config('expand')) { return collect($data) - ->when($this->isKeyed(), fn ($items) => $items->filter()) + ->when($this->isKeyed(), fn ($items) => $items->reject(fn ($value) => is_null($value))) ->map(fn ($value, $key) => ['key' => $key, 'value' => $value]) ->values() ->all(); } if ($this->isKeyed()) { - return collect($data)->filter()->all(); + return collect($data)->reject(fn ($value) => is_null($value))->all(); } return $data; diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index a58efcfea86..0e1999d9afd 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -161,7 +161,7 @@ public function process($data) $max_files = (int) $this->config('max_files'); $values = collect($data)->map(function ($id) { - return Asset::find($id)->path(); + return Asset::findOrFail($id)->path(); }); return $this->config('max_files') === 1 ? $values->first() : $values->all(); @@ -345,7 +345,16 @@ public function fieldRules() public function preProcessIndex($data) { - return $this->getItemsForPreProcessIndex($data)->map(function ($asset) { + $total = $data === null + ? 0 + : ($this->config('max_files') === 1 ? 1 : count($data)); + + // Since we only want to display a handful of thumbnails, we'll slice it up here so we don't perform more + // augmentation overhead than necessary. e.g. 5 thumbs then +remainder. If the remainder is 1, we may + // as well show all 6 since the +1 would almost take up the same amount of space. + $data = collect($data)->take(6)->all(); + + $assets = $this->getItemsForPreProcessIndex($data)->map(function ($asset) { $arr = [ 'id' => $asset->id(), 'is_image' => $isImage = $asset->isImage(), @@ -363,6 +372,8 @@ public function preProcessIndex($data) return $arr; }); + + return compact('total', 'assets'); } protected function getItemsForPreProcessIndex($values): Collection diff --git a/src/Fieldtypes/Assets/DimensionsRule.php b/src/Fieldtypes/Assets/DimensionsRule.php index d1864601fe0..6232ee23abb 100644 --- a/src/Fieldtypes/Assets/DimensionsRule.php +++ b/src/Fieldtypes/Assets/DimensionsRule.php @@ -3,11 +3,12 @@ namespace Statamic\Fieldtypes\Assets; use Illuminate\Contracts\Validation\Rule; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Asset; use Statamic\Statamic; use Symfony\Component\HttpFoundation\File\UploadedFile; -class DimensionsRule implements Rule +class DimensionsRule implements CastableToValidationString, Rule { protected $parameters; @@ -124,4 +125,9 @@ protected function failsRatioCheck($parameters, $width, $height) return abs($numerator / $denominator - $width / $height) > $precision; } + + public function toGqlValidationString(): string + { + return 'dimensions:'.implode(',', $this->parameters); + } } diff --git a/src/Fieldtypes/Assets/ImageRule.php b/src/Fieldtypes/Assets/ImageRule.php index eb9955d158e..8044190761b 100644 --- a/src/Fieldtypes/Assets/ImageRule.php +++ b/src/Fieldtypes/Assets/ImageRule.php @@ -3,11 +3,12 @@ namespace Statamic\Fieldtypes\Assets; use Illuminate\Contracts\Validation\Rule; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Asset; use Statamic\Statamic; use Symfony\Component\HttpFoundation\File\UploadedFile; -class ImageRule implements Rule +class ImageRule implements CastableToValidationString, Rule { protected $parameters; @@ -49,4 +50,9 @@ public function message() { return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.image'); } + + public function toGqlValidationString(): string + { + return 'image:'.implode(',', $this->parameters); + } } diff --git a/src/Fieldtypes/Assets/MimesRule.php b/src/Fieldtypes/Assets/MimesRule.php index 485ac393cf6..c184a34d578 100644 --- a/src/Fieldtypes/Assets/MimesRule.php +++ b/src/Fieldtypes/Assets/MimesRule.php @@ -3,11 +3,12 @@ namespace Statamic\Fieldtypes\Assets; use Illuminate\Contracts\Validation\Rule; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Asset; use Statamic\Statamic; use Symfony\Component\HttpFoundation\File\UploadedFile; -class MimesRule implements Rule +class MimesRule implements CastableToValidationString, Rule { protected $parameters; @@ -51,4 +52,9 @@ public function message() { return str_replace(':values', implode(', ', $this->parameters), __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimes')); } + + public function toGqlValidationString(): string + { + return 'mimes:'.implode(',', $this->parameters); + } } diff --git a/src/Fieldtypes/Assets/MimetypesRule.php b/src/Fieldtypes/Assets/MimetypesRule.php index ad1c82acaae..65e3e0400bd 100644 --- a/src/Fieldtypes/Assets/MimetypesRule.php +++ b/src/Fieldtypes/Assets/MimetypesRule.php @@ -3,11 +3,12 @@ namespace Statamic\Fieldtypes\Assets; use Illuminate\Contracts\Validation\Rule; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Asset; use Statamic\Statamic; use Symfony\Component\HttpFoundation\File\UploadedFile; -class MimetypesRule implements Rule +class MimetypesRule implements CastableToValidationString, Rule { protected $parameters; @@ -46,4 +47,9 @@ public function message() { return str_replace(':values', implode(', ', $this->parameters), __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimetypes')); } + + public function toGqlValidationString(): string + { + return 'mimetypes:'.implode(',', $this->parameters); + } } diff --git a/src/Fieldtypes/Assets/SizeBasedRule.php b/src/Fieldtypes/Assets/SizeBasedRule.php index 35338e010c6..875c3c612d0 100644 --- a/src/Fieldtypes/Assets/SizeBasedRule.php +++ b/src/Fieldtypes/Assets/SizeBasedRule.php @@ -3,10 +3,11 @@ namespace Statamic\Fieldtypes\Assets; use Illuminate\Contracts\Validation\Rule; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Asset; use Symfony\Component\HttpFoundation\File\UploadedFile; -abstract class SizeBasedRule implements Rule +abstract class SizeBasedRule implements CastableToValidationString, Rule { protected $parameters; @@ -66,4 +67,9 @@ protected function getFileSize($id) return false; } + + public function toGqlValidationString(): string + { + return 'size:'.implode(',', $this->parameters); + } } diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index d92c3bec0bc..c7466482cfc 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -182,6 +182,11 @@ protected function configFieldItems(): array 'type' => 'collections', 'mode' => 'select', ], + 'select_across_sites' => [ + 'display' => __('Select Across Sites'), + 'instructions' => __('statamic::fieldtypes.bard.config.select_across_sites'), + 'type' => 'toggle', + ], 'container' => [ 'display' => __('Container'), 'instructions' => __('statamic::fieldtypes.bard.config.container'), diff --git a/src/Fieldtypes/Bard/Augmentor.php b/src/Fieldtypes/Bard/Augmentor.php index 4d1db607d80..b1dc686c889 100644 --- a/src/Fieldtypes/Bard/Augmentor.php +++ b/src/Fieldtypes/Bard/Augmentor.php @@ -13,6 +13,7 @@ class Augmentor { + public static $currentBardConfig = []; protected $fieldtype; protected $sets = []; protected $includeDisabledSets = false; @@ -169,7 +170,13 @@ public function renderHtmlToProsemirror(string $value) public function renderProsemirrorToHtml(array $value) { - return $this->editor()->setContent($value)->getHTML(); + static::$currentBardConfig = $this->fieldtype->config(); + + $html = $this->editor()->setContent($value)->getHTML(); + + static::$currentBardConfig = []; + + return $html; } private function editor() diff --git a/src/Fieldtypes/Bard/LinkMark.php b/src/Fieldtypes/Bard/LinkMark.php index d5bc90e84b0..8bf5cfb7b81 100644 --- a/src/Fieldtypes/Bard/LinkMark.php +++ b/src/Fieldtypes/Bard/LinkMark.php @@ -25,13 +25,12 @@ public function addAttributes() return [ 'href' => [ 'renderHTML' => function ($attributes) { - $href = $attributes->href; - if (! isset($href)) { + if (! isset($attributes->href)) { return null; } return [ - 'href' => $this->convertHref($href) ?? '', + 'href' => $this->convertHref($attributes->href) ?? '', ]; }, ], @@ -65,11 +64,13 @@ protected function convertHref($href) return ''; } - if (! $this->isApi() && $item instanceof Entry) { + $selectAcrossSites = Augmentor::$currentBardConfig['select_across_sites'] ?? false; + + if (! $selectAcrossSites && ! $this->isApi() && $item instanceof Entry) { return ($item->in(Site::current()->handle()) ?? $item)->url(); } - return $item->url(); + return $selectAcrossSites ? $item->absoluteUrl() : $item->url(); } private function isApi() diff --git a/src/Fieldtypes/Checkboxes.php b/src/Fieldtypes/Checkboxes.php index 1bd78438566..9a7d4102114 100644 --- a/src/Fieldtypes/Checkboxes.php +++ b/src/Fieldtypes/Checkboxes.php @@ -40,7 +40,7 @@ protected function configFieldItems(): array 'default' => [ 'display' => __('Default Value'), 'instructions' => __('statamic::messages.fields_default_instructions'), - 'type' => 'text', + 'type' => 'taggable', ], ], ], diff --git a/src/Fieldtypes/Concerns/ResolvesStatamicUrls.php b/src/Fieldtypes/Concerns/ResolvesStatamicUrls.php index 90eb25ae91b..79714ba3d38 100644 --- a/src/Fieldtypes/Concerns/ResolvesStatamicUrls.php +++ b/src/Fieldtypes/Concerns/ResolvesStatamicUrls.php @@ -13,11 +13,11 @@ trait ResolvesStatamicUrls */ protected function resolveStatamicUrls(string $content) { - return preg_replace_callback('/([("])statamic:\/\/([^()"]*)([)"])/im', function ($matches) { + return preg_replace_callback('/([("])statamic:\/\/([^()"?#]*)([^()"]*)([)"])/im', function ($matches) { $data = Data::find($matches[2]); - $url = $data ? $data->url() : ''; + $url = $data ? $data->url().$matches[3] : ''; - return $matches[1].$url.$matches[3]; + return $matches[1].$url.$matches[4]; }, $content); } } diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index d9aba7a16c1..7af05db4457 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -5,6 +5,7 @@ use Carbon\Exceptions\InvalidFormatException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use InvalidArgumentException; use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; @@ -348,6 +349,10 @@ public function toQueryableValue($value) private function parseSaved($value) { + if ($value instanceof Carbon) { + return $value; + } + try { return Carbon::createFromFormat($this->saveFormat(), $value); } catch (InvalidFormatException|InvalidArgumentException $e) { @@ -367,10 +372,16 @@ public function secondsEnabled() public function preProcessValidatable($value) { - Validator::make( - [$this->field->handle() => $value], - [$this->field->handle() => [new ValidationRule($this)]], - )->validate(); + try { + Validator::make( + ['field' => $value], + ['field' => [new ValidationRule($this)]], + [], + ['field' => $this->field->display()], + )->validate(); + } catch (ValidationException $e) { + throw ValidationException::withMessages([$this->field->fieldPathPrefix() => $e->errors()['field']]); + } if ($value === null) { return null; diff --git a/src/Fieldtypes/DictionaryFields.php b/src/Fieldtypes/DictionaryFields.php index 89eda653d01..54515d039e2 100644 --- a/src/Fieldtypes/DictionaryFields.php +++ b/src/Fieldtypes/DictionaryFields.php @@ -71,7 +71,7 @@ public function process($data): string|array return $dictionary->handle(); } - return array_merge(['type' => $dictionary->handle()], $values->all()); + return array_merge(['type' => $dictionary->handle()], $values->filter()->all()); } public function extraRules(): array diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 29a9b96e783..7631364c301 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -27,6 +27,8 @@ use Statamic\Search\Result; use Statamic\Support\Arr; +use function Statamic\trans as __; + class Entries extends Relationship { use QueriesFilters; @@ -93,6 +95,9 @@ protected function configFieldItems(): array 'instructions' => __('statamic::fieldtypes.entries.config.create'), 'type' => 'toggle', 'default' => true, + 'if' => [ + 'mode' => 'default', + ], ], 'collections' => [ 'display' => __('Collections'), @@ -177,7 +182,11 @@ protected function getFirstCollectionFromRequest($request) $collections = $this->getConfiguredCollections(); } - return Collection::findByHandle(Arr::first($collections)); + $collection = Collection::findByHandle($collectionHandle = Arr::first($collections)); + + throw_if(! $collection, new CollectionNotFoundException($collectionHandle)); + + return $collection; } public function getSortColumn($request) @@ -449,6 +458,14 @@ protected function getItemsForPreProcessIndex($values): SupportCollection return $this->queryBuilder($values)->whereAnyStatus()->get(); } + public function relationshipQueryBuilder() + { + $collections = $this->config('collections'); + + return Entry::query() + ->when($collections, fn ($query) => $query->whereIn('collection', $collections)); + } + public function filter() { return new EntriesFilter($this); diff --git a/src/Fieldtypes/Floatval.php b/src/Fieldtypes/Floatval.php index 84ba8d877e8..6d92b8e9262 100644 --- a/src/Fieldtypes/Floatval.php +++ b/src/Fieldtypes/Floatval.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes; +use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; use Statamic\Query\Scopes\Filters\Fields\Floatval as FloatFilter; @@ -56,4 +57,9 @@ public function filter() { return new FloatFilter($this); } + + public function toGqlType() + { + return GraphQL::type(GraphQL::float()); + } } diff --git a/src/Fieldtypes/Group.php b/src/Fieldtypes/Group.php index d9807da6b39..bea798351f1 100644 --- a/src/Fieldtypes/Group.php +++ b/src/Fieldtypes/Group.php @@ -7,6 +7,7 @@ use Statamic\Fields\Fieldtype; use Statamic\Fields\Values; use Statamic\GraphQL\Types\GroupType; +use Statamic\Support\Arr; use Statamic\Support\Str; class Group extends Fieldtype @@ -50,7 +51,9 @@ protected function configFieldItems(): array public function process($data) { - return $this->fields()->addValues($data ?? [])->process()->values()->all(); + $values = $this->fields()->addValues($data ?? [])->process()->values()->all(); + + return Arr::removeNullValues($values); } public function preProcess($data) @@ -75,7 +78,7 @@ public function extraRules(): array ->addValues((array) $this->field->value()) ->validator() ->withContext([ - 'prefix' => $this->field->handle().'.', + 'prefix' => $this->field->validationContext('prefix'), ]) ->rules(); diff --git a/src/Fieldtypes/HasSelectOptions.php b/src/Fieldtypes/HasSelectOptions.php index 7e1289a3c42..5109dc4ddc5 100644 --- a/src/Fieldtypes/HasSelectOptions.php +++ b/src/Fieldtypes/HasSelectOptions.php @@ -52,9 +52,8 @@ public function preProcessIndex($value) { $values = $this->preProcess($value); - $values = collect(is_array($values) ? $values : [$values]); - - return $values->map(function ($value) { + // NOTE: Null-coalescing into `[null]` as that matches old behaviour. + return collect($values ?? [null])->map(function ($value) { return $this->getLabel($value); })->all(); } @@ -67,9 +66,8 @@ public function preProcess($value) return []; } - $value = is_array($value) ? $value : [$value]; - - $values = collect($value)->map(function ($value) { + // NOTE: Null-coalescing into `[null]` as that matches old behaviour. + $values = collect($value ?? [null])->map(function ($value) { return $this->config('cast_booleans') ? $this->castFromBoolean($value) : $value; }); @@ -183,7 +181,7 @@ private function singleSelectGqlType() 'resolve' => function ($item, $args, $context, $info) { $resolved = $item->resolveGqlValue($info->fieldName); - return is_null($resolved->value()) ? null : $resolved; + return is_null($resolved?->value()) ? null : $resolved; }, ]; } diff --git a/src/Fieldtypes/Icon.php b/src/Fieldtypes/Icon.php index 1c9e5332c07..efc58f6cf1c 100644 --- a/src/Fieldtypes/Icon.php +++ b/src/Fieldtypes/Icon.php @@ -22,17 +22,23 @@ public function preload(): array { [$path, $directory, $folder, $hasConfiguredDirectory] = $this->resolveParts(); - $icons = collect(Folder::getFilesByType($path, 'svg'))->mapWithKeys(fn ($path) => [ - pathinfo($path)['filename'] => $hasConfiguredDirectory ? File::get($path) : null, - ]); - return [ + 'url' => cp_route('icon-fieldtype'), 'native' => ! $hasConfiguredDirectory, + 'directory' => $directory, 'set' => $folder, - 'icons' => $icons->all(), ]; } + public function icons() + { + [$path, $directory, $folder, $hasConfiguredDirectory] = $this->resolveParts(); + + return collect(Folder::getFilesByType($path, 'svg'))->mapWithKeys(fn ($path) => [ + pathinfo($path)['filename'] => $hasConfiguredDirectory ? File::get($path) : null, + ])->all(); + } + protected function configFieldItems(): array { return [ @@ -63,6 +69,10 @@ protected function configFieldItems(): array public function augment($value) { + if (! $value) { + return null; + } + [$path] = $this->resolveParts(); return File::get($path.'/'.$value.'.svg'); diff --git a/src/Fieldtypes/Link.php b/src/Fieldtypes/Link.php index 61a9371e567..e932ad5f4cf 100644 --- a/src/Fieldtypes/Link.php +++ b/src/Fieldtypes/Link.php @@ -37,6 +37,11 @@ protected function configFieldItems(): array 'mode' => 'select', 'max_items' => 1, ], + 'select_across_sites' => [ + 'display' => __('Select Across Sites'), + 'instructions' => __('statamic::fieldtypes.entries.config.select_across_sites'), + 'type' => 'toggle', + ], ], ], ]; @@ -44,10 +49,13 @@ protected function configFieldItems(): array public function augment($value) { + $localize = ! $this->canSelectAcrossSites(); + return new ArrayableLink( $value - ? ResolveRedirect::item($value, $this->field->parent(), true) - : null + ? ResolveRedirect::item($value, $this->field->parent(), $localize) + : null, + ['select_across_sites' => $this->canSelectAcrossSites()] ); } @@ -108,6 +116,7 @@ private function nestedEntriesFieldtype($value): Fieldtype 'type' => 'entries', 'max_items' => 1, 'create' => false, + 'select_across_sites' => $this->canSelectAcrossSites(), ])); $entryField->setValue($value); @@ -190,4 +199,31 @@ public function toGqlType() }, ]; } + + protected function getConfiguredCollections() + { + return empty($collections = $this->config('collections')) + ? \Statamic\Facades\Collection::handles()->all() + : $collections; + } + + private function canSelectAcrossSites(): bool + { + return $this->config('select_across_sites', false); + } + + private function availableSites() + { + if (! Site::hasMultiple()) { + return []; + } + + $configuredSites = collect($this->getConfiguredCollections())->flatMap(fn ($collection) => \Statamic\Facades\Collection::find($collection)->sites()); + + return Site::authorized() + ->when(isset($configuredSites), fn ($sites) => $sites->filter(fn ($site) => $configuredSites->contains($site->handle()))) + ->map->handle() + ->values() + ->all(); + } } diff --git a/src/Fieldtypes/Link/ArrayableLink.php b/src/Fieldtypes/Link/ArrayableLink.php index 34658795f42..25e9a1458c8 100644 --- a/src/Fieldtypes/Link/ArrayableLink.php +++ b/src/Fieldtypes/Link/ArrayableLink.php @@ -2,6 +2,7 @@ namespace Statamic\Fieldtypes\Link; +use Illuminate\Support\Arr; use Statamic\Fields\ArrayableString; class ArrayableLink extends ArrayableString @@ -26,6 +27,14 @@ public function jsonSerialize() public function url() { - return is_object($this->value) ? $this->value?->url() : $this->value; + if (! is_object($this->value)) { + return $this->value; + } + + if (Arr::get($this->extra(), 'select_across_sites')) { + return $this->value->absoluteUrl(); + } + + return $this->value?->url(); } } diff --git a/src/Fieldtypes/Lists.php b/src/Fieldtypes/Lists.php index e42dba1433a..9fac3224547 100644 --- a/src/Fieldtypes/Lists.php +++ b/src/Fieldtypes/Lists.php @@ -38,6 +38,10 @@ public function process($data) return collect($data)->reject(function ($item) { return in_array($item, [null, ''], true); + })->map(function ($item) { + return is_numeric($item) + ? (str_contains($item, '.') ? (float) $item : (int) $item) + : $item; })->values()->all(); } diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php index 4acc544a946..035b7cfc7a1 100644 --- a/src/Fieldtypes/Markdown.php +++ b/src/Fieldtypes/Markdown.php @@ -198,4 +198,9 @@ public function preload() 'previewUrl' => cp_route('markdown.preview'), ]; } + + public function shouldParseAntlersFromRawString(): bool + { + return $this->config('smartypants', false); + } } diff --git a/src/Fieldtypes/Relationship.php b/src/Fieldtypes/Relationship.php index f785753cc79..48077226504 100644 --- a/src/Fieldtypes/Relationship.php +++ b/src/Fieldtypes/Relationship.php @@ -26,6 +26,7 @@ abstract class Relationship extends Fieldtype protected $formComponentProps = [ '_' => '_', // forces an object in js ]; + protected $formStackSize; protected function configFieldItems(): array { @@ -133,6 +134,7 @@ public function preload() 'creatables' => $this->canCreate() ? $this->getCreatables() : [], 'formComponent' => $this->getFormComponent(), 'formComponentProps' => $this->getFormComponentProps(), + 'formStackSize' => $this->getFormStackSize(), 'taggable' => $this->getTaggable(), 'initialSortColumn' => $this->initialSortColumn(), 'initialSortDirection' => $this->initialSortDirection(), @@ -182,6 +184,11 @@ protected function getFormComponentProps() return $this->formComponentProps; } + protected function getFormStackSize() + { + return $this->formStackSize; + } + protected function getColumns() { return [ diff --git a/src/Fieldtypes/Table.php b/src/Fieldtypes/Table.php index bf235cc516c..d75e770fafc 100644 --- a/src/Fieldtypes/Table.php +++ b/src/Fieldtypes/Table.php @@ -18,6 +18,16 @@ protected function configFieldItems(): array 'instructions' => __('statamic::messages.fields_default_instructions'), 'type' => 'table', ], + 'max_rows' => [ + 'display' => __('Max Rows'), + 'instructions' => __('statamic::fieldtypes.table.config.max_rows'), + 'type' => 'integer', + ], + 'max_columns' => [ + 'display' => __('Max Columns'), + 'instructions' => __('statamic::fieldtypes.table.config.max_columns'), + 'type' => 'integer', + ], ]; } diff --git a/src/Fieldtypes/Taggable.php b/src/Fieldtypes/Taggable.php index 37ba11e89fe..c88337cbe95 100644 --- a/src/Fieldtypes/Taggable.php +++ b/src/Fieldtypes/Taggable.php @@ -4,6 +4,7 @@ use Statamic\Facades\GraphQL; use Statamic\Fields\Fieldtype; +use Statamic\Support\Arr; class Taggable extends Fieldtype { @@ -38,7 +39,7 @@ public function preload() public function preProcess($data) { - return ($data) ? $data : []; + return Arr::wrap($data); } public function toGqlType() diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index c3b7dd1bd03..ce404f113b3 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -26,6 +26,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class Terms extends Relationship { protected $canEdit = true; @@ -86,6 +88,9 @@ protected function configFieldItems(): array 'instructions' => __('statamic::fieldtypes.terms.config.create'), 'type' => 'toggle', 'default' => true, + 'if' => [ + 'mode' => 'default', + ], ], 'taxonomies' => [ 'display' => __('Taxonomies'), @@ -117,7 +122,15 @@ public function augment($values) { $single = $this->config('max_items') === 1; - if ($single && Blink::has($key = 'terms-augment-'.json_encode($values))) { + // The parent is the item this terms fieldtype exists on. Most commonly an + // entry, but could also be something else, like another taxonomy term. + $parent = $this->field->parent(); + + $site = $parent && $parent instanceof Localization + ? $parent->locale() + : Site::current()->handle(); // Use the "current" site so this will get localized appropriately on the front-end. + + if ($single && Blink::has($key = 'terms-augment-'.$site.'-'.json_encode($values))) { return Blink::get($key); } @@ -371,6 +384,7 @@ protected function toItemArray($id) 'published' => $term->published(), 'private' => $term->private(), 'edit_url' => $term->editUrl(), + 'editable' => User::current()->can('edit', $term), 'hint' => $this->getItemHint($term), ]; } @@ -483,6 +497,21 @@ protected function getItemsForPreProcessIndex($values): Collection return $this->config('max_items') === 1 ? collect([$augmented]) : $augmented->get(); } + public function relationshipQueryBuilder() + { + $taxonomies = $this->taxonomies(); + + return Term::query() + ->when($taxonomies, fn ($query) => $query->whereIn('taxonomy', $taxonomies)); + } + + public function relationshipQueryIdMapFn(): ?\Closure + { + return $this->usingSingleTaxonomy() + ? fn ($term) => Str::after($term->id(), '::') + : null; + } + public function getItemHint($item): ?string { return collect([ diff --git a/src/Fieldtypes/Text.php b/src/Fieldtypes/Text.php index d2e8dbfc32b..dfc580e74c4 100644 --- a/src/Fieldtypes/Text.php +++ b/src/Fieldtypes/Text.php @@ -154,8 +154,10 @@ public function process($data) public function preProcessIndex($value) { - if ($value) { - return $this->config('prepend').$value.$this->config('append'); + if (is_null($value)) { + return null; } + + return $this->config('prepend').$value.$this->config('append'); } } diff --git a/src/Fieldtypes/Toggle.php b/src/Fieldtypes/Toggle.php index a689de319d6..8deb4cf095f 100644 --- a/src/Fieldtypes/Toggle.php +++ b/src/Fieldtypes/Toggle.php @@ -69,4 +69,9 @@ public function filter() { return new ToggleFilter($this); } + + public function toQueryableValue($value) + { + return (bool) $value; + } } diff --git a/src/Fieldtypes/UserGroups.php b/src/Fieldtypes/UserGroups.php index 29204d5bc8e..fe295dc4f1b 100644 --- a/src/Fieldtypes/UserGroups.php +++ b/src/Fieldtypes/UserGroups.php @@ -7,6 +7,8 @@ use Statamic\Facades\UserGroup; use Statamic\GraphQL\Types\UserGroupType; +use function Statamic\trans as __; + class UserGroups extends Relationship { protected $canEdit = false; @@ -17,7 +19,7 @@ protected function toItemArray($id, $site = null) { if ($group = UserGroup::find($id)) { return [ - 'title' => $group->title(), + 'title' => __($group->title()), 'id' => $group->handle(), ]; } @@ -30,7 +32,7 @@ public function getIndexItems($request) return UserGroup::all()->sortBy('title')->map(function ($group) { return [ 'id' => $group->handle(), - 'title' => $group->title(), + 'title' => __($group->title()), ]; })->values(); } diff --git a/src/Fieldtypes/UserRoles.php b/src/Fieldtypes/UserRoles.php index f056fa35733..2dd0be07708 100644 --- a/src/Fieldtypes/UserRoles.php +++ b/src/Fieldtypes/UserRoles.php @@ -7,6 +7,8 @@ use Statamic\Facades\Scope; use Statamic\GraphQL\Types\RoleType; +use function Statamic\trans as __; + class UserRoles extends Relationship { protected $canEdit = false; diff --git a/src/Fieldtypes/Users.php b/src/Fieldtypes/Users.php index 641d7d99f5d..3e688ffb626 100644 --- a/src/Fieldtypes/Users.php +++ b/src/Fieldtypes/Users.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Illuminate\Support\Collection; +use Statamic\Contracts\Auth\User as UserContract; use Statamic\CP\Column; use Statamic\Facades\GraphQL; use Statamic\Facades\Scope; @@ -86,10 +87,13 @@ public function preProcess($data) protected function toItemArray($id, $site = null) { if ($user = User::find($id)) { + $canViewUsers = $this->canViewUser($user); + return [ - 'title' => $user->name(), + 'title' => $this->userTitle($user, $canViewUsers), 'id' => $id, 'edit_url' => $user->editUrl(), + 'editable' => User::current()->can('edit', $user), ]; } @@ -106,7 +110,9 @@ public function getIndexItems($request) } else { $query->where(function ($query) use ($search) { $query - ->where('email', 'like', '%'.$search.'%') + ->when($this->canViewUsers(), function ($query) use ($search) { + $query->where('email', 'like', '%'.$search.'%'); + }) ->when(User::blueprint()->hasField('first_name'), function ($query) use ($search) { foreach (explode(' ', $search) as $word) { $query @@ -131,11 +137,18 @@ public function getIndexItems($request) $user = $user->getSearchable(); } - return [ + $canViewUsers = $this->canViewUser($user); + + $fields = [ 'id' => $user->id(), - 'title' => $user->name(), - 'email' => $user->email(), + 'title' => $this->userTitle($user, $canViewUsers), ]; + + if ($canViewUsers) { + $fields['email'] = $user->email(); + } + + return $fields; }; if ($request->boolean('paginate', true)) { @@ -151,24 +164,54 @@ public function getIndexItems($request) protected function getColumns() { - return [ + $columns = [ Column::make('title')->label('Name'), - Column::make('email'), ]; + + if ($this->canViewUsers()) { + $columns[] = Column::make('email'); + } + + return $columns; } public function preProcessIndex($data) { - return $this->getItemsForPreProcessIndex($data)->map(function ($user) { + $canViewUsers = $this->canViewUsers(); + + return $this->getItemsForPreProcessIndex($data)->map(function ($user) use ($canViewUsers) { return [ 'id' => $user->id(), - 'title' => $user->name(), + 'title' => $this->userTitle($user, $canViewUsers), 'edit_url' => $user->editUrl(), 'published' => null, ]; })->filter()->values(); } + private function userTitle($user, bool $canViewUsers): ?string + { + return $user->name() ?? ($canViewUsers ? $user->email() : $user->id()); + } + + private function canViewUsers(): bool + { + if (! $current = User::current()) { + return false; + } + + return $current->can('index', UserContract::class); + } + + private function canViewUser($user): bool + { + if (! $current = User::current()) { + return false; + } + + return $current->can('view', $user); + } + protected function getItemsForPreProcessIndex($values): Collection { if (! $augmented = $this->augment($values)) { @@ -228,4 +271,9 @@ public function filter() { return new UserFilter($this); } + + public function relationshipQueryBuilder() + { + return User::query(); + } } diff --git a/src/Forms/DeleteTemporaryAttachments.php b/src/Forms/DeleteTemporaryAttachments.php index 9d49785f52c..2905f270cd0 100644 --- a/src/Forms/DeleteTemporaryAttachments.php +++ b/src/Forms/DeleteTemporaryAttachments.php @@ -31,6 +31,8 @@ public function handle() $this->submission->remove($field->handle()); }); - $this->submission->saveQuietly(); + if ($this->submission->form()->store()) { + $this->submission->saveQuietly(); + } } } diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 6b92ba3f516..0ce40bf5671 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -7,12 +7,17 @@ use Illuminate\Queue\SerializesModels; use Statamic\Contracts\Forms\Submission; use Statamic\Facades\Antlers; +use Statamic\Facades\Blueprint; use Statamic\Facades\Config; +use Statamic\Facades\Form; use Statamic\Facades\GlobalSet; use Statamic\Facades\Parse; use Statamic\Sites\Site; use Statamic\Support\Arr; use Statamic\Support\Str; +use Statamic\View\Cascade; + +use function Statamic\trans as __; class Email extends Mailable { @@ -153,15 +158,20 @@ private function attachFiles($field) protected function addData() { $augmented = $this->submission->toAugmentedArray(); + $form = $this->submission->form(); $fields = $this->getRenderableFieldData(Arr::except($augmented, ['id', 'date', 'form'])) ->reject(fn ($field) => $field['fieldtype'] === 'spacer') ->when(Arr::has($this->config, 'attachments'), function ($fields) { return $fields->reject(fn ($field) => in_array($field['fieldtype'], ['assets', 'files'])); }); + $formConfig = ($configFields = Form::extraConfigFor($form->handle())) + ? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->values()->all() + : []; $data = array_merge($augmented, $this->getGlobalsData(), [ + 'form_config' => $formConfig, 'email_config' => $this->config, - 'config' => config()->all(), + 'config' => Cascade::config(), 'fields' => $fields, 'site_url' => Config::getSiteUrl(), 'date' => now(), @@ -235,8 +245,8 @@ protected function parseConfig(array $config) return collect($config)->map(function ($value) { $value = Parse::env($value); // deprecated - return (string) Antlers::parse($value, array_merge( - ['config' => config()->all()], + return (string) Antlers::parseUserContent($value, array_merge( + ['config' => Cascade::config()], $this->getGlobalsData(), $this->submissionData, )); diff --git a/src/Forms/Exporters/Exporter.php b/src/Forms/Exporters/Exporter.php index fff2aaf19f6..518cafca6d3 100644 --- a/src/Forms/Exporters/Exporter.php +++ b/src/Forms/Exporters/Exporter.php @@ -7,6 +7,8 @@ use Statamic\Facades\File; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use function Statamic\trans as __; + abstract class Exporter { protected static string $title; diff --git a/src/Forms/Fieldtype.php b/src/Forms/Fieldtype.php index f68ab514618..dab39ec5e0d 100644 --- a/src/Forms/Fieldtype.php +++ b/src/Forms/Fieldtype.php @@ -12,6 +12,8 @@ use Statamic\Query\ItemQueryBuilder; use Statamic\Query\Scopes\Filter; +use function Statamic\trans as __; + class Fieldtype extends Relationship { protected static $handle = 'form'; diff --git a/src/Forms/Form.php b/src/Forms/Form.php index e5af45130d7..d093537ead2 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -45,6 +45,13 @@ class Form implements Arrayable, Augmentable, FormContract public function __construct() { $this->data = collect(); + $this->supplements = collect(); + } + + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; } /** diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index c279bef08a8..b623de4b9cb 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -48,6 +48,12 @@ public function __construct() $this->supplements = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; + } + /** * Get or set the ID. * @@ -101,7 +107,7 @@ public function columns() */ public function date() { - return Carbon::createFromTimestamp($this->id()); + return Carbon::createFromTimestamp($this->id(), config('app.timezone')); } /** diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php index f44018e7c65..b78f52b5e7e 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -69,7 +69,7 @@ public function create() $jsDriver = $this->parseJsParamDriverAndOptions($this->params->get('js'), $form); $data['form_config'] = ($configFields = Form::extraConfigFor($form->handle())) - ? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->values()->all() + ? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->augment()->values()->all() : []; $data['sections'] = $this->getSections($this->sessionHandle(), $jsDriver); @@ -81,7 +81,7 @@ public function create() if ($jsDriver) { $data['js_driver'] = $jsDriver->handle(); $data['show_field'] = $jsDriver->copyShowFieldToFormData($data['fields']); - $data = array_merge($data, $jsDriver->addToFormData($form, $data)); + $data = array_merge($data, $jsDriver->addToFormData($data)); } $this->addToDebugBar($data, $formHandle); diff --git a/src/Git/CommitCommand.php b/src/Git/CommitCommand.php index ff00a5de01e..cefcadd9fe2 100644 --- a/src/Git/CommitCommand.php +++ b/src/Git/CommitCommand.php @@ -6,6 +6,8 @@ use Statamic\Console\RunsInPlease; use Statamic\Facades\Git; +use function Statamic\trans as __; + class CommitCommand extends Command { use RunsInPlease; diff --git a/src/Git/Git.php b/src/Git/Git.php index a19b24d27b4..073a5a42342 100644 --- a/src/Git/Git.php +++ b/src/Git/Git.php @@ -3,6 +3,7 @@ namespace Statamic\Git; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use Statamic\Console\Processes\Git as GitProcess; use Statamic\Contracts\Auth\User as UserContract; use Statamic\Facades\Antlers; @@ -85,7 +86,7 @@ public function commit($message = null) public function dispatchCommit($message = null) { if ($delay = config('statamic.git.dispatch_delay')) { - $delayInMinutes = now()->addMinutes($delay); + $delayInMinutes = now()->addMinutes((int) $delay); $message = null; } @@ -255,7 +256,7 @@ protected function getCommandContext($paths, $message) { return [ 'git' => config('statamic.git.binary'), - 'paths' => collect($paths)->implode(' '), + 'paths' => $this->shellQuotePaths($paths), 'message' => $this->shellEscape($message), 'name' => $this->shellEscape($this->gitUserName()), 'email' => $this->shellEscape($this->gitUserEmail()), @@ -282,4 +283,14 @@ protected function shellEscape(string $string) return escapeshellcmd($string); } + + /** + * Shell quote paths to a string for use in git commands. + */ + protected function shellQuotePaths(Collection $paths): string + { + return collect($paths) + ->map(fn ($path) => '"'.$path.'"') + ->implode(' '); + } } diff --git a/src/Globals/Variables.php b/src/Globals/Variables.php index fb7cec42f8f..8d7992f4467 100644 --- a/src/Globals/Variables.php +++ b/src/Globals/Variables.php @@ -44,6 +44,12 @@ public function __construct() $this->supplements = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->supplements = clone $this->supplements; + } + public function globalSet($set = null) { return $this->fluentlyGetOrSet('set') diff --git a/src/GraphQL/DefaultSchema.php b/src/GraphQL/DefaultSchema.php index 9e879200f0d..fafb6150581 100644 --- a/src/GraphQL/DefaultSchema.php +++ b/src/GraphQL/DefaultSchema.php @@ -93,4 +93,9 @@ private function getMiddleware() GraphQL::getExtraMiddleware() ); } + + private function getMutations() + { + return config('statamic.graphql.mutations', []); + } } diff --git a/src/GraphQL/Middleware/AuthorizeQueryScopes.php b/src/GraphQL/Middleware/AuthorizeQueryScopes.php new file mode 100644 index 00000000000..0d442894317 --- /dev/null +++ b/src/GraphQL/Middleware/AuthorizeQueryScopes.php @@ -0,0 +1,28 @@ +allowedScopes($args)); + + $forbidden = collect($args['query_scope'] ?? []) + ->keys() + ->filter(fn ($filter) => ! $allowedScopes->contains($filter)); + + if ($forbidden->isNotEmpty()) { + throw ValidationException::withMessages([ + 'query_scope' => 'Forbidden: '.$forbidden->join(', '), + ]); + } + + return $next($root, $args, $context, $info); + } +} diff --git a/src/GraphQL/Middleware/CacheResponse.php b/src/GraphQL/Middleware/CacheResponse.php index 45b6360f5ab..73b5b146363 100644 --- a/src/GraphQL/Middleware/CacheResponse.php +++ b/src/GraphQL/Middleware/CacheResponse.php @@ -13,6 +13,10 @@ public function handle($request, Closure $next) return $next($request); } + if ($this->isMutation($request)) { + return $next($request); + } + $cache = app(ResponseCache::class); if ($response = $cache->get($request)) { @@ -25,4 +29,11 @@ public function handle($request, Closure $next) return $response; } + + protected function isMutation($request): bool + { + $query = ltrim(strtolower($request->get('query', ''))); + + return str_starts_with($query, 'mutation'); + } } diff --git a/src/GraphQL/Queries/Concerns/ScopesQuery.php b/src/GraphQL/Queries/Concerns/ScopesQuery.php new file mode 100644 index 00000000000..97e33053e1a --- /dev/null +++ b/src/GraphQL/Queries/Concerns/ScopesQuery.php @@ -0,0 +1,24 @@ + $value) { + Scope::find($handle)?->apply($query, Arr::wrap($value)); + } + } +} diff --git a/src/GraphQL/Queries/EntriesQuery.php b/src/GraphQL/Queries/EntriesQuery.php index 6b2dfe6c1bd..434ec3cf22f 100644 --- a/src/GraphQL/Queries/EntriesQuery.php +++ b/src/GraphQL/Queries/EntriesQuery.php @@ -3,14 +3,17 @@ namespace Statamic\GraphQL\Queries; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Facades\Statamic\API\ResourceAuthorizer; use GraphQL\Type\Definition\Type; use Statamic\Facades\Entry; use Statamic\Facades\GraphQL; use Statamic\GraphQL\Middleware\AuthorizeFilters; +use Statamic\GraphQL\Middleware\AuthorizeQueryScopes; use Statamic\GraphQL\Middleware\AuthorizeSubResources; use Statamic\GraphQL\Middleware\ResolvePage; use Statamic\GraphQL\Queries\Concerns\FiltersQuery; +use Statamic\GraphQL\Queries\Concerns\ScopesQuery; use Statamic\GraphQL\Types\EntryInterface; use Statamic\GraphQL\Types\JsonArgument; use Statamic\Support\Str; @@ -21,6 +24,8 @@ class EntriesQuery extends Query filterQuery as traitFilterQuery; } + use ScopesQuery; + protected $attributes = [ 'name' => 'entries', ]; @@ -29,6 +34,7 @@ class EntriesQuery extends Query AuthorizeSubResources::class, ResolvePage::class, AuthorizeFilters::class, + AuthorizeQueryScopes::class, ]; public function type(): Type @@ -43,6 +49,7 @@ public function args(): array 'limit' => GraphQL::int(), 'page' => GraphQL::int(), 'filter' => GraphQL::type(JsonArgument::NAME), + 'query_scope' => GraphQL::type(JsonArgument::NAME), 'sort' => GraphQL::listOf(GraphQL::string()), 'site' => GraphQL::string(), ]; @@ -60,6 +67,8 @@ public function resolve($root, $args) $this->filterQuery($query, $args['filter'] ?? []); + $this->scopeQuery($query, $args['query_scope'] ?? []); + $this->sortQuery($query, $args['sort'] ?? []); return $query->paginate($args['limit'] ?? 1000); @@ -105,4 +114,9 @@ public function allowedFilters($args) { return FilterAuthorizer::allowedForSubResources('graphql', 'collections', $args['collection'] ?? '*'); } + + public function allowedScopes($args) + { + return QueryScopeAuthorizer::allowedForSubResources('graphql', 'collections', $args['collection'] ?? '*'); + } } diff --git a/src/GraphQL/Queries/EntryQuery.php b/src/GraphQL/Queries/EntryQuery.php index 9e2e870ff98..bb6f2d0577b 100644 --- a/src/GraphQL/Queries/EntryQuery.php +++ b/src/GraphQL/Queries/EntryQuery.php @@ -69,7 +69,7 @@ public function resolve($root, $args) $query->where('site', $site); } - $filters = $args['filter'] ?? null; + $filters = $args['filter'] ?? []; $this->filterQuery($query, $filters); @@ -107,7 +107,7 @@ public function resolve($root, $args) private function filterQuery($query, $filters) { - if (! isset($filters['status']) && ! isset($filters['published'])) { + if (! request()->isLivePreview() && (! isset($filters['status']) && ! isset($filters['published']))) { $filters['status'] = 'published'; } diff --git a/src/GraphQL/ResponseCache/DefaultCache.php b/src/GraphQL/ResponseCache/DefaultCache.php index baf313376f1..d49014bce31 100644 --- a/src/GraphQL/ResponseCache/DefaultCache.php +++ b/src/GraphQL/ResponseCache/DefaultCache.php @@ -19,7 +19,7 @@ public function put(Request $request, $response) { $key = $this->track($request); - $ttl = Carbon::now()->addMinutes(config('statamic.graphql.cache.expiry', 60)); + $ttl = Carbon::now()->addMinutes((int) config('statamic.graphql.cache.expiry', 60)); Cache::put($key, $response, $ttl); } diff --git a/src/GraphQL/TypeRegistrar.php b/src/GraphQL/TypeRegistrar.php index d0425e31abf..30936cc9834 100644 --- a/src/GraphQL/TypeRegistrar.php +++ b/src/GraphQL/TypeRegistrar.php @@ -17,10 +17,12 @@ use Statamic\GraphQL\Types\GlobalSetInterface; use Statamic\GraphQL\Types\JsonArgument; use Statamic\GraphQL\Types\LabeledValueType; +use Statamic\GraphQL\Types\NavPageInterface; use Statamic\GraphQL\Types\NavTreeBranchType; use Statamic\GraphQL\Types\NavType; use Statamic\GraphQL\Types\PageInterface; use Statamic\GraphQL\Types\RoleType; +use Statamic\GraphQL\Types\SectionType; use Statamic\GraphQL\Types\SiteType; use Statamic\GraphQL\Types\TableRowType; use Statamic\GraphQL\Types\TaxonomyType; @@ -62,6 +64,7 @@ public function register() GraphQL::addType(AssetInterface::class); GraphQL::addType(GlobalSetInterface::class); GraphQL::addType(FieldType::class); + GraphQL::addType(SectionType::class); PageInterface::addTypes(); EntryInterface::addTypes(); @@ -69,6 +72,7 @@ public function register() AssetInterface::addTypes(); GlobalSetInterface::addTypes(); UserType::addTypes(); + NavPageInterface::addTypes(); $this->registered = true; } diff --git a/src/GraphQL/Types/CollectionType.php b/src/GraphQL/Types/CollectionType.php index efad6b0524b..96e68844bbb 100644 --- a/src/GraphQL/Types/CollectionType.php +++ b/src/GraphQL/Types/CollectionType.php @@ -25,6 +25,9 @@ public function fields(): array 'structure' => [ 'type' => GraphQL::type(CollectionStructureType::NAME), ], + 'mount' => [ + 'type' => GraphQL::type(EntryInterface::NAME), + ], ])->merge(collect(GraphQL::getExtraTypeFields($this->name))->map(function ($closure) { return $closure(); }))->map(function (array $arr) { @@ -41,6 +44,10 @@ private function resolver() return $collection->structure(); } + if ($info->fieldName === 'mount') { + return $collection->mount(); + } + $value = $collection->augmentedValue($info->fieldName); if ($value instanceof Value) { diff --git a/src/GraphQL/Types/FieldType.php b/src/GraphQL/Types/FieldType.php index 76b0d97beba..829bad16c23 100644 --- a/src/GraphQL/Types/FieldType.php +++ b/src/GraphQL/Types/FieldType.php @@ -46,6 +46,18 @@ public function fields(): array return $field->config()['width'] ?? 100; }, ], + 'if' => [ + 'type' => GraphQL::type(ArrayType::NAME), + 'resolve' => function ($field) { + return $field->config()['if'] ?? null; + }, + ], + 'unless' => [ + 'type' => GraphQL::type(ArrayType::NAME), + 'resolve' => function ($field) { + return $field->config()['unless'] ?? null; + }, + ], 'config' => [ 'type' => GraphQL::type(ArrayType::NAME), 'resolve' => function ($field) { diff --git a/src/GraphQL/Types/FormType.php b/src/GraphQL/Types/FormType.php index f7182ad835b..5552eb43e76 100644 --- a/src/GraphQL/Types/FormType.php +++ b/src/GraphQL/Types/FormType.php @@ -3,6 +3,7 @@ namespace Statamic\GraphQL\Types; use Statamic\Contracts\Forms\Form; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\GraphQL; use Statamic\Fields\Value; @@ -35,7 +36,27 @@ public function fields(): array 'rules' => [ 'type' => GraphQL::type(ArrayType::NAME), 'resolve' => function ($form, $args, $context, $info) { - return $form->blueprint()->fields()->validator()->rules(); + return collect($form->blueprint()->fields()->validator()->rules()) + ->map(function ($rules) { + return collect($rules)->map(function ($rule) { + if (is_string($rule)) { + return $rule; + } + + if ($rule instanceof CastableToValidationString) { + return $rule->toGqlValidationString(); + } + + return get_class($rule).'::class'; + }); + }) + ->all(); + }, + ], + 'sections' => [ + 'type' => GraphQL::listOf(GraphQL::type(SectionType::NAME)), + 'resolve' => function ($form, $args, $context, $info) { + return $form->blueprint()->tabs()->first()->sections()->all(); }, ], ])->map(function (array $arr) { diff --git a/src/GraphQL/Types/NavPageInterface.php b/src/GraphQL/Types/NavPageInterface.php index f464713edda..b542fd61b15 100644 --- a/src/GraphQL/Types/NavPageInterface.php +++ b/src/GraphQL/Types/NavPageInterface.php @@ -4,6 +4,8 @@ use Rebing\GraphQL\Support\InterfaceType; use Statamic\Contracts\Structures\Nav; +use Statamic\Facades\GraphQL; +use Statamic\Facades\Nav as NavAPI; use Statamic\Support\Str; class NavPageInterface extends InterfaceType @@ -18,11 +20,26 @@ public function __construct(Nav $nav) public function fields(): array { - return $this->nav->blueprint()->fields()->toGql()->all(); + if ($fields = $this->nav->blueprint()->fields()->toGql()->all()) { + return $fields; + } + + return collect([ + '_' => [ + 'type' => GraphQL::string(), + ], + ])->all(); } public static function buildName(Nav $nav): string { return 'NavPage_'.Str::studly($nav->handle()); } + + public static function addTypes() + { + GraphQL::addTypes(NavAPI::all()->each(function ($nav) { + optional($nav->blueprint())->addGqlTypes(); + })->mapInto(NavBasicPageType::class)->all()); + } } diff --git a/src/GraphQL/Types/SectionType.php b/src/GraphQL/Types/SectionType.php new file mode 100644 index 00000000000..9feee181257 --- /dev/null +++ b/src/GraphQL/Types/SectionType.php @@ -0,0 +1,38 @@ + self::NAME, + ]; + + public function fields(): array + { + return [ + 'display' => [ + 'type' => GraphQL::string(), + 'resolve' => function ($section) { + return $section->display(); + }, + ], + 'instructions' => [ + 'type' => GraphQL::string(), + 'resolve' => function ($section) { + return $section->instructions(); + }, + ], + 'fields' => [ + 'type' => GraphQL::listOf(GraphQL::type(FieldType::NAME)), + 'resolve' => function ($section) { + return $section->fields()->all(); + }, + ], + ]; + } +} diff --git a/src/Http/Controllers/API/ApiController.php b/src/Http/Controllers/API/ApiController.php index 4ab286fdb1e..dfd61b05fab 100644 --- a/src/Http/Controllers/API/ApiController.php +++ b/src/Http/Controllers/API/ApiController.php @@ -5,8 +5,10 @@ use Facades\Statamic\API\ResourceAuthorizer; use Statamic\Exceptions\ApiValidationException; use Statamic\Exceptions\NotFoundHttpException; +use Statamic\Facades\Scope; use Statamic\Facades\Site; use Statamic\Http\Controllers\Controller; +use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Tags\Concerns\QueriesConditions; @@ -80,12 +82,26 @@ protected function filterAllowedResources($items) * * @param \Statamic\Query\Builder $query * @return \Statamic\Extensions\Pagination\LengthAwarePaginator + * + * @deprecated */ protected function filterSortAndPaginate($query) + { + return $this->updateAndPaginate($query); + } + + /** + * Filter, sort, scope, and paginate query for API resource output. + * + * @param \Statamic\Query\Builder $query + * @return \Statamic\Extensions\Pagination\LengthAwarePaginator + */ + protected function updateAndPaginate($query) { return $this ->filter($query) ->sort($query) + ->scope($query) ->paginate($query); } @@ -171,6 +187,52 @@ protected function doesntHaveFilter($field) ->contains($field); } + /** + * Apply query scopes a query based on conditions in the query_scope parameter. + * + * /endpoint?query_scope[scope_handle]=foo&query_scope[another_scope]=bar + * + * @param \Statamic\Query\Builder $query + * @return $this + */ + protected function scope($query) + { + $this->getScopes() + ->each(function ($value, $handle) use ($query) { + Scope::find($handle)?->apply($query, Arr::wrap($value)); + }); + + return $this; + } + + /** + * Get scopes for querying. + * + * @return \Illuminate\Support\Collection + */ + protected function getScopes() + { + if (! method_exists($this, 'allowedQueryScopes')) { + return collect(); + } + + $scopes = collect(request()->query_scope ?? []); + + $allowedScopes = collect($this->allowedQueryScopes()); + + $forbidden = $scopes + ->keys() + ->filter(fn ($handle) => ! Scope::find($handle) || ! $allowedScopes->contains($handle)); + + if ($forbidden->isNotEmpty()) { + throw ApiValidationException::withMessages([ + 'query_scope' => Str::plural('Forbidden query scope', $forbidden).': '.$forbidden->join(', '), + ]); + } + + return $scopes; + } + /** * Sorts the query based on the sort parameter. * diff --git a/src/Http/Controllers/API/AssetsController.php b/src/Http/Controllers/API/AssetsController.php index 9d144b4834f..3fc9ebc61f1 100644 --- a/src/Http/Controllers/API/AssetsController.php +++ b/src/Http/Controllers/API/AssetsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Http\Resources\API\AssetResource; class AssetsController extends ApiController @@ -22,7 +23,7 @@ public function index($assetContainer) ->filter->isRelationship()->keys()->all(); return app(AssetResource::class)::collection( - $this->filterSortAndPaginate($assetContainer->queryAssets()->with($with)) + $this->updateAndPaginate($assetContainer->queryAssets()->with($with)) ); } @@ -37,4 +38,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); + } } diff --git a/src/Http/Controllers/API/CollectionEntriesController.php b/src/Http/Controllers/API/CollectionEntriesController.php index 892d823c966..13b3d7c73b8 100644 --- a/src/Http/Controllers/API/CollectionEntriesController.php +++ b/src/Http/Controllers/API/CollectionEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Entry; use Statamic\Http\Resources\API\EntryResource; @@ -29,7 +30,7 @@ public function index($collection) ->filter->isRelationship()->keys()->all(); return app(EntryResource::class)::collection( - $this->filterSortAndPaginate($collection->queryEntries()->with($with)) + $this->updateAndPaginate($collection->queryEntries()->with($with)) ); } @@ -81,4 +82,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/CollectionTreeController.php b/src/Http/Controllers/API/CollectionTreeController.php index 9a58dbbe723..2a5cd9f0e9d 100644 --- a/src/Http/Controllers/API/CollectionTreeController.php +++ b/src/Http/Controllers/API/CollectionTreeController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Http\Resources\API\TreeResource; use Statamic\Query\ItemQueryBuilder; @@ -48,4 +49,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermEntriesController.php b/src/Http/Controllers/API/TaxonomyTermEntriesController.php index 7b732c99205..cf92e8b3e2b 100644 --- a/src/Http/Controllers/API/TaxonomyTermEntriesController.php +++ b/src/Http/Controllers/API/TaxonomyTermEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Facades\Statamic\API\ResourceAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Collection; @@ -46,7 +47,7 @@ public function index($taxonomy, $term) $with = $this->getRelationshipFieldsFromCollections($taxonomy); return app(EntryResource::class)::collection( - $this->filterSortAndPaginate($query->with($with)) + $this->updateAndPaginate($query->with($with)) ); } @@ -72,4 +73,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermsController.php b/src/Http/Controllers/API/TaxonomyTermsController.php index b97b50f96f5..93d40fa860b 100644 --- a/src/Http/Controllers/API/TaxonomyTermsController.php +++ b/src/Http/Controllers/API/TaxonomyTermsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Term; use Statamic\Http\Resources\API\TermResource; @@ -24,7 +25,7 @@ public function index($taxonomy) ->filter->isRelationship()->keys()->all(); return app(TermResource::class)::collection( - $this->filterSortAndPaginate($taxonomy->queryTerms()->with($with)) + $this->updateAndPaginate($taxonomy->queryTerms()->with($with)) ); } @@ -43,4 +44,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); + } } diff --git a/src/Http/Controllers/API/UsersController.php b/src/Http/Controllers/API/UsersController.php index de0a0a2f899..25d9ca367e9 100644 --- a/src/Http/Controllers/API/UsersController.php +++ b/src/Http/Controllers/API/UsersController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\User; use Statamic\Http\Resources\API\UserResource; @@ -16,7 +17,7 @@ public function index() $this->abortIfDisabled(); return app(UserResource::class)::collection( - $this->filterSortAndPaginate(User::query()) + $this->updateAndPaginate(User::query()) ); } @@ -42,4 +43,9 @@ protected function allowedFilters() ->reject(fn ($field) => in_array($field, ['password', 'password_hash'])) ->all(); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForResource('api', 'users'); + } } diff --git a/src/Http/Controllers/CP/Assets/AssetContainersController.php b/src/Http/Controllers/CP/Assets/AssetContainersController.php index 28e1995fe0f..01dc24420c5 100644 --- a/src/Http/Controllers/CP/Assets/AssetContainersController.php +++ b/src/Http/Controllers/CP/Assets/AssetContainersController.php @@ -3,7 +3,9 @@ namespace Statamic\Http\Controllers\CP\Assets; use Illuminate\Http\Request; +use Statamic\Contracts\Assets\Asset; use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract; +use Statamic\Contracts\Assets\AssetFolder; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blueprint; use Statamic\Facades\User; @@ -25,11 +27,8 @@ public function index(Request $request) return [ 'id' => $container->handle(), 'title' => $container->title(), - 'allow_downloading' => $container->allowDownloading(), - 'allow_moving' => $container->allowMoving(), - 'allow_renaming' => $container->allowRenaming(), - 'allow_uploads' => $container->allowUploads(), - 'create_folders' => $container->createFolders(), + 'allow_uploads' => User::current()->can('store', [Asset::class, $container]), + 'create_folders' => User::current()->can('create', [AssetFolder::class, $container]), 'edit_url' => $container->editUrl(), 'delete_url' => $container->deleteUrl(), 'blueprint_url' => cp_route('asset-containers.blueprint.edit', $container->handle()), diff --git a/src/Http/Controllers/CP/Assets/AssetsController.php b/src/Http/Controllers/CP/Assets/AssetsController.php index 8f79537b8a5..7348ec2ed0c 100644 --- a/src/Http/Controllers/CP/Assets/AssetsController.php +++ b/src/Http/Controllers/CP/Assets/AssetsController.php @@ -2,6 +2,7 @@ namespace Statamic\Http\Controllers\CP\Assets; +use Facades\Statamic\Fields\Validator as FieldValidator; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -37,7 +38,9 @@ public function show($asset) { $asset = Asset::find(base64_decode($asset)); - // TODO: Auth + abort_if(! $asset, 404); + + $this->authorize('view', $asset); return new AssetResource($asset); } @@ -81,8 +84,12 @@ public function store(Request $request) abort_unless($container->allowUploads(), 403); $this->authorize('store', [AssetContract::class, $container]); + $validationRules = collect($container->validationRules()) + ->map(fn ($rule) => FieldValidator::parse($rule)) + ->all(); + $request->validate([ - 'file' => array_merge(['file', new AllowedFile], $container->validationRules()), + 'file' => array_merge(['file', new AllowedFile], $validationRules), ]); $file = $request->file('file'); @@ -124,7 +131,9 @@ public function download($asset) { $asset = Asset::find(base64_decode($asset)); - // TODO: Auth + abort_if(! $asset, 404); + + $this->authorize('view', $asset); return $asset->download(); } diff --git a/src/Http/Controllers/CP/Assets/FoldersController.php b/src/Http/Controllers/CP/Assets/FoldersController.php index 031ac13901a..ffe31234dec 100644 --- a/src/Http/Controllers/CP/Assets/FoldersController.php +++ b/src/Http/Controllers/CP/Assets/FoldersController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Statamic\Assets\AssetUploader; +use Statamic\Contracts\Assets\AssetFolder; use Statamic\Facades\Path; use Statamic\Http\Controllers\CP\CpController; use Statamic\Rules\AlphaDashSpace; @@ -13,7 +14,7 @@ class FoldersController extends CpController { public function store(Request $request, $container) { - abort_unless($container->createFolders(), 403); + $this->authorize('create', [AssetFolder::class, $container]); $request->validate([ 'path' => 'required', @@ -36,9 +37,4 @@ public function store(Request $request, $container) return $container->assetFolder($path)->save(); } - - public function update(Request $request, $container, $folder) - { - return $container->assetFolder($folder)->save(); - } } diff --git a/src/Http/Controllers/CP/Assets/PdfController.php b/src/Http/Controllers/CP/Assets/PdfController.php index a65e49a4d38..c180413118c 100644 --- a/src/Http/Controllers/CP/Assets/PdfController.php +++ b/src/Http/Controllers/CP/Assets/PdfController.php @@ -15,9 +15,11 @@ class PdfController extends Controller */ public function show($encodedAssetId) { - if (! $contents = $this->asset($encodedAssetId)->contents()) { - abort(500); - } + $asset = $this->asset($encodedAssetId); + + abort_if(! $contents = $asset->contents(), 500); + + $this->authorize('view', $asset); return response($contents)->header('Content-Type', 'application/pdf'); } diff --git a/src/Http/Controllers/CP/Assets/SvgController.php b/src/Http/Controllers/CP/Assets/SvgController.php index 606755957ed..f1a714bee63 100644 --- a/src/Http/Controllers/CP/Assets/SvgController.php +++ b/src/Http/Controllers/CP/Assets/SvgController.php @@ -17,9 +17,9 @@ public function show($asset) { $asset = $this->asset($asset); - if (! $contents = $asset->disk()->get($asset->path())) { - abort(500); - } + abort_if(! $contents = $asset->disk()->get($asset->path()), 500); + + $this->authorize('view', $asset); return response($contents)->header('Content-Type', 'image/svg+xml'); } diff --git a/src/Http/Controllers/CP/Assets/ThumbnailController.php b/src/Http/Controllers/CP/Assets/ThumbnailController.php index 8c0e1c2c943..6e529e1bd88 100644 --- a/src/Http/Controllers/CP/Assets/ThumbnailController.php +++ b/src/Http/Controllers/CP/Assets/ThumbnailController.php @@ -65,6 +65,8 @@ public function show($asset, $size = null, $orientation = null) $this->orientation = $orientation; $this->asset = $this->asset($asset); + $this->authorize('view', $this->asset); + if ($placeholder = $this->getPlaceholderResponse()) { return $placeholder; } @@ -174,7 +176,7 @@ private function mutex() /** * If an image is deemed too large for thumbnail generation, we'll give it a placeholder icon. * - * @return \Illuminate\Http\RedirectResponse|null + * @return \Illuminate\Http\Response */ private function getPlaceholderResponse() { @@ -185,6 +187,6 @@ private function getPlaceholderResponse() return; } - return redirect(Statamic::cpAssetUrl('svg/filetypes/picture.svg')); + return response(Statamic::svg('filetypes/picture'))->header('Content-Type', 'image/svg+xml'); } } diff --git a/src/Http/Controllers/CP/Auth/LoginController.php b/src/Http/Controllers/CP/Auth/LoginController.php index 990e697972b..8c45d2f827d 100644 --- a/src/Http/Controllers/CP/Auth/LoginController.php +++ b/src/Http/Controllers/CP/Auth/LoginController.php @@ -7,10 +7,13 @@ use Illuminate\Validation\ValidationException; use Statamic\Auth\ThrottlesLogins; use Statamic\Facades\OAuth; +use Statamic\Facades\URL; use Statamic\Http\Controllers\CP\CpController; use Statamic\Http\Middleware\CP\RedirectIfAuthorized; use Statamic\Support\Str; +use function Statamic\trans as __; + class LoginController extends CpController { use ThrottlesLogins; @@ -134,7 +137,9 @@ public function logout(Request $request) $request->session()->regenerateToken(); - return redirect($request->redirect ?? '/'); + $redirect = $request->redirect ?? '/'; + + return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect); } protected function getReferrer() diff --git a/src/Http/Controllers/CP/Collections/CollectionsController.php b/src/Http/Controllers/CP/Collections/CollectionsController.php index e482e809ba9..6765779a4db 100644 --- a/src/Http/Controllers/CP/Collections/CollectionsController.php +++ b/src/Http/Controllers/CP/Collections/CollectionsController.php @@ -20,6 +20,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class CollectionsController extends CpController { public function index(Request $request) diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php index 08921a71e1a..d21796205dd 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -9,6 +9,7 @@ use Statamic\Exceptions\BlueprintNotFoundException; use Statamic\Facades\Action; use Statamic\Facades\Asset; +use Statamic\Facades\Blink; use Statamic\Facades\Entry; use Statamic\Facades\Site; use Statamic\Facades\Stache; @@ -70,7 +71,11 @@ protected function indexQuery($collection) if ($search = request('search')) { if ($collection->hasSearchIndex()) { - return $collection->searchIndex()->ensureExists()->search($search); + return $collection + ->searchIndex() + ->ensureExists() + ->search($search) + ->where('collection', $collection->handle()); } $query->where('title', 'like', '%'.$search.'%'); @@ -89,6 +94,7 @@ public function edit(Request $request, $collection, $entry) $entry = $entry->fromWorkingCopy(); + Blink::forget("entry-{$entry->id()}-blueprint"); $blueprint = $entry->blueprint(); if (! $blueprint) { @@ -321,6 +327,7 @@ public function create(Request $request, $collection, $site) 'title' => $collection->createLabel(), 'actions' => [ 'save' => cp_route('collections.entries.store', [$collection->handle(), $site->handle()]), + 'editBlueprint' => cp_route('collections.blueprints.edit', [$collection, $blueprint]), ], 'values' => $values->all(), 'extraValues' => [ @@ -545,7 +552,7 @@ protected function breadcrumbs($collection) ], [ 'text' => $collection->title(), - 'url' => $collection->showUrl(), + 'url' => $collection->breadcrumbUrl(), ], ]); } diff --git a/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php b/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php index e196ada88ad..c8edb9759bb 100644 --- a/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php +++ b/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php @@ -31,6 +31,7 @@ protected function extractFromFields($entry, $blueprint) } $fields = $blueprint + ->setParent($entry) ->fields() ->addValues($values) ->preProcess(); @@ -43,6 +44,7 @@ protected function extractFromFields($entry, $blueprint) $extraValues = [ 'depth' => $entry->page()?->depth(), + 'children' => $entry->page()?->flattenedPages()->pluck('id')->all(), ]; return [$values->all(), $fields->meta(), $extraValues]; diff --git a/src/Http/Controllers/CP/CpController.php b/src/Http/Controllers/CP/CpController.php index f13f6b770a0..f0058352bb8 100644 --- a/src/Http/Controllers/CP/CpController.php +++ b/src/Http/Controllers/CP/CpController.php @@ -5,13 +5,8 @@ use Illuminate\Auth\Access\AuthorizationException as LaravelAuthException; use Illuminate\Http\Request; use Statamic\Exceptions\AuthorizationException; -use Statamic\Facades\File; -use Statamic\Facades\Folder; -use Statamic\Facades\YAML; use Statamic\Http\Controllers\Controller; use Statamic\Statamic; -use Statamic\Support\Arr; -use Statamic\Support\Str; /** * The base control panel controller. @@ -31,43 +26,6 @@ public function __construct(Request $request) $this->request = $request; } - /** - * Get all the template names from the current theme. - * - * @return array - */ - public function templates() - { - $templates = []; - - foreach (Folder::disk('resources')->getFilesByTypeRecursively('templates', 'html') as $path) { - $parts = explode('/', $path); - array_shift($parts); - $templates[] = Str::removeRight(implode('/', $parts), '.html'); - } - - return $templates; - } - - public function themes() - { - $themes = []; - - foreach (Folder::disk('themes')->getFolders('/') as $folder) { - $name = $folder; - - // Get the name if one exists in a meta file - if (File::disk('themes')->exists($folder.'/meta.yaml')) { - $meta = YAML::parse(File::disk('themes')->get($folder.'/meta.yaml')); - $name = Arr::get($meta, 'name', $folder); - } - - $themes[] = compact('folder', 'name'); - } - - return $themes; - } - /** * 404. */ diff --git a/src/Http/Controllers/CP/Fields/FieldsController.php b/src/Http/Controllers/CP/Fields/FieldsController.php index 2755ff8fe65..bb7b4753b48 100644 --- a/src/Http/Controllers/CP/Fields/FieldsController.php +++ b/src/Http/Controllers/CP/Fields/FieldsController.php @@ -11,6 +11,8 @@ use Statamic\Http\Middleware\CP\CanManageBlueprints; use Statamic\Support\Str; +use function Statamic\trans as __; + class FieldsController extends CpController { public function __construct() @@ -72,7 +74,11 @@ function ($attribute, $value, $fail) use ($request) { ->when($request->has('id'), fn ($collection) => $collection->reject(fn ($field) => $field['_id'] === $request->id)) ->flatMap(function (array $field) { if ($field['type'] === 'import') { - return Fieldset::find($field['fieldset'])->fields()->all()->map->handle()->toArray(); + return Fieldset::find($field['fieldset']) + ->fields()->all() + ->map(fn ($importedField) => ($field['prefix'] ?? '').$importedField->handle()) + ->values() + ->toArray(); } return [$field['handle']]; diff --git a/src/Http/Controllers/CP/Fields/FieldsetController.php b/src/Http/Controllers/CP/Fields/FieldsetController.php index 91e7544825e..83085278d33 100644 --- a/src/Http/Controllers/CP/Fields/FieldsetController.php +++ b/src/Http/Controllers/CP/Fields/FieldsetController.php @@ -12,6 +12,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class FieldsetController extends CpController { public function __construct() diff --git a/src/Http/Controllers/CP/Fieldtypes/IconFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/IconFieldtypeController.php new file mode 100644 index 00000000000..29bc3d54f08 --- /dev/null +++ b/src/Http/Controllers/CP/Fieldtypes/IconFieldtypeController.php @@ -0,0 +1,46 @@ +fieldtype($request); + + return [ + 'icons' => $fieldtype->icons(), + ]; + } + + protected function fieldtype($request) + { + $config = $this->getConfig($request); + + return Fieldtype::find('icon')->setField( + new Field('icon', $config) + ); + } + + private function getConfig($request) + { + // The fieldtype base64-encodes the config. + $json = base64_decode($request->config); + + // The json may include unicode characters, so we'll try to convert it to UTF-8. + // See https://github.com/statamic/cms/issues/566 + $utf8 = mb_convert_encoding($json, 'UTF-8', mb_list_encodings()); + + // In PHP 8.1 there's a bug where encoding will return null. It's fixed in 8.1.2. + // In this case, we'll fall back to the original JSON, but without the encoding. + // Issue #566 may still occur, but it's better than failing completely. + $json = empty($utf8) ? $json : $utf8; + + return json_decode($json, true); + } +} diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php index 2940c8f7ed4..843919d3004 100644 --- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php +++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php @@ -48,15 +48,17 @@ protected function indexQuery($form) $query = $form->querySubmissions(); if ($search = request('search')) { - $query->where('date', 'like', '%'.$search.'%'); - - $form->blueprint()->fields()->all() - ->filter(function (Field $field): bool { - return in_array($field->type(), ['text', 'textarea', 'integer']); - }) - ->each(function (Field $field) use ($query, $search): void { - $query->orWhere($field->handle(), 'like', '%'.$search.'%'); - }); + $query->where(function ($query) use ($form, $search) { + $query->where('date', 'like', '%'.$search.'%'); + + $form->blueprint()->fields()->all() + ->filter(function (Field $field): bool { + return in_array($field->type(), ['text', 'textarea', 'integer']); + }) + ->each(function (Field $field) use ($query, $search): void { + $query->orWhere($field->handle(), 'like', '%'.$search.'%'); + }); + }); } return $query; diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php index a54fe143efe..384b29f5ad7 100644 --- a/src/Http/Controllers/CP/Forms/FormsController.php +++ b/src/Http/Controllers/CP/Forms/FormsController.php @@ -14,6 +14,8 @@ use Statamic\Rules\Handle; use Statamic\Support\Str; +use function Statamic\trans as __; + class FormsController extends CpController { public function index(Request $request) @@ -359,7 +361,7 @@ protected function editFormBlueprint($form) foreach (Form::extraConfigFor($form->handle()) as $handle => $config) { $merged = false; foreach ($fields as $sectionHandle => $section) { - if ($section['display'] == $config['display']) { + if ($section['display'] == __($config['display'])) { $fields[$sectionHandle]['fields'] += $config['fields']; $merged = true; } diff --git a/src/Http/Controllers/CP/Navigation/NavigationController.php b/src/Http/Controllers/CP/Navigation/NavigationController.php index 03cb46bc4e2..f2ef5e4ca84 100644 --- a/src/Http/Controllers/CP/Navigation/NavigationController.php +++ b/src/Http/Controllers/CP/Navigation/NavigationController.php @@ -6,9 +6,11 @@ use Statamic\Contracts\Structures\Nav as NavContract; use Statamic\Facades\Blueprint; use Statamic\Facades\Nav; +use Statamic\Facades\Scope; use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; +use Statamic\Query\Scopes\Filter; use Statamic\Rules\Handle; use Statamic\Support\Arr; @@ -46,6 +48,7 @@ public function edit($nav) 'title' => $nav->title(), 'handle' => $nav->handle(), 'collections' => $nav->collections()->map->handle()->all(), + 'collections_query_scopes' => $nav->collectionsQueryScopes(), 'root' => $nav->expectsRoot(), 'sites' => $nav->trees()->keys()->all(), 'max_depth' => $nav->maxDepth(), @@ -86,6 +89,7 @@ public function show(Request $request, $nav) 'nav' => $nav, 'expectsRoot' => $nav->expectsRoot(), 'collections' => $nav->collections()->map->handle()->all(), + 'collections_query_scopes' => $nav->collectionsQueryScopes(), 'sites' => $this->getAuthorizedTreesForNav($nav)->map(function ($tree) { return [ 'handle' => $tree->locale(), @@ -120,6 +124,7 @@ public function update(Request $request, $nav) ->title($values['title']) ->expectsRoot($values['root']) ->collections($values['collections']) + ->collectionsQueryScopes(Arr::get($values, 'collections_query_scopes', [])) ->maxDepth($values['max_depth']); $existingSites = $nav->trees()->keys()->all(); @@ -212,6 +217,16 @@ public function editFormBlueprint($nav) 'type' => 'collections', 'mode' => 'select', ], + 'collections_query_scopes' => [ + 'display' => __('Query Scopes'), + 'instructions' => __('statamic::fieldtypes.entries.config.query_scopes'), + 'type' => 'taggable', + 'options' => Scope::all() + ->reject(fn ($scope) => $scope instanceof Filter) + ->map->handle() + ->values() + ->all(), + ], 'root' => [ 'display' => __('Expect a root page'), 'instructions' => __('statamic::messages.expect_root_instructions'), diff --git a/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php b/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php index 57806826a06..61c70fe1cd6 100644 --- a/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php +++ b/src/Http/Controllers/CP/Preferences/Nav/RoleNavController.php @@ -8,6 +8,8 @@ use Statamic\Facades\Role; use Statamic\Http\Controllers\Controller; +use function Statamic\trans as __; + class RoleNavController extends Controller { use Concerns\HasNavBuilder; diff --git a/src/Http/Controllers/CP/SearchController.php b/src/Http/Controllers/CP/SearchController.php index e38e97ac487..2b18c5f13c6 100644 --- a/src/Http/Controllers/CP/SearchController.php +++ b/src/Http/Controllers/CP/SearchController.php @@ -5,13 +5,14 @@ use Illuminate\Http\Request; use Statamic\Contracts\Search\Result; use Statamic\Facades\Search; +use Statamic\Facades\Site; use Statamic\Facades\User; class SearchController extends CpController { public function __invoke(Request $request) { - return Search::index() + return Search::index(locale: Site::selected()->handle()) ->ensureExists() ->search($request->query('q')) ->get() diff --git a/src/Http/Controllers/CP/SessionTimeoutController.php b/src/Http/Controllers/CP/SessionTimeoutController.php index d333de9526f..f4091039e63 100644 --- a/src/Http/Controllers/CP/SessionTimeoutController.php +++ b/src/Http/Controllers/CP/SessionTimeoutController.php @@ -13,8 +13,8 @@ public function __invoke() // remember me would have already been served a 403 error and wouldn't have got this far. $lastActivity = session('last_activity', now()->timestamp); - return Carbon::createFromTimestamp($lastActivity) - ->addMinutes(config('session.lifetime')) - ->diffInSeconds(); + return abs((int) Carbon::createFromTimestamp($lastActivity, config('app.timezone')) + ->addMinutes((int) config('session.lifetime')) + ->diffInSeconds()); } } diff --git a/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php b/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php index 7c6c2550b50..0901cb18847 100644 --- a/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php +++ b/src/Http/Controllers/CP/Taxonomies/TaxonomiesController.php @@ -20,6 +20,8 @@ use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class TaxonomiesController extends CpController { public function index() @@ -32,7 +34,7 @@ public function index() return [ 'id' => $taxonomy->handle(), 'title' => $taxonomy->title(), - 'terms' => $taxonomy->queryTerms()->count(), + 'terms' => $taxonomy->queryTerms()->pluck('slug')->unique()->count(), 'edit_url' => $taxonomy->editUrl(), 'delete_url' => $taxonomy->deleteUrl(), 'terms_url' => cp_route('taxonomies.show', $taxonomy->handle()), diff --git a/src/Http/Controllers/CP/Taxonomies/TermsController.php b/src/Http/Controllers/CP/Taxonomies/TermsController.php index 9482380aae3..5dc382080a3 100644 --- a/src/Http/Controllers/CP/Taxonomies/TermsController.php +++ b/src/Http/Controllers/CP/Taxonomies/TermsController.php @@ -168,6 +168,8 @@ public function update(Request $request, $taxonomy, $term, $site) $term = $term->fromWorkingCopy(); + $term->term()->syncOriginal(); + $fields = $term->blueprint()->fields()->addValues($request->except('id')); $fields->validate([ @@ -243,6 +245,7 @@ public function create(Request $request, $taxonomy, $site) 'title' => $taxonomy->createLabel(), 'actions' => [ 'save' => cp_route('taxonomies.terms.store', [$taxonomy->handle(), $site->handle()]), + 'editBlueprint' => cp_route('taxonomies.blueprints.edit', [$taxonomy, $blueprint]), ], 'values' => $values, 'meta' => $fields->meta(), @@ -295,7 +298,7 @@ public function store(Request $request, $taxonomy, $site) $slug = $request->slug; $published = $request->get('published'); // TODO - $defaultSite = Site::default()->handle(); + $defaultSite = $term->taxonomy()->sites()->first(); // If the term is *not* being created in the default site, we'll copy all the // appropriate values into the default localization since it needs to exist. @@ -354,7 +357,7 @@ protected function breadcrumbs($taxonomy) ], [ 'text' => $taxonomy->title(), - 'url' => $taxonomy->showUrl(), + 'url' => $taxonomy->breadcrumbUrl(), ], ]); } diff --git a/src/Http/Controllers/CP/Users/RolesController.php b/src/Http/Controllers/CP/Users/RolesController.php index 83e6c1c5be4..7b66514a408 100644 --- a/src/Http/Controllers/CP/Users/RolesController.php +++ b/src/Http/Controllers/CP/Users/RolesController.php @@ -12,6 +12,8 @@ use Statamic\Rules\Handle; use Statamic\Support\Str; +use function Statamic\trans as __; + class RolesController extends CpController { public function __construct() diff --git a/src/Http/Controllers/CP/Users/UsersController.php b/src/Http/Controllers/CP/Users/UsersController.php index 61e24244176..1d63059b5a8 100644 --- a/src/Http/Controllers/CP/Users/UsersController.php +++ b/src/Http/Controllers/CP/Users/UsersController.php @@ -140,6 +140,7 @@ public function create(Request $request) $additional = $fields->all() ->reject(fn ($field) => in_array($field->handle(), ['roles', 'groups', 'super'])) + ->reject(fn ($field) => in_array($field->visibility(), ['read_only', 'computed'])) ->keys(); $viewData = [ @@ -226,11 +227,11 @@ public function edit(Request $request, $user) $blueprint = $user->blueprint(); if (! User::current()->can('assign roles')) { - $blueprint->ensureField('roles', ['visibility' => 'read_only']); + $blueprint->ensureFieldHasConfig('roles', ['visibility' => 'hidden']); } if (! User::current()->can('assign user groups')) { - $blueprint->ensureField('groups', ['visibility' => 'read_only']); + $blueprint->ensureFieldHasConfig('groups', ['visibility' => 'hidden']); } if (User::current()->isSuper() && User::current()->id() !== $user->id()) { diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index 9bfb5c99f16..325cc795d5e 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Password; use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\SendsPasswordResetEmails; +use Statamic\Exceptions\ValidationException; use Statamic\Facades\URL; use Statamic\Http\Middleware\RedirectIfAuthenticated; @@ -30,6 +31,10 @@ public function showLinkRequestForm() public function sendResetLinkEmail(Request $request) { if ($url = $request->_reset_url) { + throw_if(URL::isExternalToApplication($url), ValidationException::withMessages([ + '_reset_url' => trans('validation.url', ['attribute' => '_reset_url']), + ])); + PasswordReset::resetFormUrl(URL::makeAbsolute($url)); } diff --git a/src/Http/Controllers/FrontendController.php b/src/Http/Controllers/FrontendController.php index e6c22aeba3e..1023ccbbd12 100644 --- a/src/Http/Controllers/FrontendController.php +++ b/src/Http/Controllers/FrontendController.php @@ -2,7 +2,10 @@ namespace Statamic\Http\Controllers; +use Closure; +use Illuminate\Contracts\View\View as IlluminateView; use Illuminate\Http\Request; +use ReflectionFunction; use Statamic\Auth\Protect\Protection; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Data; @@ -39,9 +42,26 @@ public function index(Request $request) public function route(Request $request, ...$args) { $params = $request->route()->parameters(); + $view = Arr::pull($params, 'view'); $data = Arr::pull($params, 'data'); - $data = array_merge($params, is_callable($data) ? $data(...$params) : $data); + + throw_if(($view instanceof Closure) && $data, new \Exception('Parameter [$data] not supported with [$view] closure!')); + + if ($view instanceof Closure) { + $resolvedView = static::resolveRouteClosure($view, $params); + } + + if (isset($resolvedView) && $resolvedView instanceof IlluminateView) { + $view = $resolvedView->name(); + $data = $resolvedView->getData(); + } elseif (isset($resolvedView)) { + return $resolvedView; + } + + $data = array_merge($params, is_callable($data) + ? static::resolveRouteClosure($data, $params) + : $data); $view = app(View::class) ->template($view) @@ -73,4 +93,18 @@ private function getLoadedRouteItem($data) return $data; } } + + private static function resolveRouteClosure(Closure $closure, array $params) + { + $reflect = new ReflectionFunction($closure); + + $params = collect($reflect->getParameters()) + ->map(fn ($param) => $param->hasType() && class_exists($class = $param->getType()->getName()) + ? app($class) + : $params[$param->getName()] + ) + ->all(); + + return $closure(...$params); + } } diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index 88ec39e24da..61489a5fe27 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -73,7 +73,7 @@ public function generateByUrl($url) { $this->validateSignature(); - $url = base64_decode($url); + $url = Str::fromBase64Url($url); return $this->createResponse($this->generateBy('url', $url)); } @@ -90,7 +90,7 @@ public function generateByAsset($encoded) { $this->validateSignature(); - $decoded = base64_decode($encoded); + $decoded = Str::fromBase64Url($encoded); // The string before the first slash is the container [$container, $path] = explode('/', $decoded, 2); diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index 880a6ea9227..e46d068832f 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -6,7 +6,9 @@ use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\InvalidStateException; +use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\OAuth; +use Statamic\Facades\URL; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -14,9 +16,13 @@ class OAuthController { public function redirectToProvider(Request $request, string $provider) { - $referer = $request->headers->get('referer'); + $referer = $request->headers->get('referer') ?? ''; $guard = config('statamic.users.guards.web', 'web'); + if (! OAuth::providers()->has($provider)) { + throw new NotFoundHttpException(); + } + if (Str::startsWith(parse_url($referer)['path'], Str::ensureLeft(config('statamic.cp.route'), '/'))) { $guard = config('statamic.users.guards.cp', 'web'); } @@ -30,20 +36,34 @@ public function handleProviderCallback(Request $request, string $provider) { $oauth = OAuth::provider($provider); + if (! $oauth) { + throw new NotFoundHttpException(); + } + try { $providerUser = $oauth->getSocialiteUser(); } catch (InvalidStateException $e) { return $this->redirectToProvider($request, $provider); } - $user = $oauth->findOrCreateUser($providerUser); + if ($user = $oauth->findUser($providerUser)) { + if (config('statamic.oauth.merge_user_data', true)) { + $user = $oauth->mergeUser($user, $providerUser); + } + } elseif (config('statamic.oauth.create_user', true)) { + $user = $oauth->createUser($providerUser); + } + + if ($user) { + session()->put('oauth-provider', $provider); - session()->put('oauth-provider', $provider); + Auth::guard($request->session()->get('statamic.oauth.guard')) + ->login($user, config('statamic.oauth.remember_me', true)); - Auth::guard($request->session()->get('statamic.oauth.guard')) - ->login($user, config('statamic.oauth.remember_me', true)); + return redirect()->to($this->successRedirectUrl()); + } - return redirect()->to($this->successRedirectUrl()); + return redirect()->to($this->unauthorizedRedirectUrl()); } protected function successRedirectUrl() @@ -58,6 +78,35 @@ protected function successRedirectUrl() parse_str($query, $query); - return Arr::get($query, 'redirect', $default); + $redirect = Arr::get($query, 'redirect', $default); + + return URL::isExternalToApplication($redirect) ? $default : $redirect; + } + + protected function unauthorizedRedirectUrl() + { + // If a URL has been explicitly defined, use that. + if ($url = config('statamic.oauth.unauthorized_redirect')) { + return $url; + } + + // We'll check the redirect to see if they were intending on + // accessing the CP. If they were, we'll redirect them to + // the unauthorized page in the CP. Otherwise, to home. + + $default = '/'; + $previous = session('_previous.url'); + + if (! $query = Arr::get(parse_url($previous), 'query')) { + return $default; + } + + parse_str($query, $query); + + if (! $redirect = Arr::get($query, 'redirect')) { + return $default; + } + + return $redirect === '/'.config('statamic.cp.route') ? cp_route('unauthorized') : $default; } } diff --git a/src/Http/Controllers/ResetPasswordController.php b/src/Http/Controllers/ResetPasswordController.php index e2e63ca7e10..bd1c1ff7585 100644 --- a/src/Http/Controllers/ResetPasswordController.php +++ b/src/Http/Controllers/ResetPasswordController.php @@ -8,6 +8,7 @@ use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\ResetsPasswords; use Statamic\Contracts\Auth\User; +use Statamic\Facades\URL; use Statamic\Http\Middleware\CP\RedirectIfAuthorized; class ResetPasswordController extends Controller @@ -41,7 +42,11 @@ protected function resetFormTitle() public function redirectPath() { - return request('redirect') ?? route('statamic.site'); + $redirect = request('redirect'); + + return $redirect && ! URL::isExternalToApplication($redirect) + ? $redirect + : route('statamic.site'); } protected function setUserPassword($user, $password) diff --git a/src/Http/Controllers/User/LoginController.php b/src/Http/Controllers/User/LoginController.php index 7911960b6fb..39d907cde2a 100644 --- a/src/Http/Controllers/User/LoginController.php +++ b/src/Http/Controllers/User/LoginController.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Auth; use Statamic\Auth\ThrottlesLogins; +use Statamic\Facades\URL; use Statamic\Http\Controllers\Controller; use Statamic\Http\Requests\UserLoginRequest; @@ -20,12 +21,18 @@ public function login(UserLoginRequest $request) } if (Auth::attempt($request->only('email', 'password'), $request->has('remember'))) { - return redirect($request->input('_redirect', '/'))->withSuccess(__('Login successful.')); + $redirect = $request->input('_redirect', '/'); + + return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect)->withSuccess(__('Login successful.')); } $this->incrementLoginAttempts($request); - $errorResponse = $request->has('_error_redirect') ? redirect($request->input('_error_redirect')) : back(); + $errorRedirect = $request->input('_error_redirect'); + + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) + : back(); return $errorResponse->withInput()->withErrors(__('Invalid credentials.')); } @@ -34,7 +41,9 @@ public function logout() { Auth::logout(); - return redirect(request()->get('redirect', '/')); + $redirect = request()->get('redirect', '/'); + + return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect); } protected function username() diff --git a/src/Http/Controllers/User/PasswordController.php b/src/Http/Controllers/User/PasswordController.php index fe6df8b21ce..56093999e45 100644 --- a/src/Http/Controllers/User/PasswordController.php +++ b/src/Http/Controllers/User/PasswordController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\User; use Illuminate\Support\Facades\Password; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Requests\UserPasswordRequest; @@ -21,7 +22,8 @@ public function __invoke(UserPasswordRequest $request) private function successfulResponse() { - $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + $redirect = request()->get('_redirect'); + $response = $redirect && ! URL::isExternalToApplication($redirect) ? redirect($redirect) : back(); if (request()->ajax() || request()->wantsJson()) { return response([ diff --git a/src/Http/Controllers/User/ProfileController.php b/src/Http/Controllers/User/ProfileController.php index 656d1226df9..f1043f62a4c 100644 --- a/src/Http/Controllers/User/ProfileController.php +++ b/src/Http/Controllers/User/ProfileController.php @@ -2,6 +2,7 @@ namespace Statamic\Http\Controllers\User; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Requests\UserProfileRequest; @@ -26,7 +27,8 @@ public function __invoke(UserProfileRequest $request) private function successfulResponse() { - $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + $redirect = request()->get('_redirect'); + $response = $redirect && ! URL::isExternalToApplication($redirect) ? redirect($redirect) : back(); if (request()->ajax() || request()->wantsJson()) { return response([ diff --git a/src/Http/Controllers/User/RegisterController.php b/src/Http/Controllers/User/RegisterController.php index 36114fd0443..651fc50c522 100644 --- a/src/Http/Controllers/User/RegisterController.php +++ b/src/Http/Controllers/User/RegisterController.php @@ -8,6 +8,7 @@ use Statamic\Events\UserRegistered; use Statamic\Events\UserRegistering; use Statamic\Exceptions\SilentFormFailureException; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Requests\UserRegisterRequest; use Statamic\Support\Arr; @@ -52,7 +53,8 @@ public function __invoke(UserRegisterRequest $request) private function successfulResponse(bool $silentFailure = false) { - $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + $redirect = request()->get('_redirect'); + $response = $redirect && ! URL::isExternalToApplication($redirect) ? redirect($redirect) : back(); if (request()->ajax() || request()->wantsJson()) { return response([ @@ -85,7 +87,8 @@ private function failureResponse($validator) return (new ValidationException($validator))->errorBag(new MessageBag($errors)); } - $errorResponse = request()->has('_error_redirect') ? redirect(request()->input('_error_redirect')) : back(); + $errorRedirect = request()->input('_error_redirect'); + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) ? redirect($errorRedirect) : back(); return $errorResponse->withInput()->withErrors($errors, 'user.register'); } diff --git a/src/Http/Middleware/CP/SelectedSite.php b/src/Http/Middleware/CP/SelectedSite.php index bbb9e833e9e..506b8ffef11 100644 --- a/src/Http/Middleware/CP/SelectedSite.php +++ b/src/Http/Middleware/CP/SelectedSite.php @@ -10,12 +10,14 @@ class SelectedSite { public function handle($request, Closure $next) { - $this->updateSelectedSite(); + $this->selectFromRequest($request); + + $this->selectFromAuth(); return $next($request); } - private function updateSelectedSite() + private function selectFromAuth() { if (User::current()->can('view', Site::selected())) { return; @@ -25,4 +27,16 @@ private function updateSelectedSite() Site::setSelected($first->handle()); } } + + private function selectFromRequest($request) + { + // If the session already has a selected site, don't override it. + if (session('statamic.cp.selected-site')) { + return; + } + + if ($siteByUrl = Site::findByUrl($request->getSchemeAndHttpHost())) { + Site::setSelected($siteByUrl->handle()); + } + } } diff --git a/src/Http/Middleware/Localize.php b/src/Http/Middleware/Localize.php index 17a3ecdca0f..745df44a615 100644 --- a/src/Http/Middleware/Localize.php +++ b/src/Http/Middleware/Localize.php @@ -5,8 +5,10 @@ use Carbon\Carbon; use Closure; use Illuminate\Support\Facades\Date; +use ReflectionClass; use Statamic\Facades\Site; use Statamic\Statamic; +use Statamic\Support\Arr; class Localize { @@ -29,10 +31,7 @@ public function handle($request, Closure $next) app()->setLocale($site->lang()); // Get original Carbon format so it can be restored later. - // There's no getter for it, so we'll use reflection. - $format = (new \ReflectionClass(Carbon::class))->getProperty('toStringFormat'); - $format->setAccessible(true); - $originalToStringFormat = $format->getValue(); + $originalToStringFormat = $this->getToStringFormat(); Date::setToStringFormat(Statamic::dateFormat()); $response = $next($request); @@ -45,4 +44,29 @@ public function handle($request, Closure $next) return $response; } + + /** + * This method is used to get the current toStringFormat for Carbon, in order for us + * to restore it later. There's no getter for it, so we need to use reflection. + * + * @throws \ReflectionException + */ + private function getToStringFormat(): ?string + { + $reflection = new ReflectionClass($date = Date::now()); + + // Carbon 2.x + if ($reflection->hasProperty('toStringFormat')) { + $format = $reflection->getProperty('toStringFormat'); + $format->setAccessible(true); + + return $format->getValue(); + } + + // Carbon 3.x + $factory = $reflection->getMethod('getFactory'); + $factory->setAccessible(true); + + return Arr::get($factory->invoke($date)->getSettings(), 'toStringFormat'); + } } diff --git a/src/Http/Middleware/RedirectAbsoluteDomains.php b/src/Http/Middleware/RedirectAbsoluteDomains.php new file mode 100644 index 00000000000..bd93b15eee0 --- /dev/null +++ b/src/Http/Middleware/RedirectAbsoluteDomains.php @@ -0,0 +1,27 @@ +getHost(); + + if (! Str::endsWith($host, '.')) { + return $next($request); + } + + return redirect()->to(Str::replaceFirst($host, rtrim($host, '.'), $request->fullUrl()), 308); + } +} diff --git a/src/Http/Requests/FrontendFormRequest.php b/src/Http/Requests/FrontendFormRequest.php index 46898fb198a..795e042044c 100644 --- a/src/Http/Requests/FrontendFormRequest.php +++ b/src/Http/Requests/FrontendFormRequest.php @@ -4,10 +4,10 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\URL; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\ValidationException; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Rules\AllowedFile; use Statamic\Support\Arr; @@ -42,7 +42,7 @@ protected function getRedirectUrl() $url = $this->redirector->getUrlGenerator(); if ($redirect = $this->input('_error_redirect')) { - return $url->to($redirect); + return URL::isExternalToApplication($redirect) ? $url->previous() : $url->to($redirect); } return $url->previous(); diff --git a/src/Http/Requests/UserLoginRequest.php b/src/Http/Requests/UserLoginRequest.php index 298a6dc0397..d132e0ffbd3 100644 --- a/src/Http/Requests/UserLoginRequest.php +++ b/src/Http/Requests/UserLoginRequest.php @@ -4,10 +4,11 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\URL as LaravelURL; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\ValidationException; use Statamic\Facades\Site; +use Statamic\Facades\URL; class UserLoginRequest extends FormRequest { @@ -45,14 +46,15 @@ protected function failedValidation(Validator $validator) throw (new ValidationException($validator, $response)); } - $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + $errorRedirect = $this->input('_error_redirect'); + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) ? redirect($errorRedirect) : back(); throw (new ValidationException($validator, $errorResponse->withInput()->withErrors(__('Invalid credentials.')))); } public function validateResolved() { - $site = Site::findByUrl(URL::previous()) ?? Site::default(); + $site = Site::findByUrl(LaravelURL::previous()) ?? Site::default(); return $this->withLocale($site->lang(), fn () => parent::validateResolved()); } diff --git a/src/Http/Requests/UserPasswordRequest.php b/src/Http/Requests/UserPasswordRequest.php index 4bb5b770aac..d7cb084a507 100644 --- a/src/Http/Requests/UserPasswordRequest.php +++ b/src/Http/Requests/UserPasswordRequest.php @@ -4,11 +4,12 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\URL as LaravelURL; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Facades\User; class UserPasswordRequest extends FormRequest @@ -47,14 +48,15 @@ protected function failedValidation(Validator $validator) throw (new ValidationException($validator, $response)); } - $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + $errorRedirect = $this->input('_error_redirect'); + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) ? redirect($errorRedirect) : back(); throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.password'))); } public function validateResolved() { - $site = Site::findByUrl(URL::previous()) ?? Site::default(); + $site = Site::findByUrl(LaravelURL::previous()) ?? Site::default(); return $this->withLocale($site->lang(), fn () => parent::validateResolved()); } diff --git a/src/Http/Requests/UserProfileRequest.php b/src/Http/Requests/UserProfileRequest.php index 555cf1235f1..e025e29f3b1 100644 --- a/src/Http/Requests/UserProfileRequest.php +++ b/src/Http/Requests/UserProfileRequest.php @@ -4,10 +4,11 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\URL as LaravelURL; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\ValidationException; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Rules\UniqueUserValue; @@ -42,7 +43,8 @@ protected function failedValidation(Validator $validator) throw (new ValidationException($validator, $response)); } - $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + $errorRedirect = $this->input('_error_redirect'); + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) ? redirect($errorRedirect) : back(); throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.profile'))); } @@ -73,7 +75,7 @@ public function validator() public function validateResolved() { - $site = Site::findByUrl(URL::previous()) ?? Site::default(); + $site = Site::findByUrl(LaravelURL::previous()) ?? Site::default(); return $this->withLocale($site->lang(), fn () => parent::validateResolved()); } diff --git a/src/Http/Requests/UserRegisterRequest.php b/src/Http/Requests/UserRegisterRequest.php index bbf9ff3572a..44395413057 100644 --- a/src/Http/Requests/UserRegisterRequest.php +++ b/src/Http/Requests/UserRegisterRequest.php @@ -4,11 +4,12 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\URL as LaravelURL; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Rules\UniqueUserValue; @@ -43,7 +44,8 @@ protected function failedValidation(Validator $validator) throw (new ValidationException($validator, $response)); } - $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + $errorRedirect = $this->input('_error_redirect'); + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) ? redirect($errorRedirect) : back(); throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.register'))); } @@ -76,7 +78,7 @@ public function validator() public function validateResolved() { - $site = Site::findByUrl(URL::previous()) ?? Site::default(); + $site = Site::findByUrl(LaravelURL::previous()) ?? Site::default(); return $this->withLocale($site->lang(), fn () => parent::validateResolved()); } diff --git a/src/Http/Resources/CP/Assets/Asset.php b/src/Http/Resources/CP/Assets/Asset.php index 7b7e0045288..63be132bc9c 100644 --- a/src/Http/Resources/CP/Assets/Asset.php +++ b/src/Http/Resources/CP/Assets/Asset.php @@ -60,7 +60,7 @@ public function toArray($request) $this->merge($this->publishFormData()), - 'allowDownloading' => $this->container()->allowDownloading(), + 'allowDownloading' => config('statamic.assets.v6_permissions') ? true : $this->container()->allowDownloading(), 'actionUrl' => cp_route('assets.actions.run'), 'actions' => Action::for($this->resource, [ 'container' => $this->container()->handle(), diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 36453122e15..efaacf91847 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -13,6 +13,7 @@ public function toArray($request) return [ 'id' => $this->id(), 'basename' => $this->basename(), + 'path' => $this->path(), 'extension' => $this->extension(), 'url' => $this->absoluteUrl(), 'size_formatted' => Str::fileSizeForHumans($this->size(), 0), diff --git a/src/Http/Resources/CP/Entries/EntriesFieldtypeEntry.php b/src/Http/Resources/CP/Entries/EntriesFieldtypeEntry.php index dad09d47d30..2e0fc034205 100644 --- a/src/Http/Resources/CP/Entries/EntriesFieldtypeEntry.php +++ b/src/Http/Resources/CP/Entries/EntriesFieldtypeEntry.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Resources\CP\Entries; use Illuminate\Http\Resources\Json\JsonResource; +use Statamic\Facades\User; use Statamic\Fieldtypes\Entries as EntriesFieldtype; class EntriesFieldtypeEntry extends JsonResource @@ -23,6 +24,7 @@ public function toArray($request) 'title' => $this->resource->value('title'), 'status' => $this->resource->status(), 'edit_url' => $this->resource->editUrl(), + 'editable' => User::current()->can('edit', $this->resource), 'hint' => $this->fieldtype->getItemHint($this->resource), ]; diff --git a/src/Http/Responses/DataResponse.php b/src/Http/Responses/DataResponse.php index baca6e78965..83e1e8bc39d 100644 --- a/src/Http/Responses/DataResponse.php +++ b/src/Http/Responses/DataResponse.php @@ -95,7 +95,7 @@ protected function protect() $protection->protect(); - if ($protection->scheme()) { + if ($protection->scheme() && ! $protection->cacheable()) { $this->headers['X-Statamic-Protected'] = true; } @@ -149,7 +149,7 @@ protected function contents() { $contents = $this->view()->render(); - if ($this->request->isLivePreview()) { + if ($this->request->isLivePreview() && config('statamic.live_preview.force_reload_js_modules', true)) { $contents = $this->versionJavascriptModules($contents); } @@ -210,13 +210,13 @@ public static function contentType($type) { switch ($type) { case 'html': - return 'text/html; charset=UTF-8'; + return 'text/html; charset=utf-8'; case 'xml': return 'text/xml'; case 'rss': return 'application/rss+xml'; case 'atom': - return 'application/atom+xml; charset=UTF-8'; + return 'application/atom+xml; charset=utf-8'; case 'json': return 'application/json'; case 'text': diff --git a/src/Http/View/Composers/JavascriptComposer.php b/src/Http/View/Composers/JavascriptComposer.php index d9e0f253438..d1b4a0da29d 100644 --- a/src/Http/View/Composers/JavascriptComposer.php +++ b/src/Http/View/Composers/JavascriptComposer.php @@ -70,7 +70,7 @@ private function protectedVariables() 'preloadableFieldtypes' => FieldtypeRepository::preloadable()->keys(), 'livePreview' => config('statamic.live_preview'), 'permissions' => $this->permissions($user), - 'hasLicenseBanner' => $licenses->invalid() || $licenses->requestFailed(), + 'hasLicenseBanner' => ! $licenses->outpostIsOffline() && ($licenses->invalid() || $licenses->requestFailed()), 'customSvgIcons' => Icon::getCustomSvgIcons(), ]; } diff --git a/src/Imaging/Attributes.php b/src/Imaging/Attributes.php index 5c19c5a7f8c..f0b1f7f46d7 100644 --- a/src/Imaging/Attributes.php +++ b/src/Imaging/Attributes.php @@ -41,7 +41,19 @@ public function from(FilesystemAdapter $source, string $path) private function imageAttributes(string $path) { - [$width, $height] = getimagesize($this->prefixPath($path)); + $fullPath = $this->prefixPath($path); + + if (! file_exists($fullPath)) { + return ['width' => 0, 'height' => 0]; + } + + $size = @getimagesize($fullPath); + + if ($size === false) { + return ['width' => 0, 'height' => 0]; + } + + [$width, $height] = $size; return compact('width', 'height'); } diff --git a/src/Imaging/GlideManager.php b/src/Imaging/GlideManager.php index ef36459de73..0dda900bcb4 100644 --- a/src/Imaging/GlideManager.php +++ b/src/Imaging/GlideManager.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use League\Glide\ServerFactory; +use Statamic\Events\GlideAssetCacheCleared; use Statamic\Facades\Config; use Statamic\Facades\Image; use Statamic\Imaging\ResponseFactory as LaravelResponseFactory; @@ -137,6 +138,8 @@ public function clearAsset($asset) // Clear manifest itself from cache store. $this->cacheStore()->forget($manifestKey); + + GlideAssetCacheCleared::dispatch($asset); } public function normalizeParameters($params) @@ -167,6 +170,13 @@ private function getCachePathCallable() $hashCallable = $this->getHashCallable(); return function ($path, $params) use ($hashCallable) { + $qs = Str::contains($path, '?') ? Str::after($path, '?') : null; + $path = Str::before($path, '?'); + + if ($qs) { + $path = Str::replaceLast('.', '-'.md5($qs).'.', $path); + } + $sourcePath = $this->getSourcePath($path); if ($this->sourcePathPrefix) { diff --git a/src/Imaging/GlideUrlBuilder.php b/src/Imaging/GlideUrlBuilder.php index 8bfdf2c3253..5b34bf9e1dc 100644 --- a/src/Imaging/GlideUrlBuilder.php +++ b/src/Imaging/GlideUrlBuilder.php @@ -36,15 +36,15 @@ public function build($item, $params) switch ($this->itemType()) { case 'url': - $path = 'http/'.base64_encode($item); + $path = 'http/'.Str::toBase64Url($item); $filename = Str::afterLast($item, '/'); break; case 'asset': - $path = 'asset/'.base64_encode($this->item->containerId().'/'.$this->item->path()); + $path = 'asset/'.Str::toBase64Url($this->item->containerId().'/'.$this->item->path()); $filename = Str::afterLast($this->item->path(), '/'); break; case 'id': - $path = 'asset/'.base64_encode(str_replace('::', '/', $this->item)); + $path = 'asset/'.Str::toBase64Url(str_replace('::', '/', $this->item)); break; case 'path': $path = URL::encode($this->item); @@ -61,7 +61,7 @@ public function build($item, $params) if (isset($params['mark']) && $params['mark'] instanceof Asset) { $asset = $params['mark']; - $params['mark'] = 'asset::'.base64_encode($asset->containerId().'/'.$asset->path()); + $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); } return URL::prependSiteRoot($builder->getUrl($path, $params)); diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index bd38934577e..b335159e29b 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -119,12 +119,13 @@ private function doGenerateByUrl($url, array $params) $this->setParams($params); $parsed = $this->parseUrl($url); + $qs = $parsed['query']; $this->server->setSource($this->guzzleSourceFilesystem($parsed['base'])); $this->server->setSourcePathPrefix('/'); $this->server->setCachePathPrefix('http'); - return $this->generate($parsed['path']); + return $this->generate($parsed['path'].($qs ? '?'.$qs : '')); } /** @@ -198,7 +199,7 @@ private function setUpWatermark($watermark): string private function getWatermarkFilesystemAndParam($item) { if (is_string($item) && Str::startsWith($item, 'asset::')) { - $decoded = base64_decode(Str::after($item, 'asset::')); + $decoded = Str::fromBase64Url(Str::after($item, 'asset::')); [$container, $path] = explode('/', $decoded, 2); $item = Assets::find($container.'::'.$path); } @@ -305,7 +306,7 @@ private function validateImage() } if (! ImageValidator::isValidImage($extension, $mime)) { - throw new \Exception("Image [{$path}] does not actually appear to be a valid image."); + throw UnableToReadFile::fromLocation($path, "Image [{$path}] does not actually appear to be a valid image."); } } @@ -330,6 +331,7 @@ private function parseUrl($url) return [ 'path' => Str::after($parsed['path'], '/'), 'base' => $parsed['scheme'].'://'.$parsed['host'], + 'query' => $parsed['query'] ?? null, ]; } } diff --git a/src/Imaging/Manager.php b/src/Imaging/Manager.php index 4433ff4165c..0339633b53a 100644 --- a/src/Imaging/Manager.php +++ b/src/Imaging/Manager.php @@ -2,12 +2,15 @@ namespace Statamic\Imaging; +use League\Glide\Server; use Statamic\Contracts\Imaging\ImageManipulator; use Statamic\Facades\Glide; use Statamic\Support\Arr; class Manager { + private array $customManipulationPresets = []; + /** * Get a URL manipulator instance to continue chaining, or a URL right away if provided with params. * @@ -49,7 +52,10 @@ public function manipulator() */ public function manipulationPresets() { - $presets = $this->userManipulationPresets(); + $presets = [ + ...$this->userManipulationPresets(), + ...$this->customManipulationPresets(), + ]; if (config('statamic.cp.enabled')) { $presets = array_merge($presets, $this->cpManipulationPresets()); @@ -93,6 +99,27 @@ public function cpManipulationPresets() ->all(); } + /** + * Register custom image manipulation presets. + */ + public function registerCustomManipulationPresets(array $presets): void + { + foreach ($presets as $name => $preset) { + $this->customManipulationPresets[$name] = $this->normalizePreset($preset); + } + + $server = app(Server::class); + $server->setPresets([...$server->getPresets(), ...$this->customManipulationPresets()]); + } + + /** + * Get custom image manipulation presets. + */ + public function customManipulationPresets(): array + { + return $this->customManipulationPresets; + } + /** * Normalize preset. * diff --git a/src/Jobs/HandleEntrySchedule.php b/src/Jobs/HandleEntrySchedule.php index 3317bd53d89..c59e68f16bb 100644 --- a/src/Jobs/HandleEntrySchedule.php +++ b/src/Jobs/HandleEntrySchedule.php @@ -2,6 +2,7 @@ namespace Statamic\Jobs; +use Carbon\CarbonInterface; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -14,19 +15,24 @@ class HandleEntrySchedule implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; - public function handle() - { - $this->entries()->each(fn ($entry) => EntryScheduleReached::dispatch($entry)); - } + protected CarbonInterface $minute; - private function entries(): Collection + public function __construct() { // We want to target the PREVIOUS minute because we can be sure that any entries that // were scheduled for then would now be considered published. If we were targeting // the current minute and the entry has defined a time with seconds later in the // same minute, it may still be considered scheduled when it gets dispatched. - $minute = now()->subMinute(); + $this->minute = now()->subMinute(); + } - return (new MinuteEntries($minute))(); + public function handle() + { + $this->entries()->each(fn ($entry) => EntryScheduleReached::dispatch($entry)); + } + + private function entries(): Collection + { + return (new MinuteEntries($this->minute))(); } } diff --git a/src/Licensing/LicenseManager.php b/src/Licensing/LicenseManager.php index 94ca85e7c05..91c3d0ed2ed 100644 --- a/src/Licensing/LicenseManager.php +++ b/src/Licensing/LicenseManager.php @@ -35,7 +35,7 @@ public function requestRateLimited() public function failedRequestRetrySeconds() { return $this->requestRateLimited() - ? Carbon::createFromTimestamp($this->response('expiry'))->diffInSeconds() + ? (int) Carbon::createFromTimestamp($this->response('expiry'), config('app.timezone'))->diffInSeconds(absolute: true) : null; } @@ -44,6 +44,11 @@ public function requestValidationErrors() return new MessageBag($this->response('error') === 422 ? $this->response('errors') : []); } + public function outpostIsOffline() + { + return $this->requestErrorCode() >= 500 && $this->requestErrorCode() < 600; + } + public function isOnPublicDomain() { return $this->response('public'); diff --git a/src/Licensing/Outpost.php b/src/Licensing/Outpost.php index 32ffba1daa3..e3e3fa07848 100644 --- a/src/Licensing/Outpost.php +++ b/src/Licensing/Outpost.php @@ -104,7 +104,7 @@ private function licenseKeyFileResponse() Addon::all() ->reject(fn ($addon) => array_key_exists($addon->package(), $response['packages'])) ->mapWithKeys(fn ($addon) => [$addon->package() => [ - 'valid' => ! $addon->isCommercial(), + 'valid' => ! $addon->isCommercial() || $addon->edition() === 'free', 'exists' => $addon->existsOnMarketplace(), 'version_limit' => null, ]]) @@ -199,7 +199,7 @@ private function cacheAndReturnValidationResponse($e) private function cacheAndReturnRateLimitResponse($e) { - $seconds = $e->getResponse()->getHeader('Retry-After')[0]; + $seconds = (int) $e->getResponse()->getHeader('Retry-After')[0]; return $this->cacheResponse(now()->addSeconds($seconds), ['error' => 429]); } diff --git a/src/Listeners/Concerns/GetsItemsContainingData.php b/src/Listeners/Concerns/GetsItemsContainingData.php index 2a9c8a68a70..31ee343839c 100644 --- a/src/Listeners/Concerns/GetsItemsContainingData.php +++ b/src/Listeners/Concerns/GetsItemsContainingData.php @@ -2,24 +2,46 @@ namespace Statamic\Listeners\Concerns; +use Illuminate\Support\LazyCollection; use Statamic\Facades\Entry; use Statamic\Facades\GlobalSet; use Statamic\Facades\Term; use Statamic\Facades\User; +use Statamic\Support\Traits\Hookable; trait GetsItemsContainingData { + use Hookable; + /** * Get items containing data. * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\LazyCollection */ public function getItemsContainingData() { - return collect() - ->merge(Entry::all()) - ->merge(Term::all()) - ->merge(GlobalSet::all()->flatMap(fn ($set) => $set->localizations()->values())) - ->merge(User::all()); + $collections = [ + LazyCollection::make(function () { + yield from Entry::query()->lazy(); + }), + LazyCollection::make(function () { + yield from Term::query()->lazy(); + }), + LazyCollection::make(function () { + yield from GlobalSet::all()->flatMap(fn ($set) => $set->localizations()->values()); + }), + LazyCollection::make(function () { + yield from User::query()->lazy(); + }), + LazyCollection::make(function () { + yield from ($this->runHooks('additional') ?? LazyCollection::make()); + }), + ]; + + return LazyCollection::make(function () use ($collections) { + foreach ($collections as $collection) { + yield from $collection; + } + }); } } diff --git a/src/Listeners/InvalidateNavCache.php b/src/Listeners/InvalidateNavCache.php new file mode 100644 index 00000000000..b6d6243dae8 --- /dev/null +++ b/src/Listeners/InvalidateNavCache.php @@ -0,0 +1,27 @@ + 'invalidate', + NavCreated::class => 'invalidate', + TaxonomyCreated::class => 'invalidate', + AssetContainerCreated::class => 'invalidate', + GlobalSetCreated::class => 'invalidate', + ]; + + public function invalidate($event): void + { + Nav::clearCachedUrls(); + } +} diff --git a/src/Listeners/UpdateAssetReferences.php b/src/Listeners/UpdateAssetReferences.php index a82bf44d562..a7deae9e6fb 100644 --- a/src/Listeners/UpdateAssetReferences.php +++ b/src/Listeners/UpdateAssetReferences.php @@ -85,16 +85,21 @@ protected function replaceReferences($asset, $originalPath, $newPath) $container = $asset->container()->handle(); - $updatedItems = $this + $hasUpdatedItems = false; + + $this ->getItemsContainingData() - ->map(function ($item) use ($container, $originalPath, $newPath) { - return AssetReferenceUpdater::item($item) + ->each(function ($item) use ($container, $originalPath, $newPath, &$hasUpdatedItems) { + $updated = AssetReferenceUpdater::item($item) ->filterByContainer($container) ->updateReferences($originalPath, $newPath); - }) - ->filter(); - if ($updatedItems->isNotEmpty()) { + if ($updated) { + $hasUpdatedItems = true; + } + }); + + if ($hasUpdatedItems) { AssetReferencesUpdated::dispatch($asset); } } diff --git a/src/Markdown/Parser.php b/src/Markdown/Parser.php index c2aa8d8cc05..9c4a9aa7605 100644 --- a/src/Markdown/Parser.php +++ b/src/Markdown/Parser.php @@ -15,6 +15,7 @@ class Parser protected $converter; protected $extensions = []; + protected $renderers = []; protected $config = []; public function __construct(array $config = []) @@ -37,8 +38,12 @@ public function converter(): CommonMarkConverter $env = $converter->getEnvironment(); - foreach ($this->extensions() as $ext) { - $env->addExtension($ext); + foreach ($this->extensions() as $extension) { + $env->addExtension($extension); + } + + foreach ($this->renderers() as $renderer) { + $env->addRenderer(...$renderer); } return $this->converter = $converter; @@ -65,15 +70,50 @@ public function addExtensions(Closure $closure): self public function extensions(): array { - $exts = []; + $extensions = []; foreach ($this->extensions as $closure) { - foreach (Arr::wrap($closure()) as $ext) { - $exts[] = $ext; + foreach (Arr::wrap($closure()) as $extension) { + $extensions[] = $extension; + } + } + + return $extensions; + } + + public function addRenderer(Closure $closure): self + { + $this->converter = null; + + $this->renderers[] = $closure; + + return $this; + } + + public function addRenderers(Closure $closure): self + { + return $this->addRenderer($closure); + } + + public function renderers(): array + { + $renderers = []; + + foreach ($this->renderers as $closure) { + $closureRenderers = $closure(); + + // When the first item isn't an array, assume it's a single + // renderer and wrap it in an array. + if (! is_array($closureRenderers[0])) { + $closureRenderers = [$closureRenderers]; + } + + foreach ($closureRenderers as $renderer) { + $renderers[] = $renderer; } } - return $exts; + return $renderers; } public function withStatamicDefaults() @@ -145,12 +185,16 @@ public function config($key = null) public function newInstance(array $config = []) { - $parser = new self(array_replace_recursive($this->config, $config)); + $parser = new static(array_replace_recursive($this->config, $config)); foreach ($this->extensions as $ext) { $parser->addExtensions($ext); } + foreach ($this->renderers as $renderer) { + $parser->addRenderers($renderer); + } + return $parser; } } diff --git a/src/Marketplace/Client.php b/src/Marketplace/Client.php index 23227172488..c58c8ba6c8c 100644 --- a/src/Marketplace/Client.php +++ b/src/Marketplace/Client.php @@ -44,14 +44,22 @@ public function __construct() } } + public function get(string $endpoint, array $params = []) + { + return $this->request('GET', $endpoint, $params); + } + + public function post(string $endpoint, array $params = []) + { + return $this->request('POST', $endpoint, $params); + } + /** * Send API request. * - * @param string $endpoint - * @param arra $params * @return mixed */ - public function get($endpoint, $params = []) + private function request(string $method, string $endpoint, array $params = []) { $lock = $this->lock(static::LOCK_KEY, 10); @@ -61,10 +69,10 @@ public function get($endpoint, $params = []) try { $lock->block(5); - return $this->cache()->rememberWithExpiration($key, function () use ($endpoint, $params) { - $response = Guzzle::request('GET', $endpoint, [ + return $this->cache()->rememberWithExpiration($key, function () use ($method, $endpoint, $params) { + $response = Guzzle::request($method, $endpoint, [ 'verify' => $this->verifySsl, - 'query' => $params, + ($method === 'GET' ? 'query' : 'json') => $params, ]); $json = json_decode($response->getBody(), true); diff --git a/src/Marketplace/Marketplace.php b/src/Marketplace/Marketplace.php index 98e586cd9b4..08f63425d94 100644 --- a/src/Marketplace/Marketplace.php +++ b/src/Marketplace/Marketplace.php @@ -11,25 +11,18 @@ class Marketplace { - public function package($package, $version = null, $edition = null) + public function packages(array $packages) { - $uri = "packages/$package/$version"; - - if ($edition) { - $uri .= "?edition=$edition"; - } - - return Cache::rememberWithExpiration("marketplace-$uri", function () use ($uri) { - $fallback = [5 => null]; + $uri = 'packages'; + $hash = md5(json_encode($packages)); + return Cache::rememberWithExpiration("marketplace-$uri-$hash", function () use ($uri, $packages) { try { - if (! $response = Client::get($uri)) { - return $fallback; - } + $response = Client::post($uri, ['packages' => $packages]); - return [60 => $response['data']]; + return [60 => collect($response['data'])]; } catch (RequestException $e) { - return $fallback; + return [5 => collect()]; } }); } diff --git a/src/Mixins/Router.php b/src/Mixins/Router.php index aae71aba422..593af05d1f6 100644 --- a/src/Mixins/Router.php +++ b/src/Mixins/Router.php @@ -3,7 +3,6 @@ namespace Statamic\Mixins; use Statamic\Http\Controllers\FrontendController; -use Statamic\Support\Str; class Router { @@ -11,7 +10,7 @@ public function statamic() { return function ($uri, $view = null, $data = []) { if (! $view) { - $view = Str::of($uri)->ltrim('/'); + $view = ltrim($uri, '/'); } return $this->get($uri, [FrontendController::class, 'route']) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 0d3b1a8d0ac..b6f8e5449b9 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -283,6 +283,7 @@ public function bardText($value) } $text = ''; + while (count($value)) { $item = array_shift($value); @@ -291,8 +292,13 @@ public function bardText($value) } if ($item['type'] === 'text') { - $text .= ' '.($item['text'] ?? ''); + $text .= ($item['text'] ?? ''); + } + + if ($item['type'] === 'paragraph' && $text !== '') { + $text .= ' '; } + array_unshift($value, ...($item['content'] ?? [])); } @@ -546,7 +552,7 @@ public function dashify($value) */ public function daysAgo($value, $params) { - return $this->carbon($value)->diffInDays(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInDays(Arr::get($params, 0))); } /** @@ -738,6 +744,10 @@ public function first($value, $params) return Arr::first($value); } + if ($value instanceof Collection) { + return $value->first(); + } + return Stringy::first($value, Arr::get($params, 0)); } @@ -983,6 +993,50 @@ public function headline($value, $params) } } + /** + * Returns true if the array contains $needle, false otherwise + * + * @param string|array $haystack + * @param array $params + * @param array $context + * @return bool + */ + public function overlaps($haystack, $params, $context) + { + $needle = $this->getFromContext($context, $params); + + if ($needle instanceof Collection) { + $needle = $needle->values()->all(); + } + + if (! is_array($needle)) { + $needle = [$needle]; + } + + if ($haystack instanceof Collection) { + $haystack = $haystack->values()->all(); + } + + if (! is_array($haystack)) { + return false; + } + + return count(array_intersect($haystack, $needle)) > 0; + } + + /** + * Returns false if the array contains $needle, true otherwise + * + * @param string|array $haystack + * @param array $params + * @param array $context + * @return bool + */ + public function doesnt_overlap($haystack, $params, $context) + { + return ! $this->overlaps($haystack, $params, $context); + } + private function renderAPStyleHeadline($value) { $exceptions = [ @@ -1055,7 +1109,7 @@ public function hexToRgb($value) */ public function hoursAgo($value, $params) { - return $this->carbon($value)->diffInHours(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInHours(Arr::get($params, 0))); } /** @@ -1617,7 +1671,7 @@ public function md5($value) */ public function minutesAgo($value, $params) { - return $this->carbon($value)->diffInMinutes(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInMinutes(Arr::get($params, 0))); } /** @@ -1652,7 +1706,7 @@ public function modifyDate($value, $params) */ public function monthsAgo($value, $params) { - return $this->carbon($value)->diffInMonths(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInMonths(Arr::get($params, 0))); } /** @@ -2118,6 +2172,24 @@ public function replace($value, $params) return Stringy::replace($value, Arr::get($params, 0), Arr::get($params, 1)); } + /** + * Resolves a specific index or all items from an array, a Collection, or a Query Builder. + */ + public function resolve($value, $params) + { + $key = Arr::get($params, 0); + + if (Compare::isQueryBuilder($value)) { + $value = $value->get(); + } + + if ($value instanceof Collection) { + $value = $value->all(); + } + + return Arr::get($value, $key); + } + /** * Reverses the order of a string or list. * @@ -2234,7 +2306,7 @@ public function segment($value, $params, $context) */ public function secondsAgo($value, $params) { - return $this->carbon($value)->diffInSeconds(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInSeconds(Arr::get($params, 0))); } /** @@ -2933,7 +3005,7 @@ public function values($value) */ public function weeksAgo($value, $params) { - return $this->carbon($value)->diffInWeeks(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInWeeks(Arr::get($params, 0))); } /** @@ -3045,7 +3117,7 @@ public function wordCount($value) */ public function yearsAgo($value, $params) { - return $this->carbon($value)->diffInYears(Arr::get($params, 0)); + return (int) abs($this->carbon($value)->diffInYears(Arr::get($params, 0))); } /** @@ -3106,6 +3178,10 @@ public function embedUrl($url) $url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url); } + if (Str::contains($url, '&') && ! Str::contains($url, '?')) { + $url = Str::replaceFirst('&', '?', $url); + } + return $url; } @@ -3135,6 +3211,10 @@ public function trackableEmbedUrl($url) $url = str_replace('watch?v=', 'embed/', $url); } + if (Str::contains($url, '&') && ! Str::contains($url, '?')) { + $url = Str::replaceFirst('&', '?', $url); + } + return $url; } @@ -3201,8 +3281,12 @@ private function getMathModifierNumber($params, $context) private function carbon($value) { + if (! $value) { + return optional(); + } + if (! $value instanceof Carbon) { - $value = (is_numeric($value)) ? Date::createFromTimestamp($value) : Date::parse($value); + $value = (is_numeric($value)) ? Date::createFromTimestamp($value, config('app.timezone')) : Date::parse($value); } return $value; diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index b2000522286..0aeafdf8fe9 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -45,15 +45,31 @@ public function getUserId(string $id): ?string } public function findOrCreateUser($socialite): StatamicUser + { + if ($user = $this->findUser($socialite)) { + return config('statamic.oauth.merge_user_data', true) + ? $this->mergeUser($user, $socialite) + : $user; + } + + return $this->createUser($socialite); + } + + /** + * Find a Statamic user by a Socialite user. + * + * @param SocialiteUser $socialite + */ + public function findUser($socialite): ?StatamicUser { if ( ($user = User::findByOAuthId($this, $socialite->getId())) || ($user = User::findByEmail($socialite->getEmail())) ) { - return $this->mergeUser($user, $socialite); + return $user; } - return $this->createUser($socialite); + return null; } /** diff --git a/src/Policies/AssetFolderPolicy.php b/src/Policies/AssetFolderPolicy.php index f977508f84d..b258e1506bf 100644 --- a/src/Policies/AssetFolderPolicy.php +++ b/src/Policies/AssetFolderPolicy.php @@ -8,11 +8,24 @@ class AssetFolderPolicy { + public function before($user) + { + $user = User::fromUser($user); + + if ($user->hasPermission('configure asset containers')) { + return true; + } + } + public function create($user, $assetContainer) { $user = User::fromUser($user); - if (! $user->hasPermission("upload {$assetContainer->handle()} assets")) { + $permission = config('statamic.assets.v6_permissions') + ? "edit {$assetContainer->handle()} folders" + : "upload {$assetContainer->handle()} assets"; + + if (! $user->hasPermission($permission)) { return false; } @@ -23,7 +36,12 @@ public function move($user, $assetFolder) { $user = User::fromUser($user); - if (! $user->hasPermission("move {$assetFolder->container()->handle()} assets")) { + $hasPermission = config('statamic.assets.v6_permissions') + ? ($user->hasPermission("edit {$assetFolder->container()->handle()} folders") + && $user->hasPermission("move {$assetFolder->container()->handle()} assets")) + : $user->hasPermission("move {$assetFolder->container()->handle()} assets"); + + if (! $hasPermission) { return false; } @@ -41,7 +59,12 @@ public function rename($user, $assetFolder) { $user = User::fromUser($user); - if (! $user->hasPermission("rename {$assetFolder->container()->handle()} assets")) { + $hasPermission = config('statamic.assets.v6_permissions') + ? ($user->hasPermission("edit {$assetFolder->container()->handle()} folders") + && $user->hasPermission("rename {$assetFolder->container()->handle()} assets")) + : $user->hasPermission("rename {$assetFolder->container()->handle()} assets"); + + if (! $hasPermission) { return false; } @@ -59,7 +82,12 @@ public function delete($user, $assetFolder) { $user = User::fromUser($user); - if (! $user->hasPermission("delete {$assetFolder->container()->handle()} assets")) { + $hasPermission = config('statamic.assets.v6_permissions') + ? ($user->hasPermission("edit {$assetFolder->container()->handle()} folders") + && $user->hasPermission("delete {$assetFolder->container()->handle()} assets")) + : $user->hasPermission("delete {$assetFolder->container()->handle()} assets"); + + if (! $hasPermission) { return false; } diff --git a/src/Policies/GlobalSetPolicy.php b/src/Policies/GlobalSetPolicy.php index 6f48eb36b49..cb0d1bb0630 100644 --- a/src/Policies/GlobalSetPolicy.php +++ b/src/Policies/GlobalSetPolicy.php @@ -36,6 +36,11 @@ public function create($user) // handled by before() } + public function store($user) + { + // handled by before() + } + public function view($user, $set) { $user = User::fromUser($user); diff --git a/src/Preferences/CorePreferences.php b/src/Preferences/CorePreferences.php index 81f2b265fb2..bb42c71294f 100644 --- a/src/Preferences/CorePreferences.php +++ b/src/Preferences/CorePreferences.php @@ -61,6 +61,7 @@ private function localeOptions(): array 'de_CH' => 'German (Switzerland)', 'en' => 'English', 'es' => 'Spanish', + 'et' => 'Estonian', 'fa' => 'Persian', 'fr' => 'French', 'hu' => 'Hungarian', diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php index c637764fd13..bd2e65efb87 100644 --- a/src/Providers/AddonServiceProvider.php +++ b/src/Providers/AddonServiceProvider.php @@ -17,6 +17,7 @@ use Statamic\Facades\Addon; use Statamic\Facades\Blueprint; use Statamic\Facades\Fieldset; +use Statamic\Facades\Path; use Statamic\Fields\Fieldtype; use Statamic\Forms\JsDrivers\JsDriver; use Statamic\Modifiers\Modifier; @@ -182,6 +183,10 @@ abstract class AddonServiceProvider extends ServiceProvider */ protected $translations = true; + private $autoloadedClasses; + + private $bootedAddons; + public function boot() { Statamic::booted(function () { @@ -216,6 +221,8 @@ public function boot() ->bootFieldsets() ->bootPublishAfterInstall() ->bootAddon(); + + $this->bootedAddons()->push($this->getAddon()->id()); }); } @@ -301,6 +308,8 @@ protected function bootScopes() { $scopes = collect($this->scopes) ->merge($this->autoloadFilesFromFolder('Scopes', Scope::class)) + ->merge($this->autoloadFilesFromFolder('Query/Scopes', Scope::class)) + ->merge($this->autoloadFilesFromFolder('Query/Scopes/Filters', Scope::class)) ->unique(); foreach ($scopes as $class) { @@ -454,6 +463,10 @@ protected function bootVite() protected function bootConfig() { + if (! $this->shouldBootRootItems()) { + return $this; + } + $filename = $this->getAddon()->slug(); $directory = $this->getAddon()->directory(); $origin = "{$directory}config/{$filename}.php"; @@ -473,6 +486,10 @@ protected function bootConfig() protected function bootTranslations() { + if (! $this->shouldBootRootItems()) { + return $this; + } + $slug = $this->getAddon()->slug(); $directory = $this->getAddon()->directory(); $origin = "{$directory}lang"; @@ -518,7 +535,7 @@ protected function bootRoutes() $web = Arr::get( array: $this->routes, key: 'web', - default: $this->app['files']->exists($path = $directory.'routes/web.php') ? $path : null + default: $this->shouldBootRootItems() && $this->app['files']->exists($path = $directory.'routes/web.php') ? $path : null ); if ($web) { @@ -528,7 +545,7 @@ protected function bootRoutes() $cp = Arr::get( array: $this->routes, key: 'cp', - default: $this->app['files']->exists($path = $directory.'routes/cp.php') ? $path : null + default: $this->shouldBootRootItems() && $this->app['files']->exists($path = $directory.'routes/cp.php') ? $path : null ); if ($cp) { @@ -538,7 +555,7 @@ protected function bootRoutes() $actions = Arr::get( array: $this->routes, key: 'actions', - default: $this->app['files']->exists($path = $directory.'routes/actions.php') ? $path : null + default: $this->shouldBootRootItems() && $this->app['files']->exists($path = $directory.'routes/actions.php') ? $path : null ); if ($actions) { @@ -620,6 +637,10 @@ protected function bootUpdateScripts() protected function bootViews() { + if (! $this->shouldBootRootItems()) { + return $this; + } + if (file_exists($this->getAddon()->directory().'resources/views')) { $this->loadViewsFrom( $this->getAddon()->directory().'resources/views', @@ -746,6 +767,10 @@ protected function bootPublishAfterInstall() protected function bootBlueprints() { + if (! $this->shouldBootRootItems()) { + return $this; + } + if (! file_exists($path = "{$this->getAddon()->directory()}resources/blueprints")) { return $this; } @@ -760,6 +785,10 @@ protected function bootBlueprints() protected function bootFieldsets() { + if (! $this->shouldBootRootItems()) { + return $this; + } + if (! file_exists($path = "{$this->getAddon()->directory()}resources/fieldsets")) { return $this; } @@ -783,7 +812,8 @@ protected function autoloadFilesFromFolder($folder, $requiredClass = null) return []; } - $path = $addon->directory().$addon->autoload().'/'.$folder; + $reflection = new \ReflectionClass(static::class); + $path = dirname($reflection->getFileName()).'/'.$folder; if (! $this->app['files']->exists($path)) { return []; @@ -797,19 +827,71 @@ protected function autoloadFilesFromFolder($folder, $requiredClass = null) } $class = $file->getBasename('.php'); - $fqcn = $this->namespace().'\\'.str_replace('/', '\\', $folder).'\\'.$class; + $fqcn = $reflection->getNamespaceName().'\\'.str_replace('/', '\\', $folder).'\\'.$class; if ((new \ReflectionClass($fqcn))->isAbstract() || (new \ReflectionClass($fqcn))->isInterface()) { continue; } if ($requiredClass && ! is_subclass_of($fqcn, $requiredClass)) { - return; + continue; + } + + if ($this->autoloadedClasses()->contains($fqcn)) { + continue; } $autoloadable[] = $fqcn; + $this->autoloadedClasses()->push($fqcn); } return $autoloadable; } + + private function shouldBootRootItems() + { + $addon = $this->getAddon(); + + // We'll keep track of addons that have been booted to ensure that multiple + // providers don't try to boot things twice. This could happen if there are + // multiple providers in the root autoload directory (src) of an addon. + if ($this->bootedAddons()->contains($addon->id())) { + return false; + } + + // We only want to boot root items if the provider is in the autoloaded directory. + // i.e. It's the "root" provider. If it's in a subdirectory maybe the developer + // is organizing their providers. Things like tags etc. can be autoloaded but + // root level things like routes, views, config, blueprints, etc. will not. + $thisDir = Str::ensureRight(Path::tidy(dirname((new \ReflectionClass(static::class))->getFileName())), '/'); + $autoloadDir = Str::ensureRight($addon->directory().$addon->autoload(), '/'); + + return $thisDir === $autoloadDir; + } + + private function autoloadedClasses() + { + if ($this->autoloadedClasses) { + return $this->autoloadedClasses; + } + + if (! $this->app->bound($autoloaded = 'statamic.autoloaded-addon-classes')) { + $this->app->instance($autoloaded, collect()); + } + + return $this->autoloadedClasses = $this->app->make($autoloaded); + } + + private function bootedAddons() + { + if ($this->bootedAddons) { + return $this->bootedAddons; + } + + if (! $this->app->bound($booted = 'statamic.booted-addons')) { + $this->app->instance($booted, collect()); + } + + return $this->bootedAddons = $this->app->make($booted); + } } diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index ada744379f5..a543a475cd1 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -104,7 +104,7 @@ public function boot() $this->addAboutCommandInfo(); - $this->app->make(Schedule::class)->job(new HandleEntrySchedule)->everyMinute(); + $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } public function register() @@ -150,7 +150,7 @@ public function register() $this->app->singleton(\Statamic\Fields\BlueprintRepository::class, function () { return (new \Statamic\Fields\BlueprintRepository) - ->setDirectory(resource_path('blueprints')) + ->setDirectories(config('statamic.system.blueprints_path')) ->setFallback('default', function () { return \Statamic\Facades\Blueprint::makeFromFields([ 'content' => ['type' => 'markdown', 'localizable' => true], @@ -160,7 +160,7 @@ public function register() $this->app->singleton(\Statamic\Fields\FieldsetRepository::class, function () { return (new \Statamic\Fields\FieldsetRepository) - ->setDirectory(resource_path('fieldsets')); + ->setDirectory(config('statamic.system.fieldsets_path')); }); $this->app->singleton(FieldsetRecursionStack::class); diff --git a/src/Providers/CacheServiceProvider.php b/src/Providers/CacheServiceProvider.php index 6a598cf431a..064d2b88126 100644 --- a/src/Providers/CacheServiceProvider.php +++ b/src/Providers/CacheServiceProvider.php @@ -76,7 +76,7 @@ private function macroRememberWithExpiration() $keyValuePair = $callback(); $value = reset($keyValuePair); - $expiration = Carbon::now()->addMinutes(key($keyValuePair)); + $expiration = Carbon::now()->addMinutes((int) key($keyValuePair)); return Cache::remember($cacheKey, $expiration, function () use ($value) { return $value; diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 4a043e0ac46..c6f661288ca 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -11,6 +11,7 @@ class ConsoleServiceProvider extends ServiceProvider protected $commands = [ Commands\ListCommand::class, Commands\AddonsDiscover::class, + Commands\AssetsCacheClear::class, Commands\AssetsGeneratePresets::class, Commands\AssetsMeta::class, Commands\GlideClear::class, diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 0f8a9d2406c..0c8fa5b6a91 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -37,6 +37,7 @@ class EventServiceProvider extends ServiceProvider \Statamic\Listeners\GeneratePresetImageManipulations::class, \Statamic\Listeners\UpdateAssetReferences::class, \Statamic\Listeners\UpdateTermReferences::class, + \Statamic\Listeners\InvalidateNavCache::class, ]; public function register() diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 788accf0cb4..5c186be2d37 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -3,6 +3,7 @@ namespace Statamic\Providers; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Env; use Illuminate\Support\ServiceProvider; use Statamic\Actions; use Statamic\Actions\Action; @@ -259,10 +260,16 @@ public function register() protected function registerAddonManifest() { + $cachePath = $this->app->bootstrapPath().'/cache/addons.php'; + + if (! is_null($env = Env::get('STATAMIC_ADDONS_CACHE'))) { + $cachePath = Str::startsWith($env, ['/', '\\']) ? $env : $this->app->basePath($env); + } + $this->app->instance(Manifest::class, new Manifest( new Filesystem, $this->app->basePath(), - $this->app->bootstrapPath().'/cache/addons.php' + $cachePath )); } @@ -337,7 +344,7 @@ protected function registerAppExtensions($folder, $requiredClass) } foreach ($this->app['files']->allFiles($path) as $file) { - $relativePathOfFolder = str_replace(app_path('/'), '', $file->getPath()); + $relativePathOfFolder = str_replace(app_path(DIRECTORY_SEPARATOR), '', $file->getPath()); $namespace = str_replace('/', '\\', $relativePathOfFolder); $class = $file->getBasename('.php'); diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index f944749278a..b16050a7f36 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -5,7 +5,6 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View as ViewFactory; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Illuminate\View\View; use Statamic\Contracts\View\Antlers\Parser as ParserContract; use Statamic\Facades\Site; @@ -104,6 +103,7 @@ private function registerAntlers() $runtimeConfig->guardedContentTagPatterns = config('statamic.antlers.guardedContentTags', []); $runtimeConfig->guardedContentModifiers = config('statamic.antlers.guardedContentModifiers', []); $runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false); + $runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false); $runtimeConfig->guardedContentVariablePatterns = array_merge( $runtimeConfig->guardedVariablePatterns, @@ -178,18 +178,17 @@ public function registerBladeDirectives() $nested = '$children'; } - $recursiveChildren = <<<'PHP' -@include('compiled__views::'.$__currentStatamicNavView, array_merge(get_defined_vars(), [ - 'depth' => ($depth ?? 0) + 1, - '__statamicOverrideTagResultValue' => #varName#, -])) + return << (\$depth ?? 0) + 1, + '__statamicOverrideTagResultValue' => $nested, + ]), + \$___statamicNavCallback + ); +?> PHP; - - $recursiveChildren = Str::swap([ - '#varName#' => $nested, - ], $recursiveChildren); - - return Blade::compileString($recursiveChildren); }); } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index e9bd1317373..8dc6c610572 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -14,11 +14,14 @@ use Statamic\Extensions\Pagination\LengthAwarePaginator; use Statamic\Facades\Pattern; use Statamic\Query\Concerns\FakesQueries; +use Statamic\Query\Concerns\QueriesRelationships; +use Statamic\Query\Exceptions\MultipleRecordsFoundException; +use Statamic\Query\Exceptions\RecordsNotFoundException; use Statamic\Query\Scopes\AppliesScopes; abstract class Builder implements Contract { - use AppliesScopes, FakesQueries; + use AppliesScopes, FakesQueries, QueriesRelationships; protected $columns; protected $limit; @@ -314,6 +317,48 @@ public function orWhereJsonLength($column, $operator, $value = null) return $this->whereJsonLength($column, $operator, $value, 'or'); } + public function whereJsonOverlaps($column, $values, $boolean = 'and') + { + if (! is_array($values)) { + $values = [$values]; + } + + $this->wheres[] = [ + 'type' => 'JsonOverlaps', + 'column' => $column, + 'values' => $values, + 'boolean' => $boolean, + ]; + + return $this; + } + + public function orWhereJsonOverlaps($column, $values) + { + return $this->whereJsonOverlaps($column, $values, 'or'); + } + + public function whereJsonDoesntOverlap($column, $values, $boolean = 'and') + { + if (! is_array($values)) { + $values = [$values]; + } + + $this->wheres[] = [ + 'type' => 'JsonDoesntOverlap', + 'column' => $column, + 'values' => $values, + 'boolean' => $boolean, + ]; + + return $this; + } + + public function orWhereJsonDoesntOverlap($column, $values) + { + return $this->whereJsonDoesntOverlap($column, $values, 'or'); + } + public function whereNull($column, $boolean = 'and', $not = false) { $this->wheres[] = [ @@ -565,6 +610,52 @@ public function first() return $this->get()->first(); } + public function firstOrFail($columns = ['*']) + { + if (! is_null($item = $this->select($columns)->first())) { + return $item; + } + + throw new RecordsNotFoundException(); + } + + public function firstOr($columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->select($columns)->first())) { + return $model; + } + + return $callback(); + } + + public function sole($columns = ['*']) + { + $result = $this->get($columns); + + $count = $result->count(); + + if ($count === 0) { + throw new RecordsNotFoundException(); + } + + if ($count > 1) { + throw new MultipleRecordsFoundException($count); + } + + return $result->first(); + } + + public function exists() + { + return $this->count() >= 1; + } + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) { $page = $page ?: Paginator::resolveCurrentPage($pageName); @@ -699,7 +790,7 @@ protected function filterTestNotLike($item, $like) protected function filterTestLikeRegex($item, $pattern) { - return preg_match("/{$pattern}/im", $item); + return preg_match("/{$pattern}/im", (string) $item); } protected function filterTestNotLikeRegex($item, $pattern) diff --git a/src/Query/Concerns/FakesQueries.php b/src/Query/Concerns/FakesQueries.php index 26c0dc5bee6..a21fd589acd 100644 --- a/src/Query/Concerns/FakesQueries.php +++ b/src/Query/Concerns/FakesQueries.php @@ -20,7 +20,7 @@ protected function withFakeQueryLogging(\Closure $callback) $time = round((microtime(true) - $startTime) * 1000, 2); event(new QueryExecuted( - ($sql = new Dumper($this))->dump(), + ($sql = $this->dumper())->dump(), $sql->bindings()->all(), $time, $sql->connection() @@ -39,4 +39,47 @@ public function prepareForFakeQuery(): array 'offset' => $this->offset, ]; } + + public function toSql(): string + { + return $this->dumper()->dump(); + } + + public function toRawSql(): string + { + $sql = ($dumper = $this->dumper())->dump(); + $bindings = $dumper->bindings()->all(); + $connection = $dumper->connection(); + + return $connection + ->query() + ->getGrammar() + ->substituteBindingsIntoRawSql($sql, $connection->prepareBindings($bindings)); + } + + public function dumpRawSql(): static + { + dump($this->toRawSql()); + + return $this; + } + + public function ddRawSql(): void + { + dd($this->toRawSql()); + } + + public function ray(): static + { + throw_unless(function_exists('ray'), new \Exception('Ray is not installed. Run `composer require spatie/laravel-ray --dev`')); + + ray($this->toRawSql()); + + return $this; + } + + private function dumper(): Dumper + { + return new Dumper($this); + } } diff --git a/src/Query/Concerns/QueriesRelationships.php b/src/Query/Concerns/QueriesRelationships.php new file mode 100644 index 00000000000..481d1c401c4 --- /dev/null +++ b/src/Query/Concerns/QueriesRelationships.php @@ -0,0 +1,253 @@ +=', $count = 1, $boolean = 'and', ?Closure $callback = null) + { + if (str_contains($relation, '.')) { + throw new InvalidArgumentException('Nested relations are not supported'); + } + + [$relationQueryBuilder, $relationField] = $this->getRelationQueryBuilderAndField($relation); + + $maxItems = $relationField->config()['max_items'] ?? 0; + $negate = in_array($operator, ['!=', '<']); + + if (! $callback) { + if ($maxItems == 1) { + $method = $boolean == 'and' ? 'whereNull' : 'orWhereNull'; + if (! $negate) { + $method = str_replace('Null', 'NotNull', $method); + } + + return $this->$method($relation); + } + + return $this->{$boolean == 'and' ? 'whereJsonLength' : 'orWhereJsonLength'}($relation, $operator, $count); + } + + if ($count != 1) { + throw new InvalidArgumentException('Counting with subqueries in has clauses is not supported'); + } + + // Get the "IDs" - but really it's the values that are stored in the content. + // In some cases, like taxonomy term fields, the values saved to the content + // are not the actual IDs. e.g. term slugs will get saved when the field + // is only configured with a single taxonomy. + $idMapFn = $relationField->fieldtype()->relationshipQueryIdMapFn() ?? fn ($item) => $item->id(); + + $ids = $relationQueryBuilder + ->where($callback) + ->get(['id']) + ->map($idMapFn) + ->all(); + + if ($maxItems == 1) { + $method = $boolean == 'and' ? 'whereIn' : 'orWhereIn'; + if ($negate) { + $method = str_replace('here', 'hereNot', $method); + } + + return $this->$method($relation, $ids); + } + + if (empty($ids)) { + return $this->{$boolean == 'and' ? 'whereJsonContains' : 'orWhereJsonContains'}($relation, ['']); + } + + return $this->{$boolean == 'and' ? 'where' : 'orWhere'}(function ($subquery) use ($ids, $negate, $relation) { + foreach ($ids as $count => $id) { + $method = $count == 0 ? 'whereJsonContains' : 'orWhereJsonContains'; + if ($negate) { + $method = str_replace('Contains', 'DoesntContain', $method); + } + + $subquery->$method($relation, [$id]); + } + }); + } + + /** + * Add a relationship count / exists condition to the query with an "or". + * + * @param string $relation + * @param string $operator + * @param int $count + * @return \Statamic\Query\Builder|static + */ + public function orHas($relation, $operator = '>=', $count = 1) + { + return $this->has($relation, $operator, $count, 'or'); + } + + /** + * Add a relationship count / exists condition to the query. + * + * @param string $relation + * @param string $boolean + * @return \Statamic\Query\Builder|static + */ + public function doesntHave($relation, $boolean = 'and', ?Closure $callback = null) + { + return $this->has($relation, '<', 1, $boolean, $callback); + } + + /** + * Add a relationship count / exists condition to the query with an "or". + * + * @param string $relation + * @return \Statamic\Query\Builder|static + */ + public function orDoesntHave($relation) + { + return $this->doesntHave($relation, 'or'); + } + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @param string $relation + * @param string $operator + * @param int $count + * @return \Statamic\Query\Builder|static + */ + public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) + { + return $this->has($relation, $operator, $count, 'and', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @param string $relation + * @param string $operator + * @param int $count + * @return \Statamic\Query\Builder|static + */ + public function orWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) + { + return $this->has($relation, $operator, $count, 'or', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * @param string $relation + * @return \Statamic\Query\Builder|static + */ + public function whereDoesntHave($relation, ?Closure $callback = null) + { + return $this->doesntHave($relation, 'and', $callback); + } + + /** + * Add a relationship count / exists condition to the query with where clauses and an "or". + * + * @param string $relation + * @return \Statamic\Query\Builder|static + */ + public function orWhereDoesntHave($relation, ?Closure $callback = null) + { + return $this->doesntHave($relation, 'or', $callback); + } + + /** + * Add a basic where clause to a relationship query. + * + * @param string $relation + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Statamic\Query\Builder|static + */ + public function whereRelation($relation, $column, $operator = null, $value = null) + { + return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @param string $relation + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return \Statamic\Query\Builder|static + */ + public function orWhereRelation($relation, $column, $operator = null, $value = null) + { + return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Get the blueprints available to this query builder + * + * @return \Illuminate\Support\Collection + */ + protected function getBlueprintsForRelations() + { + return collect(); + } + + /** + * Get the query builder and field for the relation we are querying (if they exist) + * + * @param string $relation + * @return \Statamic\Query\Builder + */ + protected function getRelationQueryBuilderAndField($relation) + { + $relationField = $this->getBlueprintsForRelations() + ->flatMap(function ($blueprint) use ($relation) { + return $blueprint->fields()->all()->map(function ($field) use ($relation) { + if ($field->handle() == $relation && $field->fieldtype()->isRelationship()) { + return $field; + } + }) + ->filter() + ->values(); + }) + ->filter() + ->first(); + + if (! $relationField) { + throw new InvalidArgumentException("Relation {$relation} does not exist"); + } + + $queryBuilder = $relationField->fieldtype()->relationshipQueryBuilder(); + + if (! $queryBuilder) { + throw new InvalidArgumentException("Relation {$relation} does not support subquerying"); + } + + return [$queryBuilder, $relationField]; + } +} diff --git a/src/Query/EloquentQueryBuilder.php b/src/Query/EloquentQueryBuilder.php index 7d1ab1f75f8..5fdac007f93 100644 --- a/src/Query/EloquentQueryBuilder.php +++ b/src/Query/EloquentQueryBuilder.php @@ -11,12 +11,15 @@ use Statamic\Contracts\Query\Builder; use Statamic\Extensions\Pagination\LengthAwarePaginator; use Statamic\Facades\Blink; +use Statamic\Query\Concerns\QueriesRelationships; +use Statamic\Query\Exceptions\MultipleRecordsFoundException; +use Statamic\Query\Exceptions\RecordsNotFoundException; use Statamic\Query\Scopes\AppliesScopes; use Statamic\Support\Arr; abstract class EloquentQueryBuilder implements Builder { - use AppliesScopes; + use AppliesScopes, QueriesRelationships; protected $builder; protected $columns; @@ -75,11 +78,68 @@ public function get($columns = ['*']) return $items; } + public function pluck($column, $key = null) + { + $items = $this->get(); + + if (! $key) { + return $items->map(fn ($item) => $item->{$column})->values(); + } + + return $items->mapWithKeys(fn ($item) => [$item->{$key} => $item->{$column}]); + } + public function first() { return $this->get()->first(); } + public function firstOrFail($columns = ['*']) + { + if (! is_null($item = $this->select($columns)->first($columns))) { + return $item; + } + + throw new RecordsNotFoundException(); + } + + public function firstOr($columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->select($columns)->first())) { + return $model; + } + + return $callback(); + } + + public function sole($columns = ['*']) + { + $result = $this->get($columns); + + $count = $result->count(); + + if ($count === 0) { + throw new RecordsNotFoundException(); + } + + if ($count > 1) { + throw new MultipleRecordsFoundException($count); + } + + return $result->first(); + } + + public function exists() + { + return $this->builder->count() >= 1; + } + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) { $paginator = $this->builder->paginate($perPage, $this->selectableColumns($columns), $pageName, $page); @@ -112,13 +172,17 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' return $this->whereNested($column, $boolean); } - if (strtolower($operator) == 'like') { + if ($operator !== null && strtolower($operator) == 'like') { $grammar = $this->builder->getConnection()->getQueryGrammar(); $this->builder->whereRaw('LOWER('.$grammar->wrap($this->column($column)).') LIKE ?', strtolower($value), $boolean); return $this; } + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + $this->builder->where($this->column($column), $operator, $value, $boolean); return $this; @@ -204,6 +268,30 @@ public function orWhereJsonLength($column, $operator, $value = null) return $this->whereJsonLength($column, $operator, $value, 'or'); } + public function whereJsonOverlaps($column, $values, $boolean = 'and') + { + $this->builder->whereJsonOverlaps($this->column($column), $values, $boolean); + + return $this; + } + + public function orWhereJsonOverlaps($column, $values) + { + return $this->whereJsonOverlaps($column, $values, 'or'); + } + + public function whereJsonDoesntOverlap($column, $values, $boolean = 'and') + { + $this->builder->whereJsonDoesntOverlap($this->column($column), $values, $boolean); + + return $this; + } + + public function orWhereJsonDoesntOverlap($column, $values) + { + return $this->whereJsonDoesntOverlap($column, $values, 'or'); + } + public function whereNull($column, $boolean = 'and', $not = false) { $this->builder->whereNull($this->column($column), $boolean, $not); @@ -240,7 +328,7 @@ public function orWhereBetween($column, $values) public function whereNotBetween($column, $values, $boolean = 'and') { - return $this->whereBetween($column, $values, 'or', true); + return $this->whereBetween($column, $values, $boolean, true); } public function orWhereNotBetween($column, $values) @@ -395,6 +483,13 @@ public function orderBy($column, $direction = 'asc') return $this; } + public function orderByDesc($column) + { + $this->builder->orderBy($this->column($column), 'desc'); + + return $this; + } + public function reorder($column = null, $direction = 'asc') { if ($column) { diff --git a/src/Query/Exceptions/MultipleRecordsFoundException.php b/src/Query/Exceptions/MultipleRecordsFoundException.php new file mode 100644 index 00000000000..dfdb6a5cfe6 --- /dev/null +++ b/src/Query/Exceptions/MultipleRecordsFoundException.php @@ -0,0 +1,9 @@ +filter(function ($entry) use ($where) { + $value = $this->getFilterItemValue($entry, $where['column']); + + if (is_null($value) || is_null($where['values'])) { + return false; + } + + if (! is_array($value) && ! is_array($where['values'])) { + return $value === $where['values']; + } + + return ! empty(array_intersect(Arr::wrap($value), $where['values'])); + }); + } + + protected function filterWhereJsonDoesntOverlap($entries, $where) + { + return $entries->filter(function ($entry) use ($where) { + $value = $this->getFilterItemValue($entry, $where['column']); + + if (is_null($value) || is_null($where['values'])) { + return true; + } + + if (! is_array($value) && ! is_array($where['values'])) { + return $value !== $where['values']; + } + + return empty(array_intersect(Arr::wrap($value), $where['values'])); + }); + } + protected function filterWhereDate($entries, $where) { $method = $this->operatorToCarbonMethod($where['operator']); diff --git a/src/Query/Scopes/Filters/Fields.php b/src/Query/Scopes/Filters/Fields.php index 55874c41ed2..42aef42c0b8 100644 --- a/src/Query/Scopes/Filters/Fields.php +++ b/src/Query/Scopes/Filters/Fields.php @@ -10,6 +10,8 @@ use Statamic\Query\Scopes\Filter; use Statamic\Support\Arr; +use function Statamic\trans as __; + class Fields extends Filter { protected $pinned = true; diff --git a/src/Query/Scopes/Filters/Fields/Bard.php b/src/Query/Scopes/Filters/Fields/Bard.php index 18e7f235303..5af40ca4044 100644 --- a/src/Query/Scopes/Filters/Fields/Bard.php +++ b/src/Query/Scopes/Filters/Fields/Bard.php @@ -2,7 +2,58 @@ namespace Statamic\Query\Scopes\Filters\Fields; -class Bard extends Textarea +use Statamic\Support\Arr; +use Statamic\Support\Str; + +class Bard extends FieldtypeFilter { - // + public function fieldItems() + { + return [ + 'operator' => [ + 'type' => 'select', + 'placeholder' => __('Select Operator'), + 'options' => [ + 'like' => __('Contains'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => 'like', + ], + 'value' => [ + 'type' => 'text', + 'if' => [ + 'operator' => 'like', + ], + 'required' => false, + ], + ]; + } + + public function apply($query, $handle, $values) + { + $operator = $values['operator']; + $value = $values['value']; + + if ($operator === 'like') { + $value = Str::ensureLeft($value, '%'); + $value = Str::ensureRight($value, '%'); + } + + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; + } + + public function badge($values) + { + $field = $this->fieldtype->field()->display(); + $operator = $values['operator']; + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); + $value = $values['value']; + + return $field.' '.strtolower($translatedOperator).' '.$value; + } } diff --git a/src/Query/Scopes/Filters/Fields/Date.php b/src/Query/Scopes/Filters/Fields/Date.php index de6e55e6c93..1382c063155 100644 --- a/src/Query/Scopes/Filters/Fields/Date.php +++ b/src/Query/Scopes/Filters/Fields/Date.php @@ -17,6 +17,8 @@ public function fieldItems() '<' => __('Before'), '>' => __('After'), 'between' => __('Between'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), ], ], 'value' => [ @@ -54,7 +56,11 @@ public function apply($query, $handle, $values) $value = Carbon::parse($values['value']); - $query->where($handle, $operator, $value); + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; } public function badge($values) diff --git a/src/Query/Scopes/Filters/Fields/Entries.php b/src/Query/Scopes/Filters/Fields/Entries.php index 538ad0f88c8..3d9fba7f8bb 100644 --- a/src/Query/Scopes/Filters/Fields/Entries.php +++ b/src/Query/Scopes/Filters/Fields/Entries.php @@ -25,6 +25,8 @@ public function fieldItems() 'like' => __('Contains'), '=' => __('Is'), '!=' => __('Isn\'t'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), ], 'default' => 'like', ], @@ -32,8 +34,9 @@ public function fieldItems() 'type' => 'text', 'placeholder' => __('Value'), 'if' => [ - 'operator' => 'not empty', + 'operator' => 'contains_any like, =, !=', ], + 'required' => false, ], ]; } @@ -45,6 +48,15 @@ public function apply($query, $handle, $values) $operator = $values['operator']; $value = $values['value']; + if (in_array($operator, ['null', 'not-null'])) { + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + }; + + return; + } + if ($operator === 'like') { $value = Str::ensureLeft($value, '%'); $value = Str::ensureRight($value, '%'); diff --git a/src/Query/Scopes/Filters/Fields/FieldtypeFilter.php b/src/Query/Scopes/Filters/Fields/FieldtypeFilter.php index bc114b210a2..b21516c4809 100644 --- a/src/Query/Scopes/Filters/Fields/FieldtypeFilter.php +++ b/src/Query/Scopes/Filters/Fields/FieldtypeFilter.php @@ -27,6 +27,8 @@ public function fieldItems() 'like' => __('Contains'), '=' => __('Is'), '<>' => __('Isn\'t'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), ], 'default' => 'like', ], @@ -34,8 +36,9 @@ public function fieldItems() 'type' => 'text', 'placeholder' => __('Value'), 'if' => [ - 'operator' => 'not empty', + 'operator' => 'contains_any like, =, <>', ], + 'required' => false, ], ]; } @@ -50,7 +53,11 @@ public function apply($query, $handle, $values) $value = Str::ensureRight($value, '%'); } - $query->where($handle, $operator, $value); + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; } public function badge($values) diff --git a/src/Query/Scopes/Filters/Fields/Grid.php b/src/Query/Scopes/Filters/Fields/Grid.php index ed710847ee1..6446ba9fe9f 100644 --- a/src/Query/Scopes/Filters/Fields/Grid.php +++ b/src/Query/Scopes/Filters/Fields/Grid.php @@ -2,7 +2,58 @@ namespace Statamic\Query\Scopes\Filters\Fields; -class Grid extends Textarea +use Statamic\Support\Arr; +use Statamic\Support\Str; + +class Grid extends FieldtypeFilter { - // + public function fieldItems() + { + return [ + 'operator' => [ + 'type' => 'select', + 'placeholder' => __('Select Operator'), + 'options' => [ + 'like' => __('Contains'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => 'like', + ], + 'value' => [ + 'type' => 'text', + 'if' => [ + 'operator' => 'like', + ], + 'required' => false, + ], + ]; + } + + public function apply($query, $handle, $values) + { + $operator = $values['operator']; + $value = $values['value']; + + if ($operator === 'like') { + $value = Str::ensureLeft($value, '%'); + $value = Str::ensureRight($value, '%'); + } + + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; + } + + public function badge($values) + { + $field = $this->fieldtype->field()->display(); + $operator = $values['operator']; + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); + $value = $values['value']; + + return $field.' '.strtolower($translatedOperator).' '.$value; + } } diff --git a/src/Query/Scopes/Filters/Fields/Number.php b/src/Query/Scopes/Filters/Fields/Number.php index e4f655d5992..2ed26c44824 100644 --- a/src/Query/Scopes/Filters/Fields/Number.php +++ b/src/Query/Scopes/Filters/Fields/Number.php @@ -21,6 +21,8 @@ public function fieldItems() '>=' => __('Greater than or equals'), '<' => __('Less than'), '<=' => __('Less than or equals'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), ], 'default' => '=', ], @@ -28,7 +30,7 @@ public function fieldItems() 'type' => $this->valueFieldtype(), 'placeholder' => __('Value'), 'if' => [ - 'operator' => 'not empty', + 'operator' => 'contains_any <>, >, >=, <, <=, =', ], ], ]; @@ -39,7 +41,11 @@ public function apply($query, $handle, $values) $operator = $values['operator']; $value = $values['value']; - $query->where($handle, $operator, $value); + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; } public function badge($values) diff --git a/src/Query/Scopes/Filters/Fields/Replicator.php b/src/Query/Scopes/Filters/Fields/Replicator.php index 4d0ee67dc8b..1614b5f1ef0 100644 --- a/src/Query/Scopes/Filters/Fields/Replicator.php +++ b/src/Query/Scopes/Filters/Fields/Replicator.php @@ -2,7 +2,58 @@ namespace Statamic\Query\Scopes\Filters\Fields; -class Replicator extends Textarea +use Statamic\Support\Arr; +use Statamic\Support\Str; + +class Replicator extends FieldtypeFilter { - // + public function fieldItems() + { + return [ + 'operator' => [ + 'type' => 'select', + 'placeholder' => __('Select Operator'), + 'options' => [ + 'like' => __('Contains'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => 'like', + ], + 'value' => [ + 'type' => 'text', + 'if' => [ + 'operator' => 'like', + ], + 'required' => false, + ], + ]; + } + + public function apply($query, $handle, $values) + { + $operator = $values['operator']; + $value = $values['value']; + + if ($operator === 'like') { + $value = Str::ensureLeft($value, '%'); + $value = Str::ensureRight($value, '%'); + } + + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; + } + + public function badge($values) + { + $field = $this->fieldtype->field()->display(); + $operator = $values['operator']; + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); + $value = $values['value']; + + return $field.' '.strtolower($translatedOperator).' '.$value; + } } diff --git a/src/Query/Scopes/Filters/Fields/Template.php b/src/Query/Scopes/Filters/Fields/Template.php index da1aaba5b4d..80e4358389c 100644 --- a/src/Query/Scopes/Filters/Fields/Template.php +++ b/src/Query/Scopes/Filters/Fields/Template.php @@ -2,19 +2,37 @@ namespace Statamic\Query\Scopes\Filters\Fields; +use Statamic\Support\Arr; + class Template extends FieldtypeFilter { public function fieldItems() { return [ + 'operator' => [ + 'type' => 'select', + 'placeholder' => __('Select Operator'), + 'options' => [ + '=' => __('Is'), + '<>' => __('Isn\'t'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => '=', + ], 'value' => [ 'type' => 'template', + 'if' => [ + 'operator' => 'contains_any <>, =', + ], + 'required' => false, ], ]; } public function apply($query, $handle, $values) { + $operator = $values['operator']; $template = $values['value']; $variations = [ @@ -22,15 +40,21 @@ public function apply($query, $handle, $values) str_replace('/', '.', $template), ]; - $query->whereIn($handle, $variations); + match ($operator) { + '=' => $query->whereIn($handle, $variations), + '<>' => $query->whereNotIn($handle, $variations), + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + }; } public function badge($values) { $field = $this->fieldtype->field()->display(); - $operator = __('Is'); + $operator = $values['operator']; + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); $value = $values['value']; - return strtolower($field).' '.strtolower($operator).' '.$value; + return $field.' '.strtolower($translatedOperator).' '.$value; } } diff --git a/src/Query/Scopes/Filters/Fields/Terms.php b/src/Query/Scopes/Filters/Fields/Terms.php index c3ba85c2296..9fcd560ba8e 100644 --- a/src/Query/Scopes/Filters/Fields/Terms.php +++ b/src/Query/Scopes/Filters/Fields/Terms.php @@ -3,35 +3,59 @@ namespace Statamic\Query\Scopes\Filters\Fields; use Statamic\Facades; -use Statamic\Support\Str; +use Statamic\Support\Arr; class Terms extends FieldtypeFilter { public function fieldItems() { return [ - 'term' => [ + 'operator' => [ 'type' => 'select', - 'options' => $this->options()->all(), - 'placeholder' => __('Contains'), + 'options' => [ + 'like' => __('Contains'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => 'like', + ], + 'term' => [ + 'type' => 'terms', + 'placeholder' => __('Term'), 'clearable' => true, + 'mode' => 'select', + 'max_items' => 1, + 'taxonomies' => $this->fieldtype->taxonomies(), + 'if' => [ + 'operator' => 'contains_any like', + ], ], ]; } public function apply($query, $handle, $values) { - $term = $values['term']; + $operator = $values['operator']; - $term = Str::ensureLeft($term, '%'); - $term = Str::ensureRight($term, '%'); - - $query->where($handle, 'like', $term); + match ($operator) { + 'like' => $this->fieldtype->config('max_items') === 1 + ? $query->where($handle, 'like', "%{$values['term']}%") + : $query->whereJsonContains($handle, $values['term']), + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + }; } public function badge($values) { $field = $this->fieldtype->field()->display(); + $operator = $values['operator']; + + if (in_array($operator, ['null', 'not-null'])) { + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); + + return $field.' '.strtolower($translatedOperator); + } $id = $this->fieldtype->usingSingleTaxonomy() ? $this->fieldtype->taxonomies()[0].'::'.$values['term'] @@ -41,21 +65,4 @@ public function badge($values) return $field.': '.$term; } - - protected function options() - { - return collect($this->fieldtype->taxonomies()) - ->map(function ($handle) { - return Facades\Taxonomy::find($handle); - }) - ->filter() - ->flatMap(function ($taxonomy) { - return $taxonomy->queryTerms()->get(); - }) - ->mapWithKeys(function ($term) { - $value = $this->fieldtype->usingSingleTaxonomy() ? $term->slug() : $term->id(); - - return [$value => $term->title()]; - }); - } } diff --git a/src/Query/Scopes/Filters/Fields/Textarea.php b/src/Query/Scopes/Filters/Fields/Textarea.php index 6fa3acaeb70..6fb13d5a7ab 100644 --- a/src/Query/Scopes/Filters/Fields/Textarea.php +++ b/src/Query/Scopes/Filters/Fields/Textarea.php @@ -2,6 +2,7 @@ namespace Statamic\Query\Scopes\Filters\Fields; +use Statamic\Support\Arr; use Statamic\Support\Str; class Textarea extends FieldtypeFilter @@ -9,29 +10,50 @@ class Textarea extends FieldtypeFilter public function fieldItems() { return [ + 'operator' => [ + 'type' => 'select', + 'options' => [ + 'like' => __('Contains'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => 'like', + ], 'value' => [ 'type' => 'text', - 'placeholder' => __('Contains'), + 'placeholder' => __('Value'), + 'if' => [ + 'operator' => 'like', + ], + 'required' => false, ], ]; } public function apply($query, $handle, $values) { + $operator = $values['operator']; $value = $values['value']; - $value = Str::ensureLeft($value, '%'); - $value = Str::ensureRight($value, '%'); + if ($operator === 'like') { + $value = Str::ensureLeft($value, '%'); + $value = Str::ensureRight($value, '%'); + } - $query->where($handle, 'like', $value); + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + default => $query->where($handle, $operator, $value), + }; } public function badge($values) { $field = $this->fieldtype->field()->display(); - $operator = __('Contains'); + $operator = $values['operator']; + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); $value = $values['value']; - return strtolower($field).' '.strtolower($operator).' '.$value; + return $field.' '.strtolower($translatedOperator).' '.$value; } } diff --git a/src/Query/Scopes/Filters/Fields/User.php b/src/Query/Scopes/Filters/Fields/User.php index 33f67504c54..cb18df7f80f 100644 --- a/src/Query/Scopes/Filters/Fields/User.php +++ b/src/Query/Scopes/Filters/Fields/User.php @@ -3,22 +3,48 @@ namespace Statamic\Query\Scopes\Filters\Fields; use Statamic\Facades\User as Users; +use Statamic\Support\Arr; class User extends FieldtypeFilter { public function fieldItems() { return [ + 'operator' => [ + 'type' => 'select', + 'placeholder' => __('Select Operator'), + 'options' => [ + '=' => __('Is'), + 'null' => __('Empty'), + 'not-null' => __('Not empty'), + ], + 'default' => '=', + ], 'value' => [ 'type' => 'users', 'max_items' => 1, 'mode' => 'select', + 'if' => [ + 'operator' => 'contains_any like, =, !=', + ], + 'required' => false, ], ]; } public function apply($query, $handle, $values) { + $operator = $values['operator']; + + if (in_array($operator, ['null', 'not-null'])) { + match ($operator) { + 'null' => $query->whereNull($handle), + 'not-null' => $query->whereNotNull($handle), + }; + + return; + } + if (! $user = $values['value']) { return; } @@ -30,13 +56,21 @@ public function apply($query, $handle, $values) public function badge($values) { + $field = $this->fieldtype->field()->display(); + $operator = $values['operator']; + + if (in_array($operator, ['null', 'not-null'])) { + $translatedOperator = Arr::get($this->fieldItems(), "operator.options.{$operator}"); + + return $field.' '.strtolower($translatedOperator); + } + if (! $user = $values['value']) { return null; } - $field = $this->fieldtype->field()->display(); - $operator = __('Is'); - $user = Users::find($user)->name(); + $user = Users::find($user); + $user = $user->name() ?? $user->email(); return $field.' '.strtolower($operator).' '.$user; } diff --git a/src/Query/Scopes/Filters/Site.php b/src/Query/Scopes/Filters/Site.php index 21ed0dcea74..e20a24f9941 100644 --- a/src/Query/Scopes/Filters/Site.php +++ b/src/Query/Scopes/Filters/Site.php @@ -7,6 +7,8 @@ use Statamic\Facades\Collection; use Statamic\Query\Scopes\Filter; +use function Statamic\trans as __; + class Site extends Filter { protected $pinned = true; diff --git a/src/Revisions/Revision.php b/src/Revisions/Revision.php index 171c1ca52e0..56e8c2b1c17 100644 --- a/src/Revisions/Revision.php +++ b/src/Revisions/Revision.php @@ -113,7 +113,7 @@ public function toArray() $user = [ 'id' => $user->id(), 'email' => $user->email(), - 'name' => $user->name(), + 'name' => $user->name() ?? $user->email(), 'avatar' => $user->avatar(), 'initials' => $user->initials(), ]; diff --git a/src/Revisions/RevisionRepository.php b/src/Revisions/RevisionRepository.php index 66e433ae2fa..88641f3268e 100644 --- a/src/Revisions/RevisionRepository.php +++ b/src/Revisions/RevisionRepository.php @@ -29,13 +29,15 @@ public function whereKey($key) $files = Folder::getFiles($directory); - return FileCollection::make($files)->filterByExtension('yaml')->reject(function ($path) { + $revisions = FileCollection::make($files)->filterByExtension('yaml')->reject(function ($path) { return Str::endsWith($path, 'working.yaml'); })->map(function ($path) use ($key) { return $this->makeRevisionFromFile($key, $path); })->keyBy(function ($revision) { return $revision->date()->timestamp; }); + + return collect($revisions->all()); } public function findWorkingCopyByKey($key) @@ -69,7 +71,7 @@ protected function makeRevisionFromFile($key, $path) ->key($key) ->action($yaml['action'] ?? false) ->id($date = $yaml['date']) - ->date(Carbon::createFromTimestamp($date)) + ->date(Carbon::createFromTimestamp($date, config('app.timezone'))) ->user($yaml['user'] ?? false) ->message($yaml['message'] ?? false) ->attributes($yaml['attributes']); diff --git a/src/Routing/ResolveRedirect.php b/src/Routing/ResolveRedirect.php index e549051e952..7ac738ae24b 100644 --- a/src/Routing/ResolveRedirect.php +++ b/src/Routing/ResolveRedirect.php @@ -7,6 +7,7 @@ use Statamic\Facades; use Statamic\Facades\Site; use Statamic\Fields\Values; +use Statamic\Fieldtypes\Link\ArrayableLink; use Statamic\Structures\Page; use Statamic\Support\Str; @@ -32,6 +33,10 @@ public function resolve($redirect, $parent = null, $localize = false) public function item($redirect, $parent = null, $localize = false) { + if ($redirect instanceof ArrayableLink) { + return $redirect->url(); + } + if (is_null($redirect)) { return null; } diff --git a/src/Rules/AllowedFile.php b/src/Rules/AllowedFile.php index 7446c5e6f24..8f361ad783d 100644 --- a/src/Rules/AllowedFile.php +++ b/src/Rules/AllowedFile.php @@ -120,8 +120,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } - private function isAllowedExtension(UploadedFile $file): bool + private function isAllowedExtension(mixed $file): bool { + if (! $file instanceof UploadedFile) { + return false; + } + $extensions = $this->allowedExtensions ?? array_merge(static::EXTENSIONS, config('statamic.assets.additional_uploadable_extensions', [])); return in_array(trim(strtolower($file->getClientOriginalExtension())), $extensions); diff --git a/src/Rules/Handle.php b/src/Rules/Handle.php index 9a3c06514fc..fa967c26377 100644 --- a/src/Rules/Handle.php +++ b/src/Rules/Handle.php @@ -4,11 +4,18 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Support\Str; class Handle implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { + if (Str::startsWith($value, range(0, 9))) { + $fail('statamic::validation.handle_starts_with_number')->translate(); + + return; + } + if (! preg_match('/^[a-zA-Z][a-zA-Z0-9]*(?:_{0,1}[a-zA-Z0-9])*$/', $value)) { $fail('statamic::validation.handle')->translate(); } diff --git a/src/Search/Comb/Comb.php b/src/Search/Comb/Comb.php index 814a86b2f42..caab9e1c03a 100644 --- a/src/Search/Comb/Comb.php +++ b/src/Search/Comb/Comb.php @@ -539,11 +539,11 @@ private function searchOverData($params, $raw_query) $escaped_chunk = preg_quote($chunk, '#'); $chunk_is_word = ! preg_match('#\s#', $chunk); $regex = [ - 'partial_anywhere' => '#'.$escaped_chunk.'#i', - 'partial_from_start_anywhere' => '#(^|\s)'.$escaped_chunk.'#i', - 'whole_anywhere' => '#(^|\s)'.$escaped_chunk.'($|\s)#i', - 'partial_from_start' => '#^'.$escaped_chunk.'#i', - 'whole' => '#^'.$escaped_chunk.'$#i', + 'partial_anywhere' => '#'.$escaped_chunk.'#iu', + 'partial_from_start_anywhere' => '#(^|\s)'.$escaped_chunk.'#iu', + 'whole_anywhere' => '#(^|\s)'.$escaped_chunk.'($|\s)#iu', + 'partial_from_start' => '#^'.$escaped_chunk.'#iu', + 'whole' => '#^'.$escaped_chunk.'$#iu', ]; // loop over each data property @@ -605,7 +605,7 @@ private function searchOverData($params, $raw_query) } // snippet extraction (only needs to run during one chunk) - if ($matched && $j === 0) { + if ($matched && ! isset($snippets[$name])) { $snippets[$name] = $this->extractSnippets($property, $params['chunks']); } } @@ -710,8 +710,8 @@ private function searchOverData($params, $raw_query) */ private function removeDisallowedMatches($params) { - $disallowed = '#'.implode('|', $params['disallowed']).'#i'; - $required = '#(?=.*'.implode(')(?=.*', $params['required']).')#i'; + $disallowed = '#'.implode('|', $params['disallowed']).'#iu'; + $required = '#(?=.*'.implode(')(?=.*', $params['required']).')#iu'; $new_data = []; // this only applies to boolean mode @@ -726,7 +726,11 @@ private function removeDisallowedMatches($params) // string pruned results together foreach ($item['pruned'] as $pruned) { - $record .= ' '.$pruned; + if (is_array($pruned)) { + $record .= ' '.$this->flattenArray($pruned); + } else { + $record .= ' '.$pruned; + } } // check for disallowed @@ -1058,7 +1062,7 @@ private function extractSnippets($value, $chunks) $escaped_chunks = collect($chunks) ->map(fn ($chunk) => preg_quote($chunk, '#')) ->join('|'); - $regex = '#(.*?)('.$escaped_chunks.')(.{0,'.$length.'}(?:\s|$))#i'; + $regex = '#(.*?)('.$escaped_chunks.')(.{0,'.$length.'}(?:\s|$))#iu'; if (! preg_match_all($regex, $value, $matches, PREG_SET_ORDER)) { return []; } @@ -1081,7 +1085,7 @@ private function extractSnippets($value, $chunks) } $snippets[] = trim($snippet); } - if (preg_match('#('.$escaped_chunks.')#i', $surplus)) { + if (preg_match('#('.$escaped_chunks.')#iu', $surplus)) { $snippets[] = trim($surplus); } diff --git a/src/Search/Commands/Update.php b/src/Search/Commands/Update.php index 19f448fc1a6..dba5d49f5a3 100644 --- a/src/Search/Commands/Update.php +++ b/src/Search/Commands/Update.php @@ -50,7 +50,7 @@ private function getIndexes() $selection = select( label: 'Which search index would you like to update?', options: collect(['All'])->merge($this->indexes()->keys())->all(), - default: 0 + default: 'All' ); return ($selection == 'All') ? $this->indexes() : [$this->indexes()->get($selection)]; diff --git a/src/Search/Index.php b/src/Search/Index.php index 5eebe09f3f1..78eaaf65de6 100644 --- a/src/Search/Index.php +++ b/src/Search/Index.php @@ -2,6 +2,7 @@ namespace Statamic\Search; +use Closure; use Statamic\Contracts\Search\Searchable; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -11,6 +12,7 @@ abstract class Index protected $name; protected $locale; protected $config; + protected static ?Closure $nameCallback = null; abstract public function search($query); @@ -24,7 +26,10 @@ abstract protected function deleteIndex(); public function __construct($name, array $config, ?string $locale = null) { - $this->name = $locale ? $name.'_'.$locale : $name; + $this->name = static::$nameCallback + ? call_user_func(static::$nameCallback, $name, $locale) + : ($locale ? $name.'_'.$locale : $name); + $this->config = $config; $this->locale = $locale; } @@ -34,6 +39,11 @@ public function name() return $this->name; } + public static function resolveNameUsing(?Closure $callback) + { + static::$nameCallback = $callback; + } + public function title() { return $this->config['title'] ?? Str::title($this->name); diff --git a/src/Search/Null/NullSearchables.php b/src/Search/Null/NullSearchables.php index a2e1b90d04e..c63ab192419 100644 --- a/src/Search/Null/NullSearchables.php +++ b/src/Search/Null/NullSearchables.php @@ -2,10 +2,17 @@ namespace Statamic\Search\Null; +use Illuminate\Support\LazyCollection; + class NullSearchables { public function contains() { return false; } + + public function lazy(): LazyCollection + { + return LazyCollection::make(); + } } diff --git a/src/Search/Result.php b/src/Search/Result.php index 875b7984b2d..281dc423bce 100644 --- a/src/Search/Result.php +++ b/src/Search/Result.php @@ -143,4 +143,9 @@ public function setSupplement($key, $value) { $this->searchable->setSupplement($key, $value); } + + public function __call($method, $args) + { + return $this->searchable->$method(...$args); + } } diff --git a/src/Search/Tags.php b/src/Search/Tags.php index 62c4456337a..909ba146418 100644 --- a/src/Search/Tags.php +++ b/src/Search/Tags.php @@ -54,12 +54,12 @@ protected function queryStatus($query) protected function querySite($query) { - $site = $this->params->get(['site', 'locale'], Site::current()->handle()); + $sites = $this->params->explode(['site', 'locale'], [Site::current()->handle()]); - if ($site === '*' || ! Site::hasMultiple()) { + if (in_array('*', $sites) || ! Site::hasMultiple()) { return; } - return $query->where('site', $site); + return $query->whereIn('site', $sites); } } diff --git a/src/Sites/Site.php b/src/Sites/Site.php index 9e81178e7b1..bc46e744b76 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -7,7 +7,9 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\TextDirection; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Antlers\Language\Runtime\RuntimeParser; +use Statamic\View\Cascade; class Site implements Augmentable { @@ -16,12 +18,14 @@ class Site implements Augmentable protected $handle; protected $config; protected $rawConfig; + protected $isDefault; - public function __construct($handle, $config) + public function __construct($handle, $config, $isDefault = false) { $this->handle = $handle; $this->config = $this->resolveAntlers($config); $this->rawConfig = $config; + $this->isDefault = $isDefault; } public function handle() @@ -95,6 +99,11 @@ public function relativePath($url) return $path === '' ? '/' : $path; } + public function isDefault() + { + return $this->isDefault; + } + public function set($key, $value) { $this->config[$key] = $this->resolveAntlersValue($value); @@ -122,7 +131,14 @@ protected function resolveAntlersValue($value) ->all(); } - return (string) app(RuntimeParser::class)->parse($value, ['config' => config()->all()]); + $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = true; + + try { + return (string) app(RuntimeParser::class)->parse($value, ['config' => Cascade::config()]); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; + } } private function removePath($url) diff --git a/src/Sites/Sites.php b/src/Sites/Sites.php index 717ed3ad7f2..855f5786d5c 100644 --- a/src/Sites/Sites.php +++ b/src/Sites/Sites.php @@ -138,7 +138,7 @@ protected function getFallbackConfig() 'default' => [ 'name' => '{{ config:app:name }}', 'url' => '/', - 'locale' => 'en_US', + 'locale' => '{{ config:app:locale }}', ], ]; } @@ -268,7 +268,9 @@ public function config(): array protected function hydrateConfig($config): Collection { - return collect($config)->map(fn ($site, $handle) => new Site($handle, $site)); + $defaultSiteHandle = collect($config)->keys()->first(); + + return collect($config)->map(fn ($site, $handle) => new Site($handle, $site, $handle === $defaultSiteHandle)); } protected function getNewSites(): Collection diff --git a/src/Stache/Indexes/Terms/Value.php b/src/Stache/Indexes/Terms/Value.php index a5e13a48575..817510f505e 100644 --- a/src/Stache/Indexes/Terms/Value.php +++ b/src/Stache/Indexes/Terms/Value.php @@ -10,6 +10,7 @@ class Value extends Index public function getItems() { $associatedItems = $this->store->index('associations')->items() + ->filter() ->mapWithKeys(function ($association) { $term = Term::make($value = $association['slug']) ->taxonomy($this->store->childKey()) diff --git a/src/Stache/Query/Builder.php b/src/Stache/Query/Builder.php index 34449236485..a5960d6f037 100644 --- a/src/Stache/Query/Builder.php +++ b/src/Stache/Query/Builder.php @@ -157,16 +157,30 @@ protected function filterWhereBasic($values, $where) protected function filterWhereIn($values, $where) { - return $values->filter(function ($value) use ($where) { - return in_array($value, $where['values']); - }); + $lookup = array_flip(array_map($this->normalizeLookupValue(...), $where['values'])); + + return $values->filter( + fn ($value) => ! is_array($value) && isset($lookup[$this->normalizeLookupValue($value)]) + ); } protected function filterWhereNotIn($values, $where) { - return $values->filter(function ($value) use ($where) { - return ! in_array($value, $where['values']); - }); + $lookup = array_flip(array_map($this->normalizeLookupValue(...), $where['values'])); + + return $values->filter( + fn ($value) => is_array($value) || ! isset($lookup[$this->normalizeLookupValue($value)]) + ); + } + + private function normalizeLookupValue($value): string|int + { + return match (true) { + $value === null => '__NULL__', + $value === true => '__TRUE__', + $value === false => '__FALSE__', + default => $value, + }; } protected function filterWhereNull($values, $where) @@ -329,6 +343,36 @@ protected function filterWhereJsonLength($values, $where) }); } + protected function filterWhereJsonOverlaps($values, $where) + { + return $values->filter(function ($value) use ($where) { + if (is_null($value) || is_null($where['values'])) { + return false; + } + + if (! is_array($value) && ! is_array($where['values'])) { + return $value === $where['values']; + } + + return ! empty(array_intersect(Arr::wrap($value), $where['values'])); + }); + } + + protected function filterWhereJsonDoesntOverlap($values, $where) + { + return $values->filter(function ($value) use ($where) { + if (is_null($value) || is_null($where['values'])) { + return true; + } + + if (! is_array($value) && ! is_array($where['values'])) { + return $value !== $where['values']; + } + + return empty(array_intersect(Arr::wrap($value), $where['values'])); + }); + } + protected function filterWhereColumn($values, $where) { $whereColumnKeys = $this->getWhereColumnKeyValuesByIndex($where['value']); diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index d20fe0c77b9..701b050c46f 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -18,6 +18,8 @@ class EntryQueryBuilder extends Builder implements QueryBuilder public function where($column, $operator = null, $value = null, $boolean = 'and') { if ($column === 'collection') { + $this->verifyCollectionBeforeStatus(); + $this->collections[] = $operator; return $this; @@ -33,6 +35,8 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' public function whereIn($column, $values, $boolean = 'and') { if (in_array($column, ['collection', 'collections'])) { + $this->verifyCollectionBeforeStatus(); + $this->collections = array_merge($this->collections ?? [], $values); return $this; @@ -45,6 +49,13 @@ public function whereIn($column, $values, $boolean = 'and') return parent::whereIn($column, $values, $boolean); } + private function verifyCollectionBeforeStatus() + { + if ($this->queriedByStatus) { + throw new \LogicException('The collection clause must come before the status clause.'); + } + } + protected function collect($items = []) { return EntryCollection::make($items); @@ -143,6 +154,23 @@ protected function getWhereColumnKeyValuesByIndex($column) }); } + protected function getBlueprintsForRelations() + { + $collections = empty($this->collections) + ? Facades\Collection::all() + : $this->collections; + + return collect($collections)->flatMap(function ($collection) { + if (is_string($collection)) { + $collection = Facades\Collection::find($collection); + } + + return $collection ? $collection->entryBlueprints() : false; + }) + ->filter() + ->unique(); + } + private function ensureCollectionsAreQueriedForStatusQuery(): void { // If the collections property isn't empty, it means the user has explicitly diff --git a/src/Stache/Query/QueriesEntryStatus.php b/src/Stache/Query/QueriesEntryStatus.php index 77ee9b58ccc..05885635219 100644 --- a/src/Stache/Query/QueriesEntryStatus.php +++ b/src/Stache/Query/QueriesEntryStatus.php @@ -6,8 +6,12 @@ trait QueriesEntryStatus { + private $queriedByStatus = false; + public function whereStatus(string $status) { + $this->queriedByStatus = true; + if ($status === 'any') { return $this; } diff --git a/src/Stache/Query/TermQueryBuilder.php b/src/Stache/Query/TermQueryBuilder.php index deda55b3770..e02bdf86d2b 100644 --- a/src/Stache/Query/TermQueryBuilder.php +++ b/src/Stache/Query/TermQueryBuilder.php @@ -178,6 +178,23 @@ protected function getWhereColumnKeyValuesByIndex($column) return $items; } + protected function getBlueprintsForRelations() + { + $taxonomies = empty($this->taxonomies) + ? Facades\Taxonomy::handles() + : $this->taxonomies; + + return collect($taxonomies)->flatMap(function ($taxonomy) { + if (is_string($taxonomy)) { + $taxonomy = Facades\Taxonomy::find($taxonomy); + } + + return $taxonomy ? $taxonomy->termBlueprints() : false; + }) + ->filter() + ->unique(); + } + public function prepareForFakeQuery(): array { $data = parent::prepareForFakeQuery(); diff --git a/src/Stache/Query/UserQueryBuilder.php b/src/Stache/Query/UserQueryBuilder.php index 49be86af181..da3e82fdfbe 100644 --- a/src/Stache/Query/UserQueryBuilder.php +++ b/src/Stache/Query/UserQueryBuilder.php @@ -3,6 +3,7 @@ namespace Statamic\Stache\Query; use Statamic\Auth\UserCollection; +use Statamic\Facades\User; class UserQueryBuilder extends Builder { @@ -116,4 +117,9 @@ protected function getOrderKeyValuesByIndex() return [$orderBy->sort => $items]; }); } + + protected function getBlueprintsForRelations() + { + return collect([User::make()->blueprint()]); + } } diff --git a/src/Stache/Repositories/CollectionRepository.php b/src/Stache/Repositories/CollectionRepository.php index 95911347b87..0afb9d3b9d2 100644 --- a/src/Stache/Repositories/CollectionRepository.php +++ b/src/Stache/Repositories/CollectionRepository.php @@ -50,8 +50,8 @@ public function findByMount($mount): ?Collection return Blink::once('mounted-collections', fn () => $this ->all() - ->keyBy(fn ($collection) => $collection->mount()?->id()) - ->filter() + ->keyBy(fn ($collection) => $collection->mount()?->id() ?? '__nomount') + ->filter(fn ($collection, $mountId) => $mountId !== '__nomount') )->get($mount->id()); } diff --git a/src/Stache/Repositories/EntryRepository.php b/src/Stache/Repositories/EntryRepository.php index 1aac62567b9..9c0fe65e812 100644 --- a/src/Stache/Repositories/EntryRepository.php +++ b/src/Stache/Repositories/EntryRepository.php @@ -96,6 +96,28 @@ public function findByUri(string $uri, ?string $site = null): ?Entry : $entry; } + public function whereInId($ids): EntryCollection + { + if (empty($ids)) { + return EntryCollection::make(); + } + + $entries = $this->query()->whereIn('id', $ids)->get(); + + if ($entries->isEmpty()) { + return EntryCollection::make(); + } + + $entriesById = $entries->keyBy->id(); + + $ordered = collect($ids) + ->map(fn ($id) => $entriesById->get($id)) + ->filter() + ->values(); + + return EntryCollection::make($ordered); + } + public function save($entry) { if (! $entry->id()) { @@ -162,6 +184,10 @@ public function substitute($item) public function applySubstitutions($items) { + if (empty($this->substitutionsById)) { + return $items; + } + return $items->map(function ($item) { return $this->substitutionsById[$item->id()] ?? $item; }); diff --git a/src/Stache/Repositories/SubmissionRepository.php b/src/Stache/Repositories/SubmissionRepository.php index f10396aaef6..15e9f430af7 100644 --- a/src/Stache/Repositories/SubmissionRepository.php +++ b/src/Stache/Repositories/SubmissionRepository.php @@ -31,6 +31,10 @@ public function whereForm(string $handle): Collection public function whereInForm(array $handles): Collection { + if (empty($handles)) { + return collect(); + } + return $this->query()->whereIn('form', $handles)->get(); } diff --git a/src/Stache/Repositories/TermRepository.php b/src/Stache/Repositories/TermRepository.php index 033d6bad888..ff0cc578e4a 100644 --- a/src/Stache/Repositories/TermRepository.php +++ b/src/Stache/Repositories/TermRepository.php @@ -47,6 +47,10 @@ public function whereTaxonomy(string $handle): TermCollection public function whereInTaxonomy(array $handles): TermCollection { + if (empty($handles)) { + return TermCollection::make(); + } + collect($handles) ->reject(fn ($taxonomy) => Taxonomy::find($taxonomy)) ->each(fn ($taxonomy) => throw new TaxonomyNotFoundException($taxonomy)); @@ -102,6 +106,10 @@ public function findByUri(string $uri, ?string $site = null): ?Term return null; } + if ($term->uri() !== '/'.$uri) { + return null; + } + return $term->collection($collection); } @@ -195,6 +203,10 @@ public function substitute($item) public function applySubstitutions($items) { + if (empty($this->substitutionsById)) { + return $items; + } + return $items->map(function ($item) { return $this->substitutionsById[$item->id()] ?? $item; }); diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 2c08f63036b..3a5c2fc03e3 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Concurrency; use Statamic\Events\StacheCleared; use Statamic\Events\StacheWarmed; use Statamic\Extensions\FileStore; @@ -22,6 +23,7 @@ class Stache protected $lockFactory; protected $locks = []; protected $duplicates; + protected $exclude = []; public function __construct() { @@ -60,6 +62,13 @@ public function registerStores($stores) return $this; } + public function exclude(string $store) + { + $this->exclude[] = $store; + + return $this; + } + public function stores() { return $this->stores; @@ -88,7 +97,7 @@ public function generateId() public function clear() { - $this->stores()->reverse()->each->clear(); + $this->stores()->except($this->exclude)->reverse()->each->clear(); $this->duplicates()->clear(); @@ -110,7 +119,13 @@ public function warm() $this->startTimer(); - $this->stores()->each->warm(); + $stores = $this->stores()->except($this->exclude); + + if ($this->shouldUseParallelWarming($stores)) { + $this->warmInParallel($stores); + } else { + $stores->each->warm(); + } $this->stopTimer(); @@ -176,7 +191,7 @@ public function buildDate() return null; } - return Carbon::createFromTimestamp($cache['date']); + return Carbon::createFromTimestamp($cache['date'], config('app.timezone')); } public function disableUpdatingIndexes() @@ -224,4 +239,80 @@ public function isWatcherEnabled(): bool ? app()->isLocal() : (bool) $config; } + + protected function shouldUseParallelWarming($stores): bool + { + $config = config('statamic.stache.warming', []); + + if (! ($config['parallel_processing'] ?? false)) { + return false; + } + + if ($stores->count() < ($config['min_stores_for_parallel'] ?? 3)) { + return false; + } + + if ($this->getCpuCoreCount() < 2) { + return false; + } + + // Disable parallel processing if using Redis cache (serialization issues) + $cacheDriver = config('statamic.stache.cache_store', config('cache.default')); + if ($cacheDriver === 'redis') { + \Log::info('Parallel warming disabled due to Redis cache driver'); + + return false; + } + + return true; + } + + protected function warmInParallel($stores) + { + try { + $config = config('statamic.stache.warming', []); + $maxProcesses = $config['max_processes'] ?? 0; + + if ($maxProcesses <= 0) { + $maxProcesses = $this->getCpuCoreCount(); + } + + $maxProcesses = min($maxProcesses, $stores->count()); + + $chunkSize = (int) ceil($stores->count() / $maxProcesses); + $chunks = $stores->chunk($chunkSize); + + $closures = $chunks->map(function ($chunk) { + return function () use ($chunk) { + return $chunk->each->warm()->keys()->all(); + }; + })->all(); + + $driver = $config['concurrency_driver'] ?? 'process'; + + if (empty($closures)) { + \Log::info('Closures are empty, skipping parallel warming'); + } + + Concurrency::driver($driver)->run($closures); + } catch (\Exception $e) { + \Log::warning('Parallel warming failed, falling back to sequential: '.$e->getMessage()); + $stores->each->warm(); + } + } + + protected function getCpuCoreCount(): int + { + if (! function_exists('shell_exec')) { + return 1; + } + + $command = match (PHP_OS_FAMILY) { + 'Windows' => 'echo %NUMBER_OF_PROCESSORS%', + 'Darwin' => 'sysctl -n hw.ncpu 2>/dev/null || echo 1', + default => 'nproc 2>/dev/null || echo 1', + }; + + return max(1, (int) shell_exec($command)); + } } diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index 5c6450e575e..fd8de075c90 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -160,7 +160,7 @@ protected function storeIndexes() $indexes = collect([ 'slug', 'uri', - 'collection', + 'collectionHandle', 'published', 'title', 'site' => Indexes\Site::class, diff --git a/src/Stache/Stores/NavigationStore.php b/src/Stache/Stores/NavigationStore.php index fb6b75e1c44..5b974178701 100644 --- a/src/Stache/Stores/NavigationStore.php +++ b/src/Stache/Stores/NavigationStore.php @@ -36,6 +36,7 @@ public function makeItemFromFile($path, $contents) ->title($data['title'] ?? null) ->maxDepth($data['max_depth'] ?? null) ->collections($data['collections'] ?? null) + ->collectionsQueryScopes($data['collections_query_scopes'] ?? []) ->expectsRoot($data['root'] ?? false) ->canSelectAcrossSites($data['select_across_sites'] ?? false) ->initialPath($path); diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index a264b13e461..a6a144fcaa5 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -3,7 +3,6 @@ namespace Statamic\Stache\Stores; use Facades\Statamic\Stache\Traverser; -use Illuminate\Support\Facades\Cache; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\Facades\Stache; @@ -208,7 +207,11 @@ public function handleFileChanges() // Get all the deleted files. // This would be any paths that exist in the cached array that aren't there anymore. - $deleted = $existing->keys()->diff($files->keys())->values(); + $keys = $this->paths()->flip(); + $deleted = $existing->keys() + ->map(fn ($path) => $keys->get($path)) + ->diff($files->keys()->map(fn ($path) => $keys->get($path))) + ->values(); // If there are no modified or deleted files, there's nothing to update. if ($modified->isEmpty() && $deleted->isEmpty()) { diff --git a/src/Stache/Traverser.php b/src/Stache/Traverser.php index 84723f0534f..3e3e5bbeebe 100644 --- a/src/Stache/Traverser.php +++ b/src/Stache/Traverser.php @@ -2,19 +2,13 @@ namespace Statamic\Stache; -use Illuminate\Filesystem\Filesystem; use Statamic\Facades\Path; +use Symfony\Component\Finder\Finder; class Traverser { - protected $filesystem; protected $filter; - public function __construct(Filesystem $filesystem) - { - $this->filesystem = $filesystem; - } - public function traverse($store) { if (! $dir = $store->directory()) { @@ -23,20 +17,22 @@ public function traverse($store) $dir = rtrim($dir, '/'); - if (! $this->filesystem->exists($dir)) { + if (! file_exists($dir)) { return collect(); } - $files = collect($this->filesystem->allFiles($dir)); + $files = Finder::create()->files()->ignoreDotFiles(true)->in($dir)->sortByName(); + + $paths = []; + foreach ($files as $file) { + if ($this->filter && ! call_user_func($this->filter, $file)) { + continue; + } - if ($this->filter) { - $files = $files->filter($this->filter); + $paths[Path::tidy($file->getPathname())] = $file->getMTime(); } - return $files - ->mapWithKeys(function ($file) { - return [Path::tidy($file->getPathname()) => $file->getMTime()]; - })->sort(); + return collect($paths)->sort(); } public function filter($filter) diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index f3f635d4cb4..fd2bbf0d55e 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -153,7 +153,9 @@ protected function clearExportPath() return $this; } - $this->files->cleanDirectory($this->exportPath); + $this->preserveGitRepository(function () { + $this->files->cleanDirectory($this->exportPath); + }); return $this; } @@ -257,4 +259,20 @@ protected function exportPackage(): self return $this; } + + /** + * Prevent filesystem callback from affecting .git repository. + */ + protected function preserveGitRepository($callback): void + { + $this->files->makeDirectory(storage_path('statamic/tmp'), 0777, true, true); + + $this->files->moveDirectory($this->exportPath.'/.git', storage_path('statamic/tmp/.git')); + + $callback(); + + $this->files->moveDirectory(storage_path('statamic/tmp/.git'), $this->exportPath.'/.git'); + + $this->files->deleteDirectory(storage_path('statamic/tmp')); + } } diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 448b07d2c8f..8554fad327e 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -631,15 +631,15 @@ protected function restoreComposerJson(): self */ public function rollbackWithError(string $error, ?string $output = null): void { + if ($output) { + $this->console->line($this->tidyComposerErrorOutput($output)); + } + $this ->removeStarterKit() ->restoreComposerJson() ->removeComposerJsonBackup(); - if ($output) { - $this->console->line($this->tidyComposerErrorOutput($output)); - } - throw new StarterKitException($error); } diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index 4f81543fd72..22a7adf2163 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -66,7 +66,7 @@ public function getBaseUrl() */ public function getDefaultExpiration() { - return $this->config('expiry'); + return (int) $this->config('expiry'); } /** diff --git a/src/StaticCaching/Cachers/ApplicationCacher.php b/src/StaticCaching/Cachers/ApplicationCacher.php index a583f577702..92bae1e6747 100644 --- a/src/StaticCaching/Cachers/ApplicationCacher.php +++ b/src/StaticCaching/Cachers/ApplicationCacher.php @@ -117,12 +117,18 @@ public function flush() */ public function invalidateUrl($url, $domain = null) { + // For CLI contexts where Site::current()->url() may return the wrong + // domain causing getUrls() to look under the wrong cache key. + if ($domain === null) { + [$url, $domain] = $this->getPathAndDomain($url); + } + $this ->getUrls($domain) ->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?')) - ->each(function ($value, $key) { + ->each(function ($value, $key) use ($domain) { $this->cache->forget($this->normalizeKey('responses:'.$key)); - $this->forgetUrl($key); + $this->forgetUrl($key, $domain); }); UrlInvalidated::dispatch($url, $domain); diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index 0a0a594b81d..50170b5983f 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -5,9 +5,12 @@ use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Support\LazyCollection; use Statamic\Events\UrlInvalidated; use Statamic\Facades\File; +use Statamic\Facades\Path; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\StaticCaching\Page; use Statamic\StaticCaching\Replacers\CsrfTokenReplacer; use Statamic\Support\Arr; @@ -136,9 +139,41 @@ public function invalidateUrl($url, $domain = null) $this->forgetUrl($key, $domain); }); + $this->getFiles($site) + ->filter(fn ($file) => str_starts_with($file, $url.'_')) + ->each(function ($file, $path) { + $this->writer->delete($path); + }); + UrlInvalidated::dispatch($url, $domain); } + /** + * Get lazy collection file listing. + * + * @param Site $site + */ + private function getFiles($site): LazyCollection + { + $cachePath = $this->getCachePath($site); + if (! $cachePath || ! File::exists($cachePath)) { + return LazyCollection::make(); + } + + $directoryIterator = new \RecursiveDirectoryIterator($cachePath, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS); + $iterator = new \RecursiveIteratorIterator($directoryIterator); + + return LazyCollection::make(function () use ($iterator, $cachePath) { + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'html') { + continue; + } + + yield Path::tidy($file->getPathName()) => Str::start(Str::replaceFirst($cachePath, '', $file->getPathName()), '/'); + } + }); + } + public function getCachePaths() { $paths = $this->config('path'); @@ -183,7 +218,7 @@ public function getFilePath($url, $site = null) $basename = $slug.'_lqs_'.md5($query).'.html'; } - return $this->getCachePath($site).$pathParts['dirname'].'/'.$basename; + return Path::tidy($this->getCachePath($site).Str::finish($pathParts['dirname'], '/').$basename); } private function isBasenameTooLong($basename) @@ -204,17 +239,36 @@ public function setNocacheJs(string $js) public function getNocacheJs(): string { $csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT; + $nocacheUrl = URL::makeRelative(route('statamic.nocache')); $default = << response.json()) .then((data) => { + map = createMap(); // Recreate map in case the DOM changed. + const regions = data.regions; for (var key in regions) { - if (map[key]) map[key].outerHTML = regions[key]; + if (map[key]) replaceElement(map[key], regions[key]); } for (const input of document.querySelectorAll('input[value="$csrfPlaceholder"]')) { diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 00ffdce6511..fd0317cdf38 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -6,10 +6,14 @@ use Statamic\Contracts\Entries\Collection; use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Forms\Form; -use Statamic\Contracts\Globals\GlobalSet; +use Statamic\Contracts\Globals\Variables; use Statamic\Contracts\Structures\Nav; -use Statamic\Contracts\Taxonomies\Term; +use Statamic\Contracts\Structures\NavTree; +use Statamic\Facades\Site; +use Statamic\Structures\CollectionTree; use Statamic\Support\Arr; +use Statamic\Support\Str; +use Statamic\Taxonomies\LocalizedTerm; class DefaultInvalidator implements Invalidator { @@ -25,19 +29,25 @@ public function __construct(Cacher $cacher, $rules = []) public function invalidate($item) { if ($this->rules === 'all') { - return $this->cacher->flush(); + $this->cacher->flush(); + + return; } if ($item instanceof Entry) { $this->invalidateEntryUrls($item); - } elseif ($item instanceof Term) { + } elseif ($item instanceof LocalizedTerm) { $this->invalidateTermUrls($item); } elseif ($item instanceof Nav) { $this->invalidateNavUrls($item); - } elseif ($item instanceof GlobalSet) { + } elseif ($item instanceof NavTree) { + $this->invalidateNavTreeUrls($item); + } elseif ($item instanceof Variables) { $this->invalidateGlobalUrls($item); } elseif ($item instanceof Collection) { $this->invalidateCollectionUrls($item); + } elseif ($item instanceof CollectionTree) { + $this->invalidateCollectionTreeUrls($item); } elseif ($item instanceof Asset) { $this->invalidateAssetUrls($item); } elseif ($item instanceof Form) { @@ -47,80 +57,182 @@ public function invalidate($item) protected function invalidateFormUrls($form) { - $this->cacher->invalidateUrls( - Arr::get($this->rules, "forms.{$form->handle()}.urls") - ); + $rules = collect(Arr::get($this->rules, "forms.{$form->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { + return $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($site->url(), '/').Str::ensureLeft($rule, '/')); + })->flatten()->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } protected function invalidateAssetUrls($asset) { - $this->cacher->invalidateUrls( - Arr::get($this->rules, "assets.{$asset->container()->handle()}.urls") - ); + $rules = collect(Arr::get($this->rules, "assets.{$asset->container()->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { + return $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($site->url(), '/').Str::ensureLeft($rule, '/')); + })->flatten()->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } protected function invalidateEntryUrls($entry) { - $entry->descendants()->merge([$entry])->each(function ($entry) { - if (! $entry->isRedirect() && $url = $entry->absoluteUrl()) { - $this->cacher->invalidateUrl(...$this->splitUrlAndDomain($url)); - } - }); - - $this->cacher->invalidateUrls( - Arr::get($this->rules, "collections.{$entry->collectionHandle()}.urls") - ); + $rules = collect(Arr::get($this->rules, "collections.{$entry->collectionHandle()}.urls")); + + $urls = $entry->descendants() + ->merge([$entry]) + ->reject(fn ($entry) => $entry->isRedirect()) + ->map->absoluteUrl() + ->all(); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($entry->site()->url(), '/').Str::ensureLeft($rule, '/')) + ->all(); + + $this->cacher->invalidateUrls([ + ...$urls, + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } protected function invalidateTermUrls($term) { - if ($url = $term->absoluteUrl()) { - $this->cacher->invalidateUrl(...$this->splitUrlAndDomain($url)); + $rules = collect(Arr::get($this->rules, "taxonomies.{$term->taxonomyHandle()}.urls")); - $term->taxonomy()->collections()->each(function ($collection) use ($term) { - if ($url = $term->collection($collection)->absoluteUrl()) { - $this->cacher->invalidateUrl(...$this->splitUrlAndDomain($url)); - } - }); + if ($url = $term->absoluteUrl()) { + $urls = $term->taxonomy()->collections() + ->map(fn ($collection) => $term->collection($collection)->absoluteUrl()) + ->filter() + ->prepend($url) + ->all(); } - $this->cacher->invalidateUrls( - Arr::get($this->rules, "taxonomies.{$term->taxonomyHandle()}.urls") - ); + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($term->site()->url(), '/').Str::ensureLeft($rule, '/')) + ->all(); + + $this->cacher->invalidateUrls([ + ...$urls ?? [], + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } protected function invalidateNavUrls($nav) { - $this->cacher->invalidateUrls( - Arr::get($this->rules, "navigation.{$nav->handle()}.urls") - ); + $rules = collect(Arr::get($this->rules, "navigation.{$nav->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $nav->sites()->map(function ($site) use ($rules) { + return $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight(Site::get($site)->url(), '/').Str::ensureLeft($rule, '/')); + })->flatten()->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); + } + + protected function invalidateNavTreeUrls($tree) + { + $rules = collect(Arr::get($this->rules, "navigation.{$tree->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($tree->site()->url(), '/').Str::ensureLeft($rule, '/')) + ->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } - protected function invalidateGlobalUrls($set) + protected function invalidateGlobalUrls($variables) { - $this->cacher->invalidateUrls( - Arr::get($this->rules, "globals.{$set->handle()}.urls") - ); + $rules = collect(Arr::get($this->rules, "globals.{$variables->globalSet()->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($variables->site()->url(), '/').Str::ensureLeft($rule, '/')) + ->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } protected function invalidateCollectionUrls($collection) { - if ($url = $collection->absoluteUrl()) { - $this->cacher->invalidateUrl(...$this->splitUrlAndDomain($url)); - } + $rules = collect(Arr::get($this->rules, "collections.{$collection->handle()}.urls")); - $this->cacher->invalidateUrls( - Arr::get($this->rules, "collections.{$collection->handle()}.urls") - ); + $urls = $collection->sites()->map(fn ($site) => $collection->absoluteUrl($site))->filter()->all(); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); + + $prefixedRelativeUrls = $collection->sites()->map(function ($site) use ($rules) { + return $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight(Site::get($site)->url(), '/').Str::ensureLeft($rule, '/')); + })->flatten()->all(); + + $this->cacher->invalidateUrls([ + ...$urls, + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); } - private function splitUrlAndDomain(string $url) + protected function invalidateCollectionTreeUrls($tree) { - $parsed = parse_url($url); + $rules = collect(Arr::get($this->rules, "collections.{$tree->collection()->handle()}.urls")); + + $absoluteUrls = $rules->filter(fn (string $rule) => $this->isAbsoluteUrl($rule))->all(); - return [ - Arr::get($parsed, 'path', '/'), - $parsed['scheme'].'://'.$parsed['host'], - ]; + $prefixedRelativeUrls = $rules + ->reject(fn (string $rule) => $this->isAbsoluteUrl($rule)) + ->map(fn (string $rule) => Str::removeRight($tree->site()->url(), '/').Str::ensureLeft($rule, '/')) + ->all(); + + $this->cacher->invalidateUrls([ + ...$absoluteUrls, + ...$prefixedRelativeUrls, + ]); + } + + private function isAbsoluteUrl(string $url) + { + return isset(parse_url($url)['scheme']); } } diff --git a/src/StaticCaching/Invalidate.php b/src/StaticCaching/Invalidate.php index c12ae792181..b1fc01177e1 100644 --- a/src/StaticCaching/Invalidate.php +++ b/src/StaticCaching/Invalidate.php @@ -14,14 +14,14 @@ use Statamic\Events\EntryScheduleReached; use Statamic\Events\FormDeleted; use Statamic\Events\FormSaved; -use Statamic\Events\GlobalSetDeleted; -use Statamic\Events\GlobalSetSaved; +use Statamic\Events\GlobalVariablesDeleted; +use Statamic\Events\GlobalVariablesSaved; +use Statamic\Events\LocalizedTermDeleted; +use Statamic\Events\LocalizedTermSaved; use Statamic\Events\NavDeleted; use Statamic\Events\NavSaved; use Statamic\Events\NavTreeDeleted; use Statamic\Events\NavTreeSaved; -use Statamic\Events\TermDeleted; -use Statamic\Events\TermSaved; use Statamic\Facades\Form; class Invalidate implements ShouldQueue @@ -34,10 +34,10 @@ class Invalidate implements ShouldQueue EntrySaved::class => 'invalidateEntry', EntryDeleting::class => 'invalidateEntry', EntryScheduleReached::class => 'invalidateEntry', - TermSaved::class => 'invalidateTerm', - TermDeleted::class => 'invalidateTerm', - GlobalSetSaved::class => 'invalidateGlobalSet', - GlobalSetDeleted::class => 'invalidateGlobalSet', + LocalizedTermSaved::class => 'invalidateTerm', + LocalizedTermDeleted::class => 'invalidateTerm', + GlobalVariablesSaved::class => 'invalidateGlobalSet', + GlobalVariablesDeleted::class => 'invalidateGlobalSet', NavSaved::class => 'invalidateNav', NavDeleted::class => 'invalidateNav', FormSaved::class => 'invalidateForm', @@ -79,7 +79,7 @@ public function invalidateTerm($event) public function invalidateGlobalSet($event) { - $this->invalidator->invalidate($event->globals); + $this->invalidator->invalidate($event->variables); } public function invalidateNav($event) @@ -94,12 +94,12 @@ public function invalidateForm($event) public function invalidateCollectionByTree($event) { - $this->invalidator->invalidate($event->tree->collection()); + $this->invalidator->invalidate($event->tree); } public function invalidateNavByTree($event) { - $this->invalidator->invalidate($event->tree->structure()); + $this->invalidator->invalidate($event->tree); } public function invalidateByBlueprint($event) diff --git a/src/StaticCaching/Middleware/Cache.php b/src/StaticCaching/Middleware/Cache.php index 80f86e5ce17..f80342957cc 100644 --- a/src/StaticCaching/Middleware/Cache.php +++ b/src/StaticCaching/Middleware/Cache.php @@ -13,6 +13,7 @@ use Statamic\Facades\StaticCache; use Statamic\Statamic; use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\Cachers\AbstractCacher; use Statamic\StaticCaching\Cachers\ApplicationCacher; use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\Cachers\NullCacher; @@ -184,6 +185,7 @@ private function shouldBeCached($request, $response) $response->headers->has('X-Statamic-Draft') || $response->headers->has('X-Statamic-Private') || $response->headers->has('X-Statamic-Protected') + || $response->headers->has('X-Statamic-Uncacheable') ) { return false; } @@ -198,6 +200,10 @@ private function shouldBeCached($request, $response) return false; } + if ($this->cacher instanceof AbstractCacher && $this->cacher->isExcluded($this->cacher->getUrl($request))) { + return false; + } + return true; } diff --git a/src/StaticCaching/NoCache/Controller.php b/src/StaticCaching/NoCache/Controller.php index f3e4e51a952..65ab4f916c0 100644 --- a/src/StaticCaching/NoCache/Controller.php +++ b/src/StaticCaching/NoCache/Controller.php @@ -10,6 +10,8 @@ class Controller { public function __invoke(Request $request, Session $session) { + $request->validate(['url' => 'required|string']); + $url = $request->input('url'); if (config('statamic.static_caching.ignore_query_strings', false)) { diff --git a/src/StaticCaching/NoCache/DatabaseRegion.php b/src/StaticCaching/NoCache/DatabaseRegion.php index a5f0b50330a..7eac2120873 100644 --- a/src/StaticCaching/NoCache/DatabaseRegion.php +++ b/src/StaticCaching/NoCache/DatabaseRegion.php @@ -15,4 +15,9 @@ class DatabaseRegion extends Model protected $casts = [ 'key' => 'string', ]; + + public function getConnectionName() + { + return config('statamic.static_caching.nocache_db_connection') ?: parent::getConnectionName(); + } } diff --git a/src/StaticCaching/Replacers/CsrfTokenReplacer.php b/src/StaticCaching/Replacers/CsrfTokenReplacer.php index e7c0d562eff..d4c020ccc1d 100644 --- a/src/StaticCaching/Replacers/CsrfTokenReplacer.php +++ b/src/StaticCaching/Replacers/CsrfTokenReplacer.php @@ -4,6 +4,8 @@ use Illuminate\Http\Response; use Statamic\Facades\StaticCache; +use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\Replacer; use Statamic\Support\Str; @@ -32,6 +34,14 @@ public function prepareResponseToCache(Response $response, Response $initial) self::REPLACEMENT, $content )); + + if (app(Cacher::class) instanceof FileCacher) { + $initial->setContent(str_replace( + $token, + self::REPLACEMENT, + $initial->getContent() + )); + } } public function replaceInCachedResponse(Response $response) diff --git a/src/StaticCaching/Replacers/NoCacheReplacer.php b/src/StaticCaching/Replacers/NoCacheReplacer.php index 5faaecc1273..015be197d96 100644 --- a/src/StaticCaching/Replacers/NoCacheReplacer.php +++ b/src/StaticCaching/Replacers/NoCacheReplacer.php @@ -23,7 +23,12 @@ public function __construct(Session $session) public function prepareResponseToCache(Response $responseToBeCached, Response $initialResponse) { - $this->replaceInResponse($initialResponse); + if (app(Cacher::class) instanceof FileCacher) { + $this->includeJs($initialResponse); + $this->modifyFullMeasureResponse($initialResponse); + } else { + $this->replaceInResponse($initialResponse); + } $this->modifyFullMeasureResponse($responseToBeCached); } @@ -39,17 +44,28 @@ private function replaceInResponse(Response $response) return; } - if (preg_match(self::PATTERN, $content)) { - $this->session->restore(); + $this->includeJs($response); - StaticCache::includeJs(); + $response->setContent($this->replace($content)); + } + + private function includeJs(Response $response) + { + if (! $content = $response->getContent()) { + return; } - $response->setContent($this->replace($content)); + if (preg_match(self::PATTERN, $content)) { + StaticCache::includeJs(); + } } public function replace(string $content) { + if (preg_match(self::PATTERN, $content)) { + $this->session->restore(); + } + while (preg_match(self::PATTERN, $content)) { $content = $this->performReplacement($content); } @@ -104,7 +120,7 @@ private function insertJsInHead($contents, $cacher) Str::position($contents, ''), ])->filter()->min(); - $js = ""; + $js = ""; return Str::substrReplace($contents, $js, $insertBefore, 0); } @@ -113,6 +129,6 @@ private function insertJsInBody($contents, $cacher) { $js = $cacher->getNocacheJs(); - return str_replace('', '', $contents); + return str_replace('', '', $contents); } } diff --git a/src/StaticCaching/ServiceProvider.php b/src/StaticCaching/ServiceProvider.php index 5a025affe42..3cb2726cc17 100644 --- a/src/StaticCaching/ServiceProvider.php +++ b/src/StaticCaching/ServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; +use Illuminate\Support\Str; use Statamic\Facades\Cascade; use Statamic\StaticCaching\NoCache\DatabaseSession; use Statamic\StaticCaching\NoCache\Session; @@ -42,6 +43,10 @@ public function register() $uri = explode('?', $uri)[0]; } + if (Str::contains($uri, '?')) { + $uri = Str::before($uri, '?').'?'.Request::normalizeQueryString(Str::after($uri, '?')); + } + return match ($driver = config('statamic.static_caching.nocache', 'cache')) { 'cache' => new Session($uri), 'database' => new DatabaseSession($uri), diff --git a/src/Structures/CollectionStructure.php b/src/Structures/CollectionStructure.php index 3edb98f43a6..1cc03b7adb5 100644 --- a/src/Structures/CollectionStructure.php +++ b/src/Structures/CollectionStructure.php @@ -72,9 +72,8 @@ public function validateTree(array $tree, string $locale): array throw new \Exception("Duplicate entry [{$entryId}] in [{$this->collection()->handle()}] collection's structure."); } - $thisCollectionsEntries = $this->collection()->queryEntries() - ->where('site', $locale) - ->pluck('id'); + $thisCollectionsEntries = Blink::once('collection-structure-tree-entries::'.$this->handle().'::'.$locale, fn () => $this->collection()->queryEntries()) + ->where('site', $locale)->pluck('id'); $otherCollectionEntries = $entryIds->diff($thisCollectionsEntries); diff --git a/src/Structures/Nav.php b/src/Structures/Nav.php index 88d8b1e9258..e6699a8ec21 100644 --- a/src/Structures/Nav.php +++ b/src/Structures/Nav.php @@ -19,6 +19,7 @@ use Statamic\Facades\Collection; use Statamic\Facades\Site; use Statamic\Facades\Stache; +use Statamic\Support\Str; class Nav extends Structure implements Contract { @@ -26,6 +27,7 @@ class Nav extends Structure implements Contract protected $collections; protected $canSelectAcrossSites = false; + protected $collectionsQueryScopes = []; private $blueprintCache; public function save() @@ -77,6 +79,7 @@ public function fileData() return [ 'title' => $this->title, 'collections' => $this->collections, + 'collections_query_scopes' => empty($this->collectionsQueryScopes) ? null : $this->collectionsQueryScopes, 'select_across_sites' => $this->canSelectAcrossSites ? true : null, 'max_depth' => $this->maxDepth, 'root' => $this->expectsRoot ?: null, @@ -165,4 +168,23 @@ public function canSelectAcrossSites($canSelect = null) ->fluentlyGetOrSet('canSelectAcrossSites') ->args(func_get_args()); } + + public function collectionsQueryScopes($scopes = null) + { + return $this + ->fluentlyGetOrSet('collectionsQueryScopes') + ->setter(function ($scopes) { + if (empty($scopes)) { + return []; + } + + return collect($scopes) + ->filter() + ->map(fn ($scope) => Str::snake($scope)) + ->unique() + ->values() + ->all(); + }) + ->args(func_get_args()); + } } diff --git a/src/Structures/Page.php b/src/Structures/Page.php index afb23c50aca..20653ce0a61 100644 --- a/src/Structures/Page.php +++ b/src/Structures/Page.php @@ -476,6 +476,13 @@ public function collection() return Collection::findByMount($this); } + public function mountedCollection() + { + return ($entry = $this->entry()) + ? Collection::findByMount($entry) + : null; + } + public function getProtectionScheme() { return optional($this->entry())->getProtectionScheme(); diff --git a/src/Structures/Tree.php b/src/Structures/Tree.php index 1caff9d5d4d..1cbd7e1270e 100644 --- a/src/Structures/Tree.php +++ b/src/Structures/Tree.php @@ -310,7 +310,7 @@ public function move($entry, $target) { $parent = optional($this->find($entry)->parent()); - if ($parent->id() === $target || $parent->isRoot() && is_null($target)) { + if ($parent->id() == $target || $parent->isRoot() && is_null($target)) { return $this; } diff --git a/src/Structures/TreeBuilder.php b/src/Structures/TreeBuilder.php index 7efa706c6ec..c6ebac6b2a8 100644 --- a/src/Structures/TreeBuilder.php +++ b/src/Structures/TreeBuilder.php @@ -31,7 +31,13 @@ public function build($params) $tree->withEntries(); - $entry = ($from && $from !== '/') ? Entry::findByUri(Str::start($from, '/'), $params['site']) : null; + $entry = null; + + if ($from && $from !== '/') { + if (! $entry = Entry::findByUri(Str::start($from, '/'), $params['site'])) { + return []; + } + } if ($entry) { $page = $tree->find($entry->id()); @@ -83,7 +89,7 @@ protected function transformTreeForController($tree) { return collect($tree)->map(function ($item) { $page = $item['page']; - $collection = $page->collection(); + $collection = $page->mountedCollection(); $referenceExists = $page->referenceExists(); return [ @@ -95,7 +101,7 @@ protected function transformTreeForController($tree) 'handle' => $page->entry()->blueprint()->handle(), 'title' => $page->entry()->blueprint()->title(), ] : null, - 'url' => $referenceExists ? $page->url() : null, + 'url' => $page->url(), 'edit_url' => $page->editUrl(), 'can_delete' => $referenceExists ? User::current()->can('delete', $page->entry()) : true, 'slug' => $page->slug(), diff --git a/src/Support/FileCollection.php b/src/Support/FileCollection.php index 3ed53e8de90..4aebd51509a 100644 --- a/src/Support/FileCollection.php +++ b/src/Support/FileCollection.php @@ -212,7 +212,7 @@ public function toArray() 'size_mb' => $kb, 'size_gb' => $kb, 'is_file' => File::isImage($path), - 'last_modified' => Carbon::createFromTimestamp(File::lastModified($path)), + 'last_modified' => Carbon::createFromTimestamp(File::lastModified($path), config('app.timezone')), ]; } diff --git a/src/Support/Str.php b/src/Support/Str.php index 1367265d1e2..14c4852061f 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -298,6 +298,16 @@ public static function ensureRight($string, $cap) return IlluminateStr::finish($string, $cap); } + public static function toBase64Url($url): string + { + return rtrim(strtr(base64_encode($url), '+/', '-_'), '='); + } + + public static function fromBase64Url($url, $strict = false) + { + return base64_decode(strtr($url, '-_', '+/'), $strict); + } + /** * Implicitly defer all other method calls to either \Stringy\StaticStringy or \Illuminate\Support\Str. * diff --git a/src/Tags/Assets.php b/src/Tags/Assets.php index 9ef77dcc8bc..5789415d394 100644 --- a/src/Tags/Assets.php +++ b/src/Tags/Assets.php @@ -159,6 +159,22 @@ protected function filterByType($value) }); } + /** + * Filter out assets from a requested folder. + * + * @return void + */ + private function filterNotIn() + { + if ($not_in = $this->params->get('not_in')) { + $regex = '#^('.$not_in.')#'; + + $this->assets = $this->assets->reject(function ($path) use ($regex) { + return preg_match($regex, $path); + }); + } + } + /** * Perform the asset lookups. * @@ -193,6 +209,8 @@ protected function assets($urls) private function output() { + $this->filterNotIn(); + $this->sort(); $this->limit(); diff --git a/src/Tags/Cache.php b/src/Tags/Cache.php index c1076bba57e..06043ccc912 100644 --- a/src/Tags/Cache.php +++ b/src/Tags/Cache.php @@ -14,7 +14,7 @@ class Cache extends Tags implements CachesOutput public function index() { if (! $this->isEnabled()) { - return []; + return $this->parse([]); } $store = LaraCache::store($this->params->get('store')); @@ -64,8 +64,11 @@ private function isEnabled() return false; } - // Only GET requests. This disables the cache during live preview. - return request()->method() === 'GET'; + if (request()->isLivePreview()) { + return false; + } + + return true; } private function getCacheKey() diff --git a/src/Tags/Children.php b/src/Tags/Children.php index 40d1fd6b4ca..bbcdc060933 100644 --- a/src/Tags/Children.php +++ b/src/Tags/Children.php @@ -11,13 +11,15 @@ class Children extends Structure /** * The {{ children }} tag. * - * Get any children of the current url + * Get any children of the current or a specified url. * * @return string */ public function index() { - $this->params->put('from', Str::start(Str::after(URL::makeAbsolute(URL::getCurrent()), Site::current()->absoluteUrl()), '/')); + $url = $this->params->get('of', URL::getCurrent()); + + $this->params->put('from', Str::start(Str::after(URL::makeAbsolute($url), Site::current()->absoluteUrl()), '/')); $this->params->put('max_depth', 1); $collection = $this->params->get('collection', $this->context->value('collection')?->handle()); diff --git a/src/Tags/Concerns/GetsQueryResults.php b/src/Tags/Concerns/GetsQueryResults.php index 766b8b22902..0e67046dc63 100644 --- a/src/Tags/Concerns/GetsQueryResults.php +++ b/src/Tags/Concerns/GetsQueryResults.php @@ -44,7 +44,7 @@ protected function allowLegacyStylePaginationLimiting() protected function preventIncompatiblePaginationParameters() { - if ($this->params->int('paginate') && $this->params->has('limit')) { + if ($this->params->int('paginate') && $this->params->int('limit')) { throw new \Exception('Cannot use [paginate] integer in combination with [limit] param.'); } diff --git a/src/Tags/Concerns/QueriesConditions.php b/src/Tags/Concerns/QueriesConditions.php index 201aaa28415..36190a26874 100644 --- a/src/Tags/Concerns/QueriesConditions.php +++ b/src/Tags/Concerns/QueriesConditions.php @@ -123,6 +123,10 @@ protected function queryCondition($query, $field, $condition, $value) return $this->queryIsBeforeCondition($query, $field, $value); case 'is_numberwang': return $this->queryIsNumberwangCondition($query, $field, $regexOperator); + case 'overlaps': + return $this->queryOverlapsCondition($query, $field, $value); + case 'doesnt_overlap': + return $this->queryDoesntOverlapCondition($query, $field, $value); } } @@ -305,6 +309,24 @@ protected function queryIsNumberwangCondition($query, $field, $regexOperator) return $query->where($field, $regexOperator, "^(1|22|7|9|1002|2\.3|15|109876567|31)$"); } + protected function queryOverlapsCondition($query, $field, $value) + { + if (is_string($value)) { + $value = $this->getPipedValues($value); + } + + return $query->whereJsonOverlaps($field, $value); + } + + protected function queryDoesntOverlapCondition($query, $field, $value) + { + if (is_string($value)) { + $value = $this->getPipedValues($value); + } + + return $query->whereJsonDoesntOverlap($field, $value); + } + /** * This is for backwards compatibility, because v2's regex conditions required delimiters. * Passing delimiters doesn't work with Eloquent and `regexp`, so we remove them from diff --git a/src/Tags/Concerns/RendersForms.php b/src/Tags/Concerns/RendersForms.php index cedf943d6f9..b53e8d59ef6 100644 --- a/src/Tags/Concerns/RendersForms.php +++ b/src/Tags/Concerns/RendersForms.php @@ -104,7 +104,7 @@ protected function formMetaFields($meta) { return collect($meta) ->map(function ($value, $key) { - return sprintf('', $key, $value); + return sprintf('', $key, e($value)); }) ->implode("\n"); } @@ -141,7 +141,9 @@ protected function getRenderableField($field, $errorBag = 'default', $manipulate ->map->get('default') ->filter()->all(); + $formHandle = $field->form()?->handle() ?? Str::slug($errorBag); $data = array_merge($configDefaults, $field->toArray(), [ + 'id' => $this->generateFieldId($field->handle(), $formHandle), 'instructions' => $field->instructions(), 'error' => $errors->first($field->handle()) ?: null, 'default' => $field->value() ?? $field->defaultValue(), @@ -174,4 +176,12 @@ protected function minifyFieldHtml($html) return $html; } + + /** + * Generate a field id to associate input with label. + */ + private function generateFieldId(string $fieldHandle, ?string $formName = null): string + { + return ($formName ?? 'default').'-form-'.$fieldHandle.'-field'; + } } diff --git a/src/Tags/GetContent.php b/src/Tags/GetContent.php index b5c4a37ef59..478390f7ba5 100644 --- a/src/Tags/GetContent.php +++ b/src/Tags/GetContent.php @@ -5,6 +5,7 @@ use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\Facades\Entry; use Statamic\Facades\Site; +use Statamic\Query\OrderedQueryBuilder; use Statamic\Support\Str; use Statamic\Tags\Concerns\OutputsItems; @@ -58,6 +59,11 @@ private function entries($items) ->where('site', $this->params->get(['site', 'locale'], Site::current()->handle())) ->whereIn($usingUris ? 'uri' : 'id', $items); + // Ensure correct order of results + if (! $usingUris) { + $query = new OrderedQueryBuilder($query, $items); + } + return $this->output($query->get()); } } diff --git a/src/Tags/Glide.php b/src/Tags/Glide.php index f34deb64d53..33276631126 100644 --- a/src/Tags/Glide.php +++ b/src/Tags/Glide.php @@ -279,6 +279,10 @@ private function normalizeItem($item) return $item; } + if (Str::startsWith($item, config('app.url'))) { + $item = Str::after($item, config('app.url')); + } + // External URLs are already fine as-is. if (Str::startsWith($item, ['http://', 'https://'])) { return $item; diff --git a/src/Tags/Increment.php b/src/Tags/Increment.php index 296eae18f17..65414102902 100644 --- a/src/Tags/Increment.php +++ b/src/Tags/Increment.php @@ -36,12 +36,24 @@ public function reset() return ''; } - public function wildcard($tag) + public function index() { - if (! isset(self::$arr[$tag])) { - return self::$arr[$tag] = $this->params->get('from', 0); + $counter = $this->params->get('counter', null); + + return $this->increment($counter); + } + + public function wildcard($counter) + { + return $this->increment($counter); + } + + protected function increment($counter) + { + if (! isset(self::$arr[$counter])) { + return self::$arr[$counter] = $this->params->get('from', 0); } - return self::$arr[$tag] = self::$arr[$tag] + $this->params->get('by', 1); + return self::$arr[$counter] = self::$arr[$counter] + $this->params->get('by', 1); } } diff --git a/src/Tags/ParentTags.php b/src/Tags/ParentTags.php index 6e0586f7516..3d38ce29747 100644 --- a/src/Tags/ParentTags.php +++ b/src/Tags/ParentTags.php @@ -26,7 +26,7 @@ public function wildcard($method) { $var_name = Stringy::removeLeft($this->tag, 'parent:'); - return Arr::get($this->getParent(), $var_name)->value(); + return Arr::get($this->getParent(), $var_name)?->value(); } /** @@ -60,10 +60,8 @@ private function getParentUrl() /** * Get the parent data. - * - * @return string */ - private function getParent() + private function getParent(): ?array { $segments = explode('/', Str::start(Str::after(URL::getCurrent(), Site::current()->url()), '/')); $segment_count = count($segments); diff --git a/src/Tags/Structure.php b/src/Tags/Structure.php index 3bf57d1082a..fb29c72066f 100644 --- a/src/Tags/Structure.php +++ b/src/Tags/Structure.php @@ -64,7 +64,13 @@ protected function structure($handle) 'max_depth' => $this->params->get('max_depth'), ]); - return $this->toArray($tree); + $value = $this->toArray($tree); + + if ($this->parser && ($as = $this->params->get('as'))) { + return [$as => $value]; + } + + return $value; } protected function ensureStructureExists(string $handle): void @@ -130,7 +136,6 @@ public function toArray($tree, $parent = null, $depth = 1) return array_merge($data, [ 'children' => $children, - 'parent' => $parent, 'depth' => $depth, 'index' => $index, 'count' => $index + 1, @@ -139,7 +144,7 @@ public function toArray($tree, $parent = null, $depth = 1) 'is_current' => ! is_null($url) && rtrim($url, '/') === rtrim($this->currentUrl, '/'), 'is_parent' => ! is_null($url) && $this->siteAbsoluteUrl !== $absoluteUrl && URL::isAncestorOf($this->currentUrl, $url), 'is_external' => URL::isExternal((string) $absoluteUrl), - ]); + ], $this->params->bool('include_parents', true) ? ['parent' => $parent] : []); })->filter()->values(); $this->updateIsParent($pages); diff --git a/src/Tags/Trans.php b/src/Tags/Trans.php index 9e560bbe6d0..0738e7b2063 100644 --- a/src/Tags/Trans.php +++ b/src/Tags/Trans.php @@ -13,8 +13,14 @@ public function wildcard($tag) { $key = $this->params->get('key', $tag); $locale = $this->params->pull('locale') ?? $this->params->pull('site'); + $fallback = $this->params->get('fallback'); $params = $this->params->all(); - return __($key, $params, $locale); + $translation = __($key, $params, $locale); + if ($fallback && $translation === $key) { + return __($fallback, $params, $locale); + } else { + return $translation; + } } } diff --git a/src/Taxonomies/LocalizedTerm.php b/src/Taxonomies/LocalizedTerm.php index 86e5488372f..b6eb3137320 100644 --- a/src/Taxonomies/LocalizedTerm.php +++ b/src/Taxonomies/LocalizedTerm.php @@ -52,6 +52,12 @@ public function __construct($term, $locale) $this->supplements = collect(); } + public function __clone() + { + $this->term = clone $this->term; + $this->supplements = clone $this->supplements; + } + public function get($key, $fallback = null) { return $this->data()->get($key, $fallback); @@ -375,6 +381,10 @@ public function toResponse($request) throw new NotFoundHttpException; } + if ($this->collection() && ! $this->taxonomy()->collections()->contains($this->collection())) { + throw new NotFoundHttpException; + } + return (new DataResponse($this))->toResponse($request); } @@ -481,7 +491,7 @@ protected function defaultAugmentedRelations() public function lastModified() { return $this->has('updated_at') - ? Carbon::createFromTimestamp($this->get('updated_at')) + ? Carbon::createFromTimestamp($this->get('updated_at'), config('app.timezone')) : $this->term->fileLastModified(); } diff --git a/src/Taxonomies/Taxonomy.php b/src/Taxonomies/Taxonomy.php index b2bfd4e76e2..ae1a078698a 100644 --- a/src/Taxonomies/Taxonomy.php +++ b/src/Taxonomies/Taxonomy.php @@ -31,6 +31,8 @@ use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; +use function Statamic\trans as __; + class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, Contract, Responsable { use ContainsCascadingData, ContainsSupplementalData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData; @@ -81,6 +83,14 @@ public function showUrl() return cp_route('taxonomies.show', $this->handle()); } + public function breadcrumbUrl() + { + $referer = request()->header('referer'); + $showUrl = $this->showUrl(); + + return $referer && Str::before($referer, '?') === $showUrl ? $referer : $showUrl; + } + public function editUrl() { return cp_route('taxonomies.edit', $this->handle()); @@ -381,6 +391,10 @@ public function toResponse($request) throw new NotFoundHttpException; } + if ($this->collection() && ! $this->collections()->contains($this->collection())) { + throw new NotFoundHttpException; + } + return (new \Statamic\Http\Responses\DataResponse($this)) ->with([ 'terms' => $termQuery = $this->queryTerms()->where('site', $site), diff --git a/src/Taxonomies/Term.php b/src/Taxonomies/Term.php index 667b9f466e9..45438958ac1 100644 --- a/src/Taxonomies/Term.php +++ b/src/Taxonomies/Term.php @@ -37,6 +37,14 @@ public function __construct() $this->data = collect(); } + public function __clone() + { + $this->data = clone $this->data; + $this->data->transform(function ($data) { + return clone $data; + }); + } + public function id() { return $this->taxonomyHandle().'::'.$this->slug(); diff --git a/src/Testing/AddonTestCase.php b/src/Testing/AddonTestCase.php index 890960fe67d..7d7d3faf185 100644 --- a/src/Testing/AddonTestCase.php +++ b/src/Testing/AddonTestCase.php @@ -27,7 +27,7 @@ protected function setUp(): void if (isset($uses[PreventsSavingStacheItemsToDisk::class])) { $reflection = new ReflectionClass($this); - $this->fakeStacheDirectory = Str::before(dirname($reflection->getFileName()), '/tests').'/tests/__fixtures__/dev-null'; + $this->fakeStacheDirectory = Str::before(dirname($reflection->getFileName()), DIRECTORY_SEPARATOR.'tests').'/tests/__fixtures__/dev-null'; $this->preventSavingStacheItemsToDisk(); } @@ -36,7 +36,8 @@ protected function setUp(): void $this->addToAssertionCount(-1); \Statamic\Facades\CP\Nav::shouldReceive('build')->zeroOrMoreTimes()->andReturn(collect()); - $this->addToAssertionCount(-1); // Dont want to assert this + \Statamic\Facades\CP\Nav::shouldReceive('clearCachedUrls')->zeroOrMoreTimes(); + $this->addToAssertionCount(-2); // Dont want to assert this } protected function tearDown(): void diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php index 95d0d36357d..94a9caee9d9 100644 --- a/src/Tokens/FileTokenRepository.php +++ b/src/Tokens/FileTokenRepository.php @@ -55,7 +55,7 @@ private function makeFromPath(string $path): FileToken return $this ->make($token, $yaml['handler'], $yaml['data'] ?? []) - ->expireAt(Carbon::createFromTimestamp($yaml['expires_at'])); + ->expireAt(Carbon::createFromTimestamp($yaml['expires_at'], config('app.timezone'))); } public static function bindings(): array diff --git a/src/Tokens/Handlers/LivePreview.php b/src/Tokens/Handlers/LivePreview.php index 67a07083cbe..1a36f3bdd33 100644 --- a/src/Tokens/Handlers/LivePreview.php +++ b/src/Tokens/Handlers/LivePreview.php @@ -4,7 +4,10 @@ use Closure; use Facades\Statamic\CP\LivePreview as Facade; +use Illuminate\Support\Collection; use Statamic\Contracts\Tokens\Token; +use Statamic\Facades\Site as Sites; +use Statamic\Sites\Site; class LivePreview { @@ -12,12 +15,36 @@ public function handle(Token $token, $request, Closure $next) { $item = Facade::item($token); + if (! $item) { + return $next($request); + } + $item->repository()->substitute($item); $response = $next($request); + if (Sites::multiEnabled()) { + /** @var Collection */ + $siteURLs = Sites::all() + ->map(fn (Site $site) => $this->getSchemeAndHost($site)) + ->values() + ->unique() + ->join(' '); + + $response->headers->set('Content-Security-Policy', "frame-ancestors $siteURLs"); + } + $response->headers->set('X-Statamic-Live-Preview', true); return $response; } + + private function getSchemeAndHost(Site $site): string + { + $parts = parse_url($site->absoluteUrl()); + + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return $parts['scheme'].'://'.$parts['host'].$port; + } } diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index b0fc4f9c43a..b1dc611cfbc 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -5,6 +5,7 @@ use Closure; use Statamic\Contracts\View\Antlers\Parser; use Statamic\View\Antlers\Language\Parser\IdentifierFinder; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; class Antlers { @@ -31,6 +32,18 @@ public function parse($str, $variables = []) return $this->parser()->parse($str, $variables); } + public function parseUserContent($str, $variables = []) + { + $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = true; + + try { + return $this->parser()->parse($str, $variables); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; + } + } + /** * Iterate over an array and parse the string/template for each. * diff --git a/src/View/Antlers/Language/Errors/AntlersErrorCodes.php b/src/View/Antlers/Language/Errors/AntlersErrorCodes.php index 20738583344..2af8bc206c6 100644 --- a/src/View/Antlers/Language/Errors/AntlersErrorCodes.php +++ b/src/View/Antlers/Language/Errors/AntlersErrorCodes.php @@ -131,4 +131,5 @@ class AntlersErrorCodes const TYPE_UNPAIRED_CLOSING_TAG = 'ANTLR_131'; const TYPE_UNEXPECTED_CHARACTER_WHILE_PARSING_SHORTHAND_PARAMETER = 'ANTLR_132'; const TYPE_UNEXPECTED_EOI_PARSING_SHORTHAND_PARAMETER = 'ANTLR_133'; + const RUNTIME_METHOD_CALL_USER_CONTENT = 'ANTLR_135'; } diff --git a/src/View/Antlers/Language/Nodes/AntlersNode.php b/src/View/Antlers/Language/Nodes/AntlersNode.php index 66f8ce3bfb9..74a2dea7336 100644 --- a/src/View/Antlers/Language/Nodes/AntlersNode.php +++ b/src/View/Antlers/Language/Nodes/AntlersNode.php @@ -312,6 +312,8 @@ public function copyBasicDetailsTo($instance) $instance->rawEnd = $this->rawEnd; $instance->startPosition = $this->startPosition; $instance->endPosition = $this->endPosition; + $instance->interpolationRegions = $this->interpolationRegions; + $instance->processedInterpolationRegions = $this->processedInterpolationRegions; return $instance; } diff --git a/src/View/Antlers/Language/Parser/PathParser.php b/src/View/Antlers/Language/Parser/PathParser.php index b797049bc87..10e7a0ab411 100644 --- a/src/View/Antlers/Language/Parser/PathParser.php +++ b/src/View/Antlers/Language/Parser/PathParser.php @@ -211,7 +211,15 @@ public function parse($content) } } - if ($this->isParsingString == false && $this->cur == self::LeftBracket) { + if ( + $this->isParsingString == false && $this->cur == self::LeftBracket && + ( + ctype_alnum($this->prev) || + $this->prev == DocumentParser::LeftBrace || + $this->prev == DocumentParser::RightBracket || + $this->prev == DocumentParser::Punctuation_FullStop + ) + ) { if (! empty($currentChars)) { $pathNode = new PathNode(); $pathNode->name = implode($currentChars); diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index 9b470bf4ed2..a0d2b481508 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -184,6 +184,13 @@ public static function mergeTagRuntimeAssignments($assignments) */ public static $allowPhpInContent = false; + /** + * Controls if method invocations are evaluated in user content. + * + * @var bool + */ + public static $allowMethodsInContent = false; + /** * Maintains a list of all field prefixes that have been encountered. * @@ -216,6 +223,7 @@ public static function mergeTagRuntimeAssignments($assignments) public static function resetGlobalState() { + self::$templateFileStack = []; self::$shareVariablesTemplateTrigger = ''; self::$layoutVariables = []; self::$containsLayout = false; @@ -225,10 +233,14 @@ public static function resetGlobalState() self::$yieldCount = 0; self::$yieldStacks = []; self::$abandonedNodes = []; + self::$isEvaluatingUserData = false; + self::$isEvaluatingData = false; + self::$userContentEvalState = null; StackReplacementManager::clearStackState(); LiteralReplacementManager::resetLiteralState(); RecursiveNodeManager::resetRecursiveNodeState(); + RuntimeParser::clearRenderNodeCache(); } public static function createIndicatorVariable($indicator) diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index b2be4c19dc6..4450d838d32 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -1218,7 +1218,9 @@ public function reduce($processNodes) 'User content Antlers PHP tag.' ); } else { - Log::warning('PHP Node evaluated in user content: '.$node->name->name, [ + $logContent = $node->rawStart.$node->innerContent().$node->rawEnd; + + Log::warning('PHP Node evaluated in user content: '.$logContent, [ 'file' => GlobalRuntimeState::$currentExecutionFile, 'trace' => GlobalRuntimeState::$templateFileStack, 'content' => $node->innerContent(), @@ -2164,6 +2166,8 @@ public function reduce($processNodes) if ($val instanceof Value) { if ($val->shouldParseAntlers()) { + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; GlobalRuntimeState::$userContentEvalState = [ @@ -2171,14 +2175,18 @@ public function reduce($processNodes) $node, ]; - $val = $val->antlersValue($this->antlersParser, $this->getActiveData()); - GlobalRuntimeState::$userContentEvalState = null; - GlobalRuntimeState::$isEvaluatingUserData = false; - GlobalRuntimeState::$isEvaluatingData = false; + try { + $val = $val->antlersValue($this->antlersParser, $this->getActiveData()); + } finally { + GlobalRuntimeState::$userContentEvalState = null; + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; + } } else { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $val = $val->value(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; } } @@ -2456,7 +2464,7 @@ protected function evaluatePhp($buffer) protected function evaluateAntlersPhpNode(PhpExecutionNode $node) { - if (! GlobalRuntimeState::$allowPhpInContent == false && GlobalRuntimeState::$isEvaluatingUserData) { + if (! GlobalRuntimeState::$allowPhpInContent && GlobalRuntimeState::$isEvaluatingUserData) { return StringUtilities::sanitizePhp($node->content); } @@ -2550,6 +2558,7 @@ protected function addLoopIterationVariables($loop) $value['count'] = $index + 1; $value['index'] = $index; $value['total_results'] = $total; + $value['no_results'] = false; $value['first'] = $index === 0; $value['last'] = $index === $lastIndex; @@ -2627,9 +2636,13 @@ protected function runModifier($modifier, $parameters, $data, $context = []) return $data->value(); } + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = true; - $value = $data->antlersValue($this->antlersParser, $context); - GlobalRuntimeState::$isEvaluatingUserData = false; + try { + $value = $data->antlersValue($this->antlersParser, $context); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; + } try { return Modify::value($value)->context($context)->$modifier($parameters)->fetch(); diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index cec692806ea..19e34520c7f 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -983,12 +983,14 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta $reductionValue = array_pop($reductionStack); if ($reductionValue instanceof Value) { + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; $augmented = RuntimeValues::getValue($reductionValue); $augmented = self::guardRuntimeReturnValue($augmented); - GlobalRuntimeState::$isEvaluatingUserData = false; - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; if (! $isPair) { return $augmented; @@ -998,32 +1000,37 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta continue; } elseif ($reductionValue instanceof Values) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = $reductionValue->toArray(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof \Statamic\Entries\Collection) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = RuntimeValues::resolveWithRuntimeIsolation($reductionValue); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof ArrayableString) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = $reductionValue->toArray(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof Augmentable) { // Avoids resolving augmented data "too early". if ($reduceBuildersAndAugmentables) { + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; $augmented = RuntimeValues::resolveWithRuntimeIsolation($reductionValue); $augmented = self::guardRuntimeReturnValue($augmented); - GlobalRuntimeState::$isEvaluatingUserData = false; - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; $reductionStack[] = $augmented; } else { return $reductionValue; @@ -1031,12 +1038,14 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta continue; } elseif ($reductionValue instanceof Collection) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = $reductionValue->all(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof Model) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $data = $reductionValue->toArray(); @@ -1053,19 +1062,21 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta } $reductionStack[] = $data; - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof Arrayable) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = $reductionValue->toArray(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } elseif ($reductionValue instanceof Builder && $reduceBuildersAndAugmentables) { + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingData = true; $reductionStack[] = $reductionValue->get(); - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; continue; } @@ -1087,6 +1098,8 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta */ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = true) { + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $prevIsEvaluatingData = GlobalRuntimeState::$isEvaluatingData; GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; @@ -1099,20 +1112,14 @@ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = } if ($value instanceof Value) { - GlobalRuntimeState::$isEvaluatingUserData = true; - if (! $isPair) { $returnValue = $value->antlersValue($parser, $data); } else { $returnValue = self::reduce($value->antlersValue($parser, $data)); } $returnValue = self::guardRuntimeReturnValue($returnValue); - - GlobalRuntimeState::$isEvaluatingUserData = false; } elseif ($value instanceof Values) { - GlobalRuntimeState::$isEvaluatingUserData = true; $returnValue = $value->toArray(); - GlobalRuntimeState::$isEvaluatingUserData = false; } else { if (! $isPair) { if (is_array($value)) { @@ -1127,8 +1134,8 @@ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = } } - GlobalRuntimeState::$isEvaluatingUserData = false; - GlobalRuntimeState::$isEvaluatingData = false; + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; return $returnValue; } diff --git a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php index b89a48e4c85..548be3452f0 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php +++ b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php @@ -109,6 +109,16 @@ class RuntimeConfiguration */ public $allowPhpInUserContent = false; + /** + * Indicates if method invocations should be evaluated in user content. + * + * When disabled, method calls like object:method() within + * fields with antlers:true will be blocked. + * + * @var bool + */ + public $allowMethodsInUserContent = false; + /** * Registers a new Antlers preparser callback. * diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index 7df144a7daf..4d7d32461ab 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -134,6 +134,7 @@ public function __construct(DocumentParser $documentParser, NodeProcessor $nodeP public function setRuntimeConfiguration(RuntimeConfiguration $configuration) { GlobalRuntimeState::$allowPhpInContent = $configuration->allowPhpInUserContent; + GlobalRuntimeState::$allowMethodsInContent = $configuration->allowMethodsInUserContent; GlobalRuntimeState::$throwErrorOnAccessViolation = $configuration->throwErrorOnAccessViolation; GlobalRuntimeState::$bannedVarPaths = $configuration->guardedVariablePatterns; GlobalRuntimeState::$bannedContentVarPaths = $configuration->guardedContentVariablePatterns; @@ -314,6 +315,11 @@ protected function isIgnitionInstalled() return class_exists(ViewException::class) || class_exists('Spatie\LaravelIgnition\Exceptions\ViewException'); } + protected function shouldCacheRenderNodes($text) + { + return ! str_contains($text, '/noparse'); + } + /** * Parses and renders the input text, with the provided runtime data. * @@ -350,7 +356,7 @@ protected function renderText($text, $data = []) $parseText = $this->sanitizePhp($text); $cacheSlug = md5($parseText); - if (! array_key_exists($cacheSlug, self::$standardRenderNodeCache)) { + if (! array_key_exists($cacheSlug, self::$standardRenderNodeCache) || ! $this->shouldCacheRenderNodes($text)) { $this->documentParser->setIsVirtual($this->view == ''); if (strlen($this->view) > 0) { @@ -898,4 +904,12 @@ public function callback($callback) { return $this; } + + /** + * Clears the standard render node cache. + */ + public static function clearRenderNodeCache() + { + self::$standardRenderNodeCache = []; + } } diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index 0bc872c1c2c..4c7f855a8e1 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -6,6 +6,7 @@ use Exception; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Illuminate\Support\MessageBag; use Illuminate\Support\ViewErrorBag; use Statamic\Contracts\Query\Builder; @@ -890,6 +891,27 @@ public function process($nodes) continue; } elseif ($currentNode instanceof MethodInvocationNode) { + if (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent) { + array_pop($stack); + + if (GlobalRuntimeState::$throwErrorOnAccessViolation) { + throw ErrorFactory::makeRuntimeError( + AntlersErrorCodes::RUNTIME_METHOD_CALL_USER_CONTENT, + $currentNode, + 'Method invocation in user content.' + ); + } else { + Log::warning('Method call evaluated in user content.', [ + 'file' => GlobalRuntimeState::$currentExecutionFile, + 'trace' => GlobalRuntimeState::$templateFileStack, + ]); + } + + $stack[] = null; + + continue; + } + $leftNode = array_pop($stack); if ($leftNode == null) { @@ -1369,6 +1391,7 @@ private function adjustValue($value, $originalNode) private function checkForFieldValue($value, $hasModifiers = false, $modifierChain = null) { if ($value instanceof Value) { + $prevIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = true; if ($value->shouldParseAntlers()) { if (! $hasModifiers || ($modifierChain != null && $modifierChain[0]->nameNode->name != 'raw')) { @@ -1376,15 +1399,18 @@ private function checkForFieldValue($value, $hasModifiers = false, $modifierChai $value, $this->nodeProcessor->getActiveNode(), ]; - $value = $value->antlersValue($this->nodeProcessor->getAntlersParser(), $this->data); - GlobalRuntimeState::$userContentEvalState = null; + try { + $value = $value->antlersValue($this->nodeProcessor->getAntlersParser(), $this->data); + } finally { + GlobalRuntimeState::$userContentEvalState = null; + } } } else { if (! $hasModifiers) { $value = $value->value(); } } - GlobalRuntimeState::$isEvaluatingUserData = false; + GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; } return $value; diff --git a/src/View/Blade/Concerns/CompilesNavs.php b/src/View/Blade/Concerns/CompilesNavs.php index 3d92ba3dfc7..c8549ad1327 100644 --- a/src/View/Blade/Concerns/CompilesNavs.php +++ b/src/View/Blade/Concerns/CompilesNavs.php @@ -12,13 +12,20 @@ protected function compileNav(ComponentNode $component): string $viewName = '___nav'.sha1($component->outerDocumentContent); $compiled = (new StatamicTagCompiler()) - ->prependCompiledContent('$__currentStatamicNavView = \''.$viewName.'\';') - ->appendCompiledContent('unset($__currentStatamicNavView);') ->setInterceptNav(false) ->compile($component->outerDocumentContent); - file_put_contents(storage_path('framework/views/'.$viewName.'.blade.php'), $compiled); + return <<$compiled +PHP; } } diff --git a/src/View/Cascade.php b/src/View/Cascade.php index 85758fc158e..e16fa127126 100644 --- a/src/View/Cascade.php +++ b/src/View/Cascade.php @@ -158,6 +158,10 @@ protected function hydrateContent() return $this; } + if ($this->content instanceof \Closure) { + $this->content = call_user_func($this->content); + } + $variables = $this->content instanceof Augmentable ? $this->content->toDeferredAugmentedArray() : $this->content->toArray(); @@ -179,7 +183,7 @@ private function contextualVariables() 'xml_header' => '', // @TODO remove and document new best practice 'csrf_token' => csrf_token(), 'csrf_field' => csrf_field(), - 'config' => config()->all(), + 'config' => static::config(), 'response_code' => 200, // Auth @@ -243,4 +247,141 @@ public function clearSections() return $this; } + + public static function config(): array + { + $defaults = [ + 'app.name', + 'app.env', + 'app.debug', + 'app.url', + 'app.asset_url', + 'app.locale', + 'app.fallback_locale', + 'app.timezone', + 'auth.defaults', + 'auth.guards', + 'auth.passwords', + 'broadcasting.default', + 'cache.default', + 'filesystems.default', + 'mail.default', + 'mail.from', + 'queue.default', + 'session.lifetime', + 'session.expire_on_close', + 'session.driver', + 'statamic.assets.image_manipulation', + 'statamic.assets.auto_crop', + 'statamic.assets.thumbnails', + 'statamic.assets.video_thumbnails', + 'statamic.assets.google_docs_viewer', + 'statamic.assets.cache_meta', + 'statamic.assets.focal_point_editor', + 'statamic.assets.lowercase', + 'statamic.assets.svg_sanitization_on_upload', + 'statamic.assets.ffmpeg', + 'statamic.assets.set_preview_images', + 'statamic.autosave', + 'statamic.cp', + 'statamic.editions', + 'statamic.forms.email_view_folder', + 'statamic.forms.send_email_job', + 'statamic.forms.exporters', + 'statamic.git.enabled', + 'statamic.git.automatic', + 'statamic.git.queue_connection', + 'statamic.git.dispatch_delay', + 'statamic.git.use_authenticated', + 'statamic.git.user', + 'statamic.git.binary', + 'statamic.git.commands', + 'statamic.git.push', + 'statamic.git.ignored_events', + 'statamic.git.locale', + 'statamic.graphql', + 'statamic.live_preview', + 'statamic.markdown', + 'statamic.oauth', + 'statamic.protect.default', + 'statamic.revisions', + 'statamic.routes', + 'statamic.search.default', + 'statamic.search.indexes', + 'statamic.search.defaults', + 'statamic.search.queue', + 'statamic.search.queue_connection', + 'statamic.search.chunk_size', + 'statamic.stache.watcher', + 'statamic.stache.cache_store', + 'statamic.stache.indexes', + 'statamic.stache.lock', + 'statamic.stache.warming', + 'statamic.static_caching.strategy', + 'statamic.static_caching.strategies', + 'statamic.static_caching.exclude', + 'statamic.static_caching.invalidation', + 'statamic.static_caching.ignore_query_strings', + 'statamic.static_caching.allowed_query_strings', + 'statamic.static_caching.disallowed_query_strings', + 'statamic.static_caching.nocache', + 'statamic.static_caching.nocache_db_connection', + 'statamic.static_caching.replacers', + 'statamic.static_caching.warm_queue', + 'statamic.static_caching.warm_queue_connection', + 'statamic.static_caching.warm_insecure', + 'statamic.static_caching.background_recache', + 'statamic.static_caching.recache_token_parameter', + 'statamic.static_caching.share_errors', + 'statamic.system.multisite', + 'statamic.system.send_powered_by_header', + 'statamic.system.date_format', + 'statamic.system.display_timezone', + 'statamic.system.localize_dates_in_modifiers', + 'statamic.system.charset', + 'statamic.system.track_last_update', + 'statamic.system.cache_tags_enabled', + 'statamic.system.php_memory_limit', + 'statamic.system.php_max_execution_time', + 'statamic.system.ajax_timeout', + 'statamic.system.pcre_backtrack_limit', + 'statamic.system.debugbar', + 'statamic.system.ascii_replace_extra_symbols', + 'statamic.system.update_references', + 'statamic.system.always_augment_to_query', + 'statamic.system.row_id_handle', + 'statamic.system.fake_sql_queries', + 'statamic.system.layout', + 'statamic.templates', + 'statamic.users.repository', + 'statamic.users.avatars', + 'statamic.users.new_user_roles', + 'statamic.users.new_user_groups', + 'statamic.users.wizard_invitation', + 'statamic.users.passwords', + 'statamic.users.database', + 'statamic.users.tables', + 'statamic.users.guards', + 'statamic.users.impersonate', + 'statamic.users.elevated_session_duration', + 'statamic.users.two_factor_enforced_roles', + 'statamic.users.sort_field', + 'statamic.users.sort_direction', + 'statamic.webauthn', + ]; + + $allowed = collect((array) config('statamic.system.view_config_allowlist', $defaults)) + ->flatMap(fn ($key) => $key === '@default' ? $defaults : [$key]) + ->unique()->values()->all(); + + return array_reduce($allowed, function ($config, $key) { + $value = config($key); + + if (! is_null($value)) { + Arr::set($config, $key, $value); + } + + return $config; + }, []); + } } diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 32ba32298bf..d9cb6813f24 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -10,6 +10,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Token; use Statamic\Facades\User; +use Statamic\Query\Scopes\Scope; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -136,6 +137,25 @@ public function it_filters_out_past_entries_from_past_private_collection() $response->assertJsonPath('data.0.id', 'a'); } + #[Test] + public function it_can_use_a_query_scope_on_collection_entries_when_configuration_allows_for_it() + { + app('statamic.scopes')['test_scope'] = TestScope::class; + + Facades\Config::set('statamic.api.resources.collections.pages', [ + 'allowed_query_scopes' => ['test_scope'], + ]); + + Facades\Collection::make('pages')->save(); + + Facades\Entry::make()->collection('pages')->id('about')->slug('about')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('dance')->slug('dance')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('nectar')->slug('nectar')->published(true)->save(); + + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=is&query_scope[test_scope][value]=about', 1); + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=isnt&query_scope[test_scope][value]=about', 2); + } + #[Test] public function it_can_filter_collection_entries_when_configuration_allows_for_it() { @@ -624,3 +644,11 @@ public function handle(\Statamic\Contracts\Tokens\Token $token, \Illuminate\Http return $next($token); } } + +class TestScope extends Scope +{ + public function apply($query, $values) + { + $query->where('id', $values['operator'] == 'is' ? '=' : '!=', $values['value']); + } +} diff --git a/tests/Actions/DuplicateEntryTest.php b/tests/Actions/DuplicateEntryTest.php index c4ffe0a4a95..f82777eeb7e 100644 --- a/tests/Actions/DuplicateEntryTest.php +++ b/tests/Actions/DuplicateEntryTest.php @@ -65,6 +65,28 @@ public function it_increments_the_number_if_duplicate_already_exists() ], $this->entryData()); } + #[Test] + public function it_updates_last_modified() + { + $originalUser = User::make()->email('alfa@romeo.test')->save(); + $duplicateUser = User::make()->email('alfa-1@romeo.test')->save(); + + $this->actingAs($duplicateUser); + + Collection::make('test')->save(); + $originalEntry = EntryFactory::id('alfa-id')->collection('test')->slug('alfa')->data(['title' => 'Alfa', 'updated_at' => 123, 'updated_by' => $originalUser->id()])->create(); + + (new DuplicateEntry)->run(collect([ + Entry::find('alfa-id'), + ]), []); + + $duplicatedEntry = Entry::query()->where('slug', 'alfa-1')->first(); + + $this->assertEquals($duplicatedEntry->lastModifiedBy()->id(), $duplicateUser->id()); + $this->assertNotEquals($originalEntry->lastModifiedBy()->id(), $duplicatedEntry->lastModifiedBy()->id()); + $this->assertNotEquals($originalEntry->lastModified(), $duplicatedEntry->lastModified()); + } + #[Test] #[DataProvider('authorizationProvider')] public function it_authorizes( @@ -433,6 +455,42 @@ public function it_duplicates_an_entry_from_a_non_default_site() ], $this->entryData()); } + #[Test] + public function it_duplicates_an_entry_with_localizations_without_propagating() + { + $this->setSites([ + 'en' => ['url' => 'http://domain.com/', 'locale' => 'en'], + 'fr' => ['url' => 'http://domain.com/fr/', 'locale' => 'fr'], + ]); + + $collection = Collection::make('test')->sites(['en', 'fr']); + $collection->save(); + + $entry = EntryFactory::id('alfa-id')->locale('en')->collection('test')->slug('alfa')->data(['title' => 'Alfa'])->create(); + $entry->makeLocalization('fr')->id('alfa-id-fr')->data(['title' => 'Alfa (French)'])->save(); + + $this->assertEquals([ + ['slug' => 'alfa', 'published' => true, 'data' => ['title' => 'Alfa'], 'locale' => 'en', 'origin' => ''], + ['slug' => 'alfa', 'published' => true, 'data' => ['title' => 'Alfa (French)'], 'locale' => 'fr', 'origin' => 'en.alfa'], + ], $this->entryData()); + + // Make super user since this test isn't concerned with permissions. + $this->actingAs(tap(User::make()->makeSuper())->save()); + + $collection->propagate(true); + + (new DuplicateEntry)->run(collect([ + Entry::find('alfa-id'), + ]), []); + + $this->assertEquals([ + ['slug' => 'alfa', 'published' => true, 'data' => ['title' => 'Alfa'], 'locale' => 'en', 'origin' => null], + ['slug' => 'alfa', 'published' => true, 'data' => ['title' => 'Alfa (French)'], 'locale' => 'fr', 'origin' => 'en.alfa'], + ['slug' => 'alfa-1', 'published' => false, 'data' => ['title' => 'Alfa (Duplicated)', 'duplicated_from' => 'alfa-id'], 'locale' => 'en', 'origin' => null], + ['slug' => 'alfa-1', 'published' => false, 'data' => ['title' => 'Alfa (French) (Duplicated)', 'duplicated_from' => 'alfa-id-fr'], 'locale' => 'fr', 'origin' => 'en.alfa-1'], + ], $this->entryData()); + } + #[Test] public function if_an_entry_has_an_origin_it_duplicates_the_root_origin() { @@ -506,7 +564,7 @@ private function entryData() $arr = [ 'slug' => $entry->slug(), 'published' => $entry->published(), - 'data' => $entry->data()->all(), + 'data' => $entry->data()->except(['updated_at', 'updated_by'])->all(), ]; if (Site::hasMultiple()) { diff --git a/tests/Antlers/Fixtures/MethodClasses/ArrayClass.php b/tests/Antlers/Fixtures/MethodClasses/ArrayClass.php new file mode 100644 index 00000000000..0beac4aacc0 --- /dev/null +++ b/tests/Antlers/Fixtures/MethodClasses/ArrayClass.php @@ -0,0 +1,11 @@ +join(' '); + } +} diff --git a/tests/Antlers/ParseUserContentTest.php b/tests/Antlers/ParseUserContentTest.php new file mode 100644 index 00000000000..5177ee45152 --- /dev/null +++ b/tests/Antlers/ParseUserContentTest.php @@ -0,0 +1,91 @@ +assertSame( + (string) Antlers::parse('Hello {{ name }}!', ['name' => 'Jason']), + (string) Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']) + ); + } + + #[Test] + public function it_blocks_php_nodes_in_user_content_mode() + { + Log::shouldReceive('warning') + ->once() + ->with('PHP Node evaluated in user content: {{? echo Str::upper(\'hello\') ?}}', \Mockery::type('array')); + + $result = (string) Antlers::parseUserContent('Text: {{? echo Str::upper(\'hello\') ?}}'); + + $this->assertSame('Text: ', $result); + } + + #[Test] + public function it_blocks_method_calls_in_user_content_mode() + { + Log::shouldReceive('warning') + ->once() + ->with('Method call evaluated in user content.', \Mockery::type('array')); + + $result = (string) Antlers::parseUserContent('{{ object:method("hello") }}', [ + 'object' => new ClassOne(), + ]); + + $this->assertSame('', $result); + } + + #[Test] + public function it_restores_user_data_flag_after_successful_parse() + { + GlobalRuntimeState::$isEvaluatingUserData = false; + + Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']); + + $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); + } + + #[Test] + public function it_restores_user_data_flag_after_parse_exceptions() + { + GlobalRuntimeState::$isEvaluatingUserData = false; + $parser = \Mockery::mock(Parser::class); + $parser->shouldReceive('parse') + ->once() + ->andThrow(new \RuntimeException('Failed to parse user content.')); + + try { + Antlers::usingParser($parser, function ($antlers) { + $antlers->parseUserContent('Hello {{ name }}', ['name' => 'Jason']); + }); + + $this->fail('Expected RuntimeException to be thrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('Failed to parse user content.', $exception->getMessage()); + } + + $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); + } +} diff --git a/tests/Antlers/ParserTestCase.php b/tests/Antlers/ParserTestCase.php index 24c037997b7..a51e64bed02 100644 --- a/tests/Antlers/ParserTestCase.php +++ b/tests/Antlers/ParserTestCase.php @@ -46,6 +46,9 @@ protected function setUp(): void parent::setUp(); GlobalRuntimeState::resetGlobalState(); + GlobalRuntimeState::$throwErrorOnAccessViolation = false; + GlobalRuntimeState::$allowPhpInContent = false; + GlobalRuntimeState::$allowMethodsInContent = false; $this->setupTestBlueprintAndFields(); diff --git a/tests/Antlers/Runtime/ArraysTest.php b/tests/Antlers/Runtime/ArraysTest.php index 04d1cbd3454..a3ec713b459 100644 --- a/tests/Antlers/Runtime/ArraysTest.php +++ b/tests/Antlers/Runtime/ArraysTest.php @@ -3,6 +3,7 @@ namespace Tests\Antlers\Runtime; use PHPUnit\Framework\Attributes\Test; +use Tests\Antlers\Fixtures\MethodClasses\ArrayClass; use Tests\Antlers\ParserTestCase; class ArraysTest extends ParserTestCase @@ -426,4 +427,22 @@ public function test_arrays_as_the_tag_name() { $this->assertSame('array', $this->renderString('{{ [1, 2, 3, 4] | type_of }}', [], true)); } + + public function test_shorthand_arrays_inside_method_calls() + { + $this->assertSame('one two three', $this->renderString( + "{{ instance->join(['one', 'two', 'three']) }}", [ + 'instance' => new ArrayClass, + ])); + + $this->assertSame('one two three', $this->renderString( + "{{ instance:join(['one', 'two', 'three']) }}", [ + 'instance' => new ArrayClass, + ])); + + $this->assertSame('one two three', $this->renderString( + "{{ instance.join(['one', 'two', 'three']) }}", [ + 'instance' => new ArrayClass, + ])); + } } diff --git a/tests/Antlers/Runtime/MethodCallTest.php b/tests/Antlers/Runtime/MethodCallTest.php index da243f6e34c..4f84b977f95 100644 --- a/tests/Antlers/Runtime/MethodCallTest.php +++ b/tests/Antlers/Runtime/MethodCallTest.php @@ -3,7 +3,13 @@ namespace Tests\Antlers\Runtime; use Carbon\Carbon; +use Illuminate\Support\Facades\Log; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fields\Field; +use Statamic\Fields\Value; +use Statamic\Fieldtypes\Text; +use Statamic\View\Antlers\Language\Exceptions\RuntimeException; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Tests\Antlers\Fixtures\MethodClasses\CallCounter; use Tests\Antlers\Fixtures\MethodClasses\ClassOne; use Tests\Antlers\Fixtures\MethodClasses\StringLengthObject; @@ -11,6 +17,16 @@ class MethodCallTest extends ParserTestCase { + public function tearDown(): void + { + GlobalRuntimeState::$throwErrorOnAccessViolation = false; + GlobalRuntimeState::$allowMethodsInContent = false; + GlobalRuntimeState::$isEvaluatingUserData = false; + GlobalRuntimeState::$isEvaluatingData = false; + + parent::tearDown(); + } + public function test_methods_can_be_called() { $object = new ClassOne(); @@ -245,6 +261,121 @@ public function test_dangling_chained_method_calls() $this->assertSame('2001-10-22T00:00:00+00:00', $result); } + + public function test_method_calls_blocked_in_user_content() + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + $object = new ClassOne(); + $value = new Value('{{ object:method("hello") }}', 'text_field', $textFieldtype); + + Log::shouldReceive('warning') + ->once() + ->with('Method call evaluated in user content.', \Mockery::type('array')); + + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'object' => $object, + ]); + + $this->assertSame('', $result); + } + + public function test_method_calls_allowed_in_user_content_when_configured() + { + GlobalRuntimeState::$allowMethodsInContent = true; + + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + $object = new ClassOne(); + $value = new Value('{{ object:method("hello") }}', 'text_field', $textFieldtype); + + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'object' => $object, + ]); + + $this->assertSame('String: hello', $result); + + GlobalRuntimeState::$allowMethodsInContent = false; + } + + public function test_method_calls_in_user_content_throw_when_configured() + { + GlobalRuntimeState::$throwErrorOnAccessViolation = true; + + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + $object = new ClassOne(); + $value = new Value('{{ object:method("hello") }}', 'text_field', $textFieldtype); + + $this->expectException(RuntimeException::class); + + $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'object' => $object, + ]); + + GlobalRuntimeState::$throwErrorOnAccessViolation = false; + } + + public function test_method_calls_still_work_in_templates() + { + $object = new ClassOne(); + + $this->assertSame('String: hello', $this->renderString('{{ object:method("hello") }}', [ + 'object' => $object, + ])); + } + + public function test_nested_value_does_not_reset_user_data_flag() + { + $textFieldtype = new Text(); + + $nestedField = new Field('nested_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($nestedField); + $nestedValue = new Value('Hello', 'nested_field', $textFieldtype); + + $outerField = new Field('outer_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($outerField); + $object = new ClassOne(); + $outerValue = new Value('{{ nested_field }}{{ object:method("hello") }}', 'outer_field', $textFieldtype); + + Log::shouldReceive('warning') + ->once() + ->with('Method call evaluated in user content.', \Mockery::type('array')); + + $result = $this->renderString('{{ outer_field }}', [ + 'outer_field' => $outerValue, + 'nested_field' => $nestedValue, + 'object' => $object, + ]); + + $this->assertSame('Hello', $result); + } } class TestDateTime diff --git a/tests/Antlers/Runtime/NoparseTest.php b/tests/Antlers/Runtime/NoparseTest.php index 67befc65498..1f190bd4d6e 100644 --- a/tests/Antlers/Runtime/NoparseTest.php +++ b/tests/Antlers/Runtime/NoparseTest.php @@ -2,11 +2,16 @@ namespace Tests\Antlers\Runtime; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use Statamic\View\Antlers\Language\Runtime\NodeProcessor; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Tests\Antlers\ParserTestCase; +use Tests\FakesViews; class NoparseTest extends ParserTestCase { + use FakesViews; + public function test_noparse_ignores_braces_entirely() { $template = <<<'EOT' @@ -142,4 +147,39 @@ public function test_multiple_noparse_regions() $this->assertSame($expected, StringUtilities::normalizeLineEndings(trim($this->renderString($template, ['title' => 'the title'])))); } + + public function test_noparse_in_nested_partials_renders_correctly() + { + $template = <<<'EOT' + {{ partial:partial_a }} + {{ partial:partial_a }} + {{ noparse }}inside noparse{{ /noparse }} + {{ /partial:partial_a }} + {{ /partial:partial_a }} + + {{ partial:partial_a }} + {{ partial:partial_a }} + {{ noparse }}inside noparse{{ /noparse }} + {{ /partial:partial_a }} + {{ /partial:partial_a }} + + {{ partial:partial_a }} + {{ partial:partial_a }} + {{ noparse }}inside noparse{{ /noparse }} + {{ /partial:partial_a }} + {{ /partial:partial_a }} +EOT; + + GlobalRuntimeState::$peekCallbacks[] = function ($processor, $nodes) { + NodeProcessor::$break = true; + }; + + $this->withFakeViews(); + $this->viewShouldReturnRaw('partial_a', '{{ slot }}'); + + $actual = StringUtilities::normalizeLineEndings(trim($this->renderString($template))); + + $occurrences = substr_count($actual, 'inside noparse'); + $this->assertEquals(3, $occurrences, "Expected 'inside noparse' to appear exactly 3 times"); + } } diff --git a/tests/Antlers/Runtime/ParserIsolationTest.php b/tests/Antlers/Runtime/ParserIsolationTest.php index fcbcb5a748a..90b5c162120 100644 --- a/tests/Antlers/Runtime/ParserIsolationTest.php +++ b/tests/Antlers/Runtime/ParserIsolationTest.php @@ -229,4 +229,49 @@ public function test_variables_created_in_template_are_shared_with_the_layout() $this->assertSame($expected, $result); } + + public function test_escaped_braces_in_concurrent_requests() + { + Collection::make('pages')->routes(['en' => '{slug}'])->save(); + EntryFactory::collection('pages')->id('1')->slug('page-one')->data([ + 'title' => 'Page One', + 'template' => 'escaped_braces', + ])->create(); + EntryFactory::collection('pages')->id('2')->slug('page-two')->data([ + 'title' => 'Page Two', + 'template' => 'escaped_braces', + ])->create(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + + $template = <<<'EOT' +{{ title }} +{{ "string @{foo@} bar" }} +{{ "another @{example@} here" }} +EOT; + + $this->viewShouldReturnRaw('escaped_braces', $template); + + $expectedOne = <<<'EXPECTED' +Page One +string {foo} bar +another {example} here +EXPECTED; + + $expectedTwo = <<<'EXPECTED' +Page Two +string {foo} bar +another {example} here +EXPECTED; + + $responseOne = $this->get('page-one')->assertOk(); + $contentOne = StringUtilities::normalizeLineEndings(trim($responseOne->content())); + + $responseTwo = $this->get('page-two')->assertOk(); + $contentTwo = StringUtilities::normalizeLineEndings(trim($responseTwo->content())); + + $this->assertSame($expectedOne, $contentOne); + $this->assertSame($expectedTwo, $contentTwo); + } } diff --git a/tests/Antlers/Runtime/PhpEnabledTest.php b/tests/Antlers/Runtime/PhpEnabledTest.php index d5e051fb65d..cd1122defbb 100644 --- a/tests/Antlers/Runtime/PhpEnabledTest.php +++ b/tests/Antlers/Runtime/PhpEnabledTest.php @@ -2,9 +2,13 @@ namespace Tests\Antlers\Runtime; +use Illuminate\Support\Facades\Log; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; +use Statamic\Fieldtypes\Text; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Antlers\Language\Runtime\RuntimeConfiguration; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Tests\Antlers\ParserTestCase; @@ -513,8 +517,8 @@ public function test_php_node_assignments_within_loops() public function test_assignments_from_php_nodes() { $template = <<<'EOT' -{{? - $value_one = 100; +{{? + $value_one = 100; $value_two = 0; ?}} @@ -533,4 +537,76 @@ public function test_assignments_from_php_nodes() $this->assertStringContainsString('', $result); $this->assertStringContainsString('', $result); } + + public function test_disabled_php_echo_node_inside_user_values() + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textContent = <<<'TEXT' +Text: {{$ Str::upper('hello, world.') $}} +TEXT; + + $textFieldtype->setField($field); + $value = new Value($textContent, 'text_field', $textFieldtype); + + Log::shouldReceive('warning') + ->once() + ->with("PHP Node evaluated in user content: {{\$ Str::upper('hello, world.') \$}}", [ + 'file' => null, + 'trace' => [], + 'content' => " Str::upper('hello, world.') ", + ]); + + $result = $this->renderString('{{ text_field }}', ['text_field' => $value]); + + $this->assertSame('Text: ', $result); + + GlobalRuntimeState::$allowPhpInContent = true; + + $result = $this->renderString('{{ text_field }}', ['text_field' => $value]); + + $this->assertSame('Text: HELLO, WORLD.', $result); + + GlobalRuntimeState::$allowPhpInContent = false; + } + + public function test_disabled_php_node_inside_user_values() + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textContent = <<<'TEXT' +Text: {{? echo Str::upper('hello, world.') ?}} +TEXT; + + $textFieldtype->setField($field); + $value = new Value($textContent, 'text_field', $textFieldtype); + + Log::shouldReceive('warning') + ->once() + ->with("PHP Node evaluated in user content: {{? echo Str::upper('hello, world.') ?}}", [ + 'file' => null, + 'trace' => [], + 'content' => " echo Str::upper('hello, world.') ", + ]); + + $result = $this->renderString('{{ text_field }}', ['text_field' => $value]); + + $this->assertSame('Text: ', $result); + + GlobalRuntimeState::$allowPhpInContent = true; + + $result = $this->renderString('{{ text_field }}', ['text_field' => $value]); + + $this->assertSame('Text: HELLO, WORLD.', $result); + + GlobalRuntimeState::$allowPhpInContent = false; + } } diff --git a/tests/Antlers/Runtime/TemplateTest.php b/tests/Antlers/Runtime/TemplateTest.php index b3af5515df7..bcc54ad609f 100644 --- a/tests/Antlers/Runtime/TemplateTest.php +++ b/tests/Antlers/Runtime/TemplateTest.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Facades\Log; use Illuminate\Support\MessageBag; +use Illuminate\Support\Str; use Illuminate\Support\ViewErrorBag; use Mockery; use PHPUnit\Framework\Attributes\DataProvider; @@ -22,6 +23,7 @@ use Statamic\Fields\LabeledValue; use Statamic\Fields\Value; use Statamic\Fields\Values; +use Statamic\Tags\Concerns\OutputsItems; use Statamic\Tags\Tags; use Statamic\View\Cascade; use Tests\Antlers\Fixtures\Addon\Tags\RecursiveChildren; @@ -2599,6 +2601,77 @@ public function test_it_returns_escaped_content() $input = 'Hey, look at that @{{ noun }}!'; $this->assertSame('Hey, look at that {{ noun }}!', $this->renderString($input, [])); } + + #[Test] + public function no_results_value_is_added_automatically() + { + (new class extends Tags + { + use OutputsItems; + public static $handle = 'the_tag'; + + public function index() + { + if ($this->params->get('has_value')) { + return $this->output(collect([ + 'one', + 'two', + 'three', + ])); + } + + return $this->parseNoResults(); + } + })::register(); + + $template = <<<'TEMPLATE' +{{ the_tag }} + {{ if no_results }} + No Results 1. + {{ the_tag has_value="true" }} + {{ if no_results }} + No Results 2. + {{ else }} + {{ value }} + {{ /if }} + {{ /the_tag }} + + {{ if no_results }} No Results 1.1 {{ /if }} + {{ else }} + Has Results 1. + {{ /if }} +{{ /the_tag }} +TEMPLATE; + + $this->assertSame( + 'No Results 1. one two three No Results 1.1', + Str::squish($this->renderString($template, [], true)) + ); + + $template = <<<'TEMPLATE' +{{ the_tag }} + {{ if no_results }} + No Results 1. + {{ the_tag has_value="true" as="items" }} + {{ if no_results }} + No Results 2. + {{ else }} + {{ items }}{{ value }} {{ /items }} + {{ /if }} + {{ /the_tag }} + + {{ if no_results }} No Results 1.1 {{ /if }} + {{ else }} + Has Results 1. + {{ /if }} +{{ /the_tag }} +TEMPLATE; + + $this->assertSame( + 'No Results 1. one two three No Results 1.1', + Str::squish($this->renderString($template, [], true)) + ); + } } class NonArrayableObject diff --git a/tests/Antlers/Runtime/UnlessTest.php b/tests/Antlers/Runtime/UnlessTest.php index 636ebb04250..28602c49ca3 100644 --- a/tests/Antlers/Runtime/UnlessTest.php +++ b/tests/Antlers/Runtime/UnlessTest.php @@ -2,6 +2,7 @@ namespace Tests\Antlers\Runtime; +use Illuminate\Support\Str; use Tests\Antlers\ParserTestCase; class UnlessTest extends ParserTestCase @@ -17,4 +18,19 @@ public function test_elseunless_conditions_does_not_cause_error() 'last' => false, ])); } + + public function test_unless_with_vars() + { + $template = <<<'EOT' +{{ the_var = 1 }} + +{{ unless {the_var} }}true{{ else }}false{{ /unless }} +{{ if ! {the_var} }}true{{ else }}false{{ /if }} +EOT; + + $this->assertSame( + 'false false', + Str::squish($this->renderString($template)) + ); + } } diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 507c7358792..4054d5935ba 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -291,6 +291,28 @@ public static function warmPresetProvider() ]; } + #[Test] + public function custom_manipulation_presets_are_included_in_warm_presets() + { + config(['statamic.assets.image_manipulation.presets' => [ + 'small' => ['w' => '15', 'h' => '15'], + 'medium' => ['w' => '500', 'h' => '500'], + 'large' => ['w' => '1000', 'h' => '1000'], + 'max' => ['w' => '3000', 'h' => '3000', 'mark' => 'watermark.jpg'], + ]]); + + Facades\Image::registerCustomManipulationPresets([ + 'og_image' => ['w' => 1146, 'h' => 600], + 'twitter_image' => ['w' => 1200, 'h' => 600], + ]); + + $container = (new AssetContainer); + + $this->assertEquals([ + 'small', 'medium', 'large', 'max', 'og_image', 'twitter_image', + ], $container->warmPresets()); + } + #[Test] public function it_saves_the_container_through_the_api() { diff --git a/tests/Assets/AssetFolderTest.php b/tests/Assets/AssetFolderTest.php index 24a868a49e7..e9e6093ba93 100644 --- a/tests/Assets/AssetFolderTest.php +++ b/tests/Assets/AssetFolderTest.php @@ -1151,6 +1151,24 @@ public function it_converts_to_an_array() ], $folder->toArray()); } + #[Test] + public function it_uses_a_custom_cache_store() + { + config([ + 'cache.stores.asset_container_contents' => [ + 'driver' => 'file', + 'path' => storage_path('statamic/asset-container-contents'), + ], + ]); + + Storage::fake('local'); + + $store = Facades\AssetContainer::make('test')->disk('local')->contents()->cacheStore(); + + // ideally we would have checked the store name, but laravel 10 doesnt give us a way to do that + $this->assertStringContainsString('asset-container-contents', $store->getStore()->getDirectory()); + } + private function containerWithDisk() { Storage::fake('local'); diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 06fc356c622..8493c26d4f8 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -1105,6 +1105,54 @@ public function it_can_be_moved_to_another_folder() Event::assertDispatched(AssetSaved::class); } + #[Test] + public function it_can_be_moved_to_another_folder_quietly() + { + Storage::fake('local'); + $disk = Storage::disk('local'); + $disk->put('old/asset.txt', 'The asset contents'); + $container = Facades\AssetContainer::make('test')->disk('local'); + Facades\AssetContainer::shouldReceive('save')->with($container); + Facades\AssetContainer::shouldReceive('findByHandle')->with('test')->andReturn($container); + $asset = $container->makeAsset('old/asset.txt')->data(['foo' => 'bar']); + $asset->save(); + $oldMeta = $disk->get('old/.meta/asset.txt.yaml'); + $disk->assertExists('old/asset.txt'); + $disk->assertExists('old/.meta/asset.txt.yaml'); + $this->assertEquals([ + 'old/asset.txt', + ], $container->files()->all()); + $this->assertEquals([ + 'old/asset.txt' => ['foo' => 'bar'], + ], $container->assets('/', true)->keyBy->path()->map(function ($item) { + return $item->data()->all(); + })->all()); + + Event::fake(); + $return = $asset->moveQuietly('new'); + + $this->assertEquals($asset, $return); + $disk->assertMissing('old/asset.txt'); + $disk->assertMissing('old/.meta/asset.txt.yaml'); + $disk->assertExists('new/asset.txt'); + $disk->assertExists('new/.meta/asset.txt.yaml'); + $this->assertEquals($oldMeta, $disk->get('new/.meta/asset.txt.yaml')); + $this->assertEquals([ + 'new/asset.txt', + ], $container->files()->all()); + $this->assertEquals([ + 'new/asset.txt' => ['foo' => 'bar'], + ], $container->assets('/', true)->keyBy->path()->map(function ($item) { + return $item->data()->all(); + })->all()); + $this->assertEquals([ + 'old', // the empty directory doesnt actually get deleted + 'new', + 'new/asset.txt', + ], $container->contents()->cached()->keys()->all()); + Event::assertNotDispatched(AssetSaved::class); + } + #[Test] public function it_can_be_moved_to_another_folder_with_a_new_filename() { @@ -2017,7 +2065,7 @@ public function it_can_process_a_custom_image_format() public function it_appends_timestamp_to_uploaded_files_filename_if_it_already_exists() { Event::fake(); - Carbon::setTestNow(Carbon::createFromTimestamp(1549914700)); + Carbon::setTestNow(Carbon::createFromTimestamp(1549914700, config('app.timezone'))); $asset = $this->container->makeAsset('path/to/asset.jpg'); Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); Storage::disk('test')->put('path/to/asset.jpg', ''); @@ -2032,6 +2080,22 @@ public function it_appends_timestamp_to_uploaded_files_filename_if_it_already_ex }); } + #[Test] + public function it_sanitizes_uploaded_filenames_but_passes_the_original_name_into_the_event() + { + Event::fake(); + $asset = $this->container->makeAsset('path/to/Sanitize This Asset © Photographer.JPG'); + Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); + + $asset->upload(UploadedFile::fake()->image('Sanitize This Asset © Photographer.JPG')); + + Storage::disk('test')->assertExists('path/to/sanitize-this-asset--photographer.jpg'); + $this->assertEquals('path/to/sanitize-this-asset--photographer.jpg', $asset->path()); + Event::assertDispatched(AssetUploaded::class, function ($event) use ($asset) { + return $event->asset = $asset && $event->originalFilename === 'Sanitize This Asset © Photographer.JPG'; + }); + } + #[Test] public function it_lowercases_uploaded_filenames_by_default() { @@ -2639,4 +2703,40 @@ public function it_does_not_delete_when_a_deleting_event_returns_false() Facades\Asset::shouldNotHaveReceived('delete'); Event::assertNotDispatched(AssetDeleted::class); } + + #[Test] + public function it_uses_a_custom_cache_store() + { + config([ + 'cache.stores.asset_meta' => [ + 'driver' => 'file', + 'path' => storage_path('statamic/asset-meta'), + ], + ]); + + Storage::fake('local'); + + $store = (new Asset)->cacheStore(); + + // ideally we would have checked the store name, but laravel 10 doesnt give us a way to do that + $this->assertStringContainsString('asset-meta', $store->getStore()->getDirectory()); + } + + #[Test] + public function it_clones_internal_collections() + { + $asset = (new Asset)->container($this->container)->path('foo/test.txt'); + $asset->set('foo', 'A'); + $asset->setSupplement('bar', 'A'); + + $clone = clone $asset; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $asset->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $asset->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/Assets/AssetUploaderTest.php b/tests/Assets/AssetUploaderTest.php index 540e411db63..3e2ec8ec5da 100644 --- a/tests/Assets/AssetUploaderTest.php +++ b/tests/Assets/AssetUploaderTest.php @@ -31,6 +31,8 @@ public static function filenameReplacementsProvider() 'question marks' => ['one?two.jpg', 'one-two.jpg'], 'asterisks' => ['one*two.jpg', 'one-two.jpg'], 'percentage' => ['one%two.jpg', 'one-two.jpg'], + 'single quote' => ["one'two'three.jpg", 'one-two-three.jpg'], + 'double dash' => ['one--two--three.jpg', 'one-two-three.jpg'], 'ascii' => ['fòô-bàř', 'foo-bar'], ]; } diff --git a/tests/Assets/AttributesTest.php b/tests/Assets/AttributesTest.php index 6cd3786556d..87fc3d02cab 100644 --- a/tests/Assets/AttributesTest.php +++ b/tests/Assets/AttributesTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\Asset; use Statamic\Assets\Attributes; @@ -78,23 +79,37 @@ public function it_gets_the_attributes_of_audio_file() } #[Test] - public function it_gets_the_attributes_of_video_file() + #[DataProvider('videoProvider')] + public function it_gets_the_attributes_of_video_file($playtimeSeconds, $resolutionX, $resolutionY, $rotate, $expected) { $asset = (new Asset) ->container(AssetContainer::make('test-container')->disk('test')) ->path('path/to/asset.mp4'); ExtractInfo::shouldReceive('fromAsset')->with($asset)->andReturn([ - 'playtime_seconds' => 13, + 'playtime_seconds' => $playtimeSeconds, 'video' => [ - 'resolution_x' => 1920, - 'resolution_y' => 1080, + 'resolution_x' => $resolutionX, + 'resolution_y' => $resolutionY, + 'rotate' => $rotate, ], ]); $attributes = $this->attributes->asset($asset); - $this->assertEquals(['duration' => 13, 'width' => 1920, 'height' => 1080], $attributes->get()); + $this->assertEquals($expected, $attributes->get()); + } + + public static function videoProvider() + { + return [ + 'not rotated' => [13, 1920, 1080, null, ['duration' => 13, 'width' => 1920, 'height' => 1080]], + 'rotated 90' => [13, 1920, 1080, 90, ['duration' => 13, 'width' => 1080, 'height' => 1920]], + 'rotated -90' => [13, 1920, 1080, -90, ['duration' => 13, 'width' => 1080, 'height' => 1920]], + 'rotated 270' => [13, 1920, 1080, 270, ['duration' => 13, 'width' => 1080, 'height' => 1920]], + 'rotated -270' => [13, 1920, 1080, -270, ['duration' => 13, 'width' => 1080, 'height' => 1920]], + 'rotated 180' => [13, 1920, 1080, 180, ['duration' => 13, 'width' => 1920, 'height' => 1080]], + ]; } #[Test] diff --git a/tests/Assets/GeneratePresetImageManipulationsOnUpload.php b/tests/Assets/GeneratePresetImageManipulationsOnUpload.php index 7a63c5a227a..f0ab14049c6 100644 --- a/tests/Assets/GeneratePresetImageManipulationsOnUpload.php +++ b/tests/Assets/GeneratePresetImageManipulationsOnUpload.php @@ -28,10 +28,10 @@ public function it_subscribes() #[Test] #[DataProvider('presetProvider')] - public function presets_are_generated_for_images($event, $extension, $shouldGenerate) + public function presets_are_generated_for_images($event, $basename, $shouldGenerate) { $generator = Mockery::mock(PresetGenerator::class); - $asset = (new Asset)->path('foo.'.$extension); + $asset = (new Asset)->path($basename); if ($shouldGenerate) { $generator->shouldReceive('generate')->once()->with($asset); @@ -41,19 +41,19 @@ public function presets_are_generated_for_images($event, $extension, $shouldGene $listener = new GeneratePresetImageManipulations($generator); - $listener->handle(new $event($asset)); + $listener->handle(new $event($asset, $basename)); } public static function presetProvider() { return [ - [AssetUploaded::class, 'jpg', true], - [AssetUploaded::class, 'svg', false], - [AssetUploaded::class, 'txt', false], + [AssetUploaded::class, 'foo.jpg', true], + [AssetUploaded::class, 'foo.svg', false], + [AssetUploaded::class, 'foo.txt', false], - [AssetReuploaded::class, 'jpg', true], - [AssetReuploaded::class, 'svg', false], - [AssetReuploaded::class, 'txt', false], + [AssetReuploaded::class, 'foo.jpg', true], + [AssetReuploaded::class, 'foo.svg', false], + [AssetReuploaded::class, 'foo.txt', false], ]; } } diff --git a/tests/Auth/Eloquent/EloquentUserTest.php b/tests/Auth/Eloquent/EloquentUserTest.php index a8953f797ac..b722f18ca83 100644 --- a/tests/Auth/Eloquent/EloquentUserTest.php +++ b/tests/Auth/Eloquent/EloquentUserTest.php @@ -303,4 +303,28 @@ public function it_gets_super_correctly_on_the_model() $this->assertFalse($user->super); $this->assertFalse($user->model()->super); } + + #[Test] + public function it_does_not_save_null_values_on_the_model() + { + $user = $this->user(); + + $user->set('null_field', null); + $user->set('not_null_field', true); + + $attributes = $user->model()->getAttributes(); + + $this->assertArrayNotHasKey('null_field', $attributes); + $this->assertTrue($attributes['not_null_field']); + + $user->merge([ + 'null_field' => null, + 'not_null_field' => false, + ]); + + $attributes = $user->model()->getAttributes(); + + $this->assertArrayNotHasKey('null_field', $attributes); + $this->assertFalse($attributes['not_null_field']); + } } diff --git a/tests/Auth/FileUserTest.php b/tests/Auth/FileUserTest.php index 6332c16446c..c0b95084c02 100644 --- a/tests/Auth/FileUserTest.php +++ b/tests/Auth/FileUserTest.php @@ -9,7 +9,9 @@ use Statamic\Contracts\Auth\Role as RoleContract; use Statamic\Contracts\Auth\UserGroup as UserGroupContract; use Statamic\Facades\Role; +use Statamic\Facades\Role as RoleAPI; use Statamic\Facades\UserGroup; +use Statamic\Facades\UserGroup as UserGroupAPI; use Statamic\Support\Arr; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -123,4 +125,65 @@ public function it_gets_permissions_from_a_cache() // Doing it a second time should give the same result but without multiple calls. $this->assertEquals($expectedPermissions, $user->permissions()->all()); } + + #[Test] + public function it_prevents_saving_duplicate_roles() + { + $roleA = (new \Statamic\Auth\File\Role)->handle('a'); + $roleB = (new \Statamic\Auth\File\Role)->handle('b'); + $roleC = (new \Statamic\Auth\File\Role)->handle('c'); + + RoleAPI::shouldReceive('find')->with('a')->andReturn($roleA); + RoleAPI::shouldReceive('find')->with('b')->andReturn($roleB); + RoleAPI::shouldReceive('find')->with('c')->andReturn($roleC); + RoleAPI::shouldReceive('all')->andReturn(collect([$roleA, $roleB])); // the stache calls this when getting a user. unrelated to test. + + $user = $this->createPermissible(); + $user->assignRole('a'); + + $this->assertEquals(['a'], $user->get('roles')); + + $user->assignRole(['a', 'b', 'c']); + + $this->assertEquals(['a', 'b', 'c'], $user->get('roles')); + } + + #[Test] + public function it_prevents_saving_duplicate_groups() + { + $groupA = (new \Statamic\Auth\File\UserGroup)->handle('a'); + $groupB = (new \Statamic\Auth\File\UserGroup)->handle('b'); + $groupC = (new \Statamic\Auth\File\UserGroup)->handle('c'); + + UserGroupAPI::shouldReceive('find')->with('a')->andReturn($groupA); + UserGroupAPI::shouldReceive('find')->with('b')->andReturn($groupB); + UserGroupAPI::shouldReceive('find')->with('c')->andReturn($groupC); + + $user = $this->createPermissible(); + $user->addToGroup('a'); + + $this->assertEquals(['a'], $user->get('groups')); + + $user->addToGroup(['a', 'b', 'c']); + + $this->assertEquals(['a', 'b', 'c'], $user->get('groups')); + } + + #[Test] + public function it_clones_internal_collections() + { + $user = $this->user(); + $user->set('foo', 'A'); + $user->setSupplement('bar', 'A'); + + $clone = clone $user; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $user->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $user->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php new file mode 100644 index 00000000000..38d35f835d3 --- /dev/null +++ b/tests/Auth/ForgotPasswordTest.php @@ -0,0 +1,90 @@ +set('app.url', 'http://absolute-url-resolved-from-request.com'); + } + + #[Test] + #[DataProvider('externalUrlProvider')] + public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal) + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], + 'c' => ['name' => 'C', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $this->simulateSuccessfulPasswordResetEmail(); + + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $response = $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => $url, + ]); + + if ($isExternal) { + $response->assertSessionHasErrors(['_reset_url']); + + return; + } + + $response->assertSessionHasNoErrors(); + } + + #[Test] + public function it_allows_reset_url_for_current_request_domain_when_not_in_sites_config() + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + ]); + + $this->simulateSuccessfulPasswordResetEmail(); + + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $this + ->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'http://absolute-url-resolved-from-request.com/some-slug', + ]) + ->assertSessionHasNoErrors(); + } + + protected function simulateSuccessfulPasswordResetEmail() + { + $success = new class + { + public function sendResetLink() + { + return Password::RESET_LINK_SENT; + } + }; + + Password::shouldReceive('broker')->andReturn($success); + } +} diff --git a/tests/Auth/LoginTest.php b/tests/Auth/LoginTest.php new file mode 100644 index 00000000000..16d77a7ee73 --- /dev/null +++ b/tests/Auth/LoginTest.php @@ -0,0 +1,147 @@ +get(cp_route('login')) + ->assertOk() + ->assertViewIs('statamic::auth.login'); + } + + #[Test] + public function it_doesnt_show_the_login_page_when_authenticated() + { + $this + ->actingAs($this->user()) + ->get(cp_route('login')) + ->assertRedirect(cp_route('index')); + } + + #[Test] + public function it_allows_logging_in() + { + $user = $this->user(); + + $this + ->assertGuest() + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + 'remember' => true, + ]) + ->assertRedirect(cp_route('index')); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_doesnt_allow_logging_in_with_invalid_credentials() + { + $user = $this->user(); + + $this + ->assertGuest() + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'invalid-password', + 'remember' => true, + ]) + ->assertSessionHasErrors(['email']); + + $this->assertGuest(); + } + + #[Test] + public function it_redirects_to_referer_url() + { + $user = $this->user(); + + $this + ->assertGuest() + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + 'referer' => 'http://localhost/cp/cp/collections', + ]) + ->assertRedirect('http://localhost/cp/cp/collections'); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_redirects_to_intended_url() + { + $user = $this->user(); + + $this + ->assertGuest() + ->session(['url.intended' => 'http://localhost/cp/cp/collections']) + ->post(cp_route('login'), [ + 'email' => $user->email(), + 'password' => 'secret', + ]) + ->assertRedirect('http://localhost/cp/cp/collections'); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_can_logout() + { + $this + ->actingAs($this->user()) + ->get(cp_route('logout')) + ->assertRedirect('/'); + + $this->assertGuest(); + } + + #[Test] + public function it_can_logout_with_redirect() + { + $this + ->actingAs($this->user()) + ->get(cp_route('logout').'?redirect=/cp') + ->assertRedirect('/cp'); + + $this->assertGuest(); + } + + #[Test] + public function it_does_not_redirect_to_external_url_on_logout() + { + $this + ->actingAs($this->user()) + ->get(cp_route('logout').'?redirect=https://evil.com') + ->assertRedirect('/'); + + $this->assertGuest(); + } + + #[Test] + public function it_cant_logout_when_unauthenticated() + { + $this + ->get(cp_route('logout')) + ->assertRedirect(); + + $this->assertGuest(); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('david@hasselhoff.com')->password('secret'))->save(); + } +} diff --git a/tests/Auth/Protect/PasswordEntryTest.php b/tests/Auth/Protect/PasswordEntryTest.php index cd8a89e1dd3..371912b833e 100644 --- a/tests/Auth/Protect/PasswordEntryTest.php +++ b/tests/Auth/Protect/PasswordEntryTest.php @@ -110,16 +110,16 @@ public static function localPasswordProvider() { return [ 'string' => [ - 'value' => 'the-local-password', - 'submitted' => 'the-local-password', + 'the-local-password', + 'the-local-password', ], 'array with single value' => [ - 'value' => ['the-local-password'], - 'submitted' => 'the-local-password', + ['the-local-password'], + 'the-local-password', ], 'array with multiple values' => [ - 'value' => ['first-local-password', 'second-local-password'], - 'submitted' => 'second-local-password', + ['first-local-password', 'second-local-password'], + 'second-local-password', ], ]; } diff --git a/tests/Auth/Protect/PasswordProtectionTest.php b/tests/Auth/Protect/PasswordProtectionTest.php index 973896d43ce..9d998e05cdd 100644 --- a/tests/Auth/Protect/PasswordProtectionTest.php +++ b/tests/Auth/Protect/PasswordProtectionTest.php @@ -35,6 +35,27 @@ public function redirects_to_password_form_url_and_generates_token() ]); } + #[Test] + public function redirects_to_password_form_url_and_generates_token_on_a_404_url() + { + config()->set('statamic.protect.default', 'password-scheme'); + config(['statamic.protect.schemes.password-scheme' => [ + 'driver' => 'password', + 'allowed' => ['test'], + ]]); + + Token::shouldReceive('generate')->andReturn('test-token'); + + $this + ->get('this-page-does-not-exist') + ->assertRedirect('http://localhost/!/protect/password?token=test-token') + ->assertSessionHas('statamic:protect:password.tokens.test-token', [ + 'scheme' => 'password-scheme', + 'url' => 'http://localhost/this-page-does-not-exist', + 'reference' => null, + ]); + } + #[Test] public function password_form_url_can_be_overridden() { diff --git a/tests/Auth/Protect/ProtectionTest.php b/tests/Auth/Protect/ProtectionTest.php index 38005fa7c3e..febdfa3eb42 100644 --- a/tests/Auth/Protect/ProtectionTest.php +++ b/tests/Auth/Protect/ProtectionTest.php @@ -147,6 +147,46 @@ public function protect() $this->assertTrue($state->protected); } + #[Test] + public function protector_driver_allows_static_caching() + { + config(['statamic.protect.default' => 'test']); + config(['statamic.protect.schemes.test' => [ + 'driver' => 'test', + ]]); + + app(ProtectorManager::class)->extend('test', function ($app) { + return new class() extends Protector + { + public function protect() + { + // + } + + public function cacheable() + { + return true; + } + }; + }); + + $this->assertTrue($this->protection->cacheable()); + $this->assertTrue($this->protection->driver()->cacheable()); + } + + #[Test] + public function protector_driver_disallows_static_caching() + { + config(['statamic.protect.default' => 'logged_in']); + config(['statamic.protect.schemes.logged_in' => [ + 'driver' => 'auth', + 'form_url' => '/login', + ]]); + + $this->assertFalse($this->protection->cacheable()); + $this->assertFalse($this->protection->driver()->cacheable()); + } + private function createEntryWithScheme($scheme) { return EntryFactory::id('test') diff --git a/tests/Auth/UserContractTests.php b/tests/Auth/UserContractTests.php index 4e953e228fb..13c753bd6a1 100644 --- a/tests/Auth/UserContractTests.php +++ b/tests/Auth/UserContractTests.php @@ -66,7 +66,7 @@ public function gets_the_name() $this->assertEquals('John Smith', $this->makeUser()->set('name', 'John Smith')->name()); $this->assertEquals('John', $this->makeUser()->data(['name' => null, 'first_name' => 'John'])->name()); $this->assertEquals('John Smith', $this->makeUser()->data(['name' => null, 'first_name' => 'John', 'last_name' => 'Smith'])->name()); - $this->assertEquals('john@example.com', $this->makeUser()->remove('name')->email('john@example.com')->name()); + $this->assertNull($this->makeUser()->remove('name')->email('john@example.com')->name()); } #[Test] @@ -94,6 +94,15 @@ public function it_gets_custom_computed_data() return $user->name().'\'s balance is $25 owing.'; }); + Facades\User::computed([ + 'ocupation' => function ($user) { + return 'Smuggler'; + }, + 'vehicle' => function ($user) { + return 'Millennium Falcon'; + }, + ]); + $user = $this->makeUser()->data(['name' => 'Han Solo']); $expectedData = [ @@ -102,6 +111,8 @@ public function it_gets_custom_computed_data() $expectedComputedData = [ 'balance' => 'Han Solo\'s balance is $25 owing.', + 'ocupation' => 'Smuggler', + 'vehicle' => 'Millennium Falcon', ]; $expectedValues = array_merge($expectedData, $expectedComputedData); @@ -313,11 +324,11 @@ public function it_gets_initials_from_name_with_no_surname() } #[Test] - public function it_gets_initials_from_email_if_name_doesnt_exist() + public function it_gets_question_mark_initials_if_name_doesnt_exist() { $user = $this->user()->remove('name'); - $this->assertEquals('J', $user->initials()); + $this->assertEquals('?', $user->initials()); } #[Test] diff --git a/tests/Auth/UserGroupTest.php b/tests/Auth/UserGroupTest.php index 99f76bff8a2..2d48b1bce1f 100644 --- a/tests/Auth/UserGroupTest.php +++ b/tests/Auth/UserGroupTest.php @@ -374,4 +374,22 @@ public function it_gets_blueprint_values() $this->assertEquals($group->get('one'), $data['one']); $this->assertEquals($group->get('two'), $data['two']); } + + #[Test] + public function it_clones_internal_collections() + { + $group = UserGroup::make(); + $group->set('foo', 'A'); + $group->setSupplement('bar', 'A'); + + $clone = clone $group; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $group->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $group->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/CP/Navigation/ActiveNavItemTest.php b/tests/CP/Navigation/ActiveNavItemTest.php index 5f344c902c8..544ecddfefc 100644 --- a/tests/CP/Navigation/ActiveNavItemTest.php +++ b/tests/CP/Navigation/ActiveNavItemTest.php @@ -110,17 +110,9 @@ public function it_updates_caches_when_new_child_urls_are_detected_after_resolvi // Now let's create a new collection Facades\Collection::make('products')->title('Products')->save(); - // Simply building the nav should change what is cached - $collectionsChildrenUrls = [ - 'http://localhost/cp/collections/articles', - 'http://localhost/cp/collections/pages', - ]; - $this->assertEquals($collectionsChildrenUrls, Cache::get(NavBuilder::UNRESOLVED_CHILDREN_URLS_CACHE_KEY)->get('content::collections')); - $this->assertEquals($collectionsChildrenUrls, Blink::get(NavBuilder::UNRESOLVED_CHILDREN_URLS_CACHE_KEY)->get('content::collections')); - collect($collectionsChildrenUrls)->each(function ($url) { - $this->assertTrue(Cache::get(NavBuilder::ALL_URLS_CACHE_KEY)->contains($url)); - $this->assertTrue(Blink::get(NavBuilder::ALL_URLS_CACHE_KEY)->contains($url)); - }); + // The InvalidateNavCache subscriber will clear the URLs cache. + $this->assertNull(Cache::get(NavBuilder::UNRESOLVED_CHILDREN_URLS_CACHE_KEY)); + $this->assertNull(Blink::get(NavBuilder::UNRESOLVED_CHILDREN_URLS_CACHE_KEY)); // But if we build the nav again by hitting collections url to resolve its' children, the caches should get updated $this @@ -196,6 +188,25 @@ public function it_resolves_core_children_closure_and_can_check_when_parent_and_ $this->assertTrue($this->getItemByDisplay($collections->children(), 'Articles')->isActive()); } + #[Test] + public function it_resolves_core_children_closure_and_can_check_when_parent_and_descendant_of_parent_item_is_active() + { + Facades\Collection::make('pages')->title('Pages')->save(); + Facades\Collection::make('articles')->title('Articles')->save(); + + $this + ->prepareNavCaches() + ->get('http://localhost/cp/collections/create') + ->assertStatus(200); + + $collections = $this->buildAndGetItem('Content', 'Collections'); + + $this->assertTrue($collections->isActive()); + $this->assertInstanceOf(Collection::class, $collections->children()); + $this->assertFalse($collections->children()->keyBy->display()->get('Pages')->isActive()); + $this->assertFalse($collections->children()->keyBy->display()->get('Articles')->isActive()); + } + #[Test] public function it_resolves_core_children_closure_and_can_check_when_parent_and_descendant_of_child_item_is_active() { @@ -517,6 +528,25 @@ public function it_properly_handles_various_edge_cases_when_checking_is_active_o $this->assertFalse($externalSecure->isActive()); } + #[Test] + public function active_nav_descendant_url_still_functions_properly_when_parent_item_has_no_children() + { + Facades\CP\Nav::extend(function ($nav) { + $nav->tools('Schopify')->url('/cp/totally-custom-url'); + }); + + $this + ->prepareNavCaches() + ->get('http://localhost/cp/totally-custom-url/deeper/descendant') + ->assertStatus(200); + + $toolsItems = $this->build()->get('Tools'); + + $this->assertTrue($this->getItemByDisplay($toolsItems, 'Schopify')->isActive()); + $this->assertFalse($this->getItemByDisplay($toolsItems, 'Addons')->isActive()); + $this->assertFalse($this->getItemByDisplay($toolsItems, 'Utilities')->isActive()); + } + #[Test] public function active_nav_check_still_functions_properly_when_custom_nav_extension_hijacks_a_core_item_child() { diff --git a/tests/CP/Navigation/NavPreferencesTest.php b/tests/CP/Navigation/NavPreferencesTest.php index bdeaffffe75..7d7fe7776c0 100644 --- a/tests/CP/Navigation/NavPreferencesTest.php +++ b/tests/CP/Navigation/NavPreferencesTest.php @@ -5,12 +5,14 @@ use Illuminate\Support\Facades\Request; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades; +use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class NavPreferencesTest extends TestCase { use Concerns\HashedIdAssertions; + use FakesRoles; use PreventSavingStacheItemsToDisk; protected $shouldPreventNavBeingBuilt = false; @@ -1694,9 +1696,58 @@ public function it_checks_active_status_on_moved_items() $this->assertTrue($tags->isActive()); } - private function buildNavWithPreferences($preferences, $withHidden = false) + #[Test] + public function it_can_hide_items_the_user_does_not_have_permission_to_see() { - $this->actingAs(tap(Facades\User::make()->makeSuper())->save()); + $preferences = [ + 'favourites' => [ + 'display' => 'Favourites', + 'items' => [ + 'content::collections::articles' => '@alias', + 'content::collections::pages' => '@alias', + ], + ], + 'author_section' => [ + 'display' => 'Authors', + 'items' => [ + 'content::collections::articles' => '@move', + ], + ], + 'webmaster_section' => [ + 'display' => 'Webmasters', + 'items' => [ + 'content::collections::pages' => '@move', + ], + ], + ]; + + // A super user can see these items... + $nav = $this->buildNavWithPreferences($preferences); + $this->assertCount(2, $nav->get('Favourites')); + $this->assertTrue($nav->get('Favourites')->keyBy->display()->has('Articles')); + $this->assertTrue($nav->get('Favourites')->keyBy->display()->has('Pages')); + $this->assertCount(1, $nav->get('Authors')); + $this->assertTrue($nav->get('Authors')->keyBy->display()->has('Articles')); + $this->assertCount(1, $nav->get('Webmasters')); + $this->assertTrue($nav->get('Webmasters')->keyBy->display()->has('Pages')); + + // But an author with permissions to only view articles... + $this->setTestRoles(['author' => ['view articles entries']]); + $user = Facades\User::make()->assignRole('author'); + + // Should not see pages related section and/or items... + $nav = $this->buildNavWithPreferences($preferences, user: $user); + $this->assertCount(1, $nav->get('Favourites')); + $this->assertTrue($nav->get('Favourites')->keyBy->display()->has('Articles')); + $this->assertFalse($nav->get('Favourites')->keyBy->display()->has('Pages')); + $this->assertCount(1, $nav->get('Authors')); + $this->assertTrue($nav->get('Authors')->keyBy->display()->has('Articles')); + $this->assertFalse($nav->has('Webmasters')); + } + + private function buildNavWithPreferences($preferences, $withHidden = false, $user = null) + { + $this->actingAs(tap($user ?? Facades\User::make()->makeSuper())->save()); return Facades\CP\Nav::build($preferences, $withHidden)->pluck('items', 'display'); } diff --git a/tests/CP/Navigation/NavTest.php b/tests/CP/Navigation/NavTest.php index 91834f1d861..713e8e0ff01 100644 --- a/tests/CP/Navigation/NavTest.php +++ b/tests/CP/Navigation/NavTest.php @@ -130,14 +130,15 @@ public function it_can_create_a_nav_item_with_a_custom_inline_svg_icon() $this->actingAs(tap(User::make()->makeSuper())->save()); Nav::utilities('Test') - ->icon(''); + ->icon(''); $item = $this->build()->get('Utilities')->last(); - $expected = ''; + $expectedIcon = ''; + $this->assertEquals($expectedIcon, $item->icon()); - $this->assertEquals($expected, $item->icon()); - $this->assertEquals($expected, $item->svg()); + $expectedSvg = ''; + $this->assertEquals($expectedSvg, $item->svg()); } #[Test] diff --git a/tests/CP/StartPageTest.php b/tests/CP/StartPageTest.php new file mode 100644 index 00000000000..f05bbf6a5d4 --- /dev/null +++ b/tests/CP/StartPageTest.php @@ -0,0 +1,46 @@ +setPreference('start_page', 'collections/pages')->save(); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get('/cp') + ->assertRedirect('/cp/collections/pages'); + } + + #[Test] + public function it_falls_back_to_start_page_config_option_when_preference_is_missing() + { + config('statamic.cp.start_page', 'dashboard'); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get('/cp') + ->assertRedirect('/cp/dashboard'); + } +} diff --git a/tests/Composer/ComposerTest.php b/tests/Composer/ComposerTest.php index 119974e6e1e..a88b2edac63 100644 --- a/tests/Composer/ComposerTest.php +++ b/tests/Composer/ComposerTest.php @@ -36,6 +36,13 @@ public function setUp(): void public function tearDown(): void { + // If the test was skipped, avoid trying to clean up. The setUp would've never happened. + if (! $this->files) { + parent::tearDown(); + + return; + } + $this->files->deleteDirectory($this->basePath('tmp')); $this->files->deleteDirectory($this->basePath('vendor')); $this->files->delete($this->basePath('composer.json')); diff --git a/tests/Composer/__fixtures__/composer.lock b/tests/Composer/__fixtures__/composer.lock index cdc2775464d..41e739903c2 100644 --- a/tests/Composer/__fixtures__/composer.lock +++ b/tests/Composer/__fixtures__/composer.lock @@ -8,28 +8,28 @@ "packages": [ { "name": "composer/ca-bundle", - "version": "1.2.11", + "version": "1.5.9", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582" + "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", - "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/1905981ee626e6f852448b7aaa978f8666c5bc54", + "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54", "shasum": "" }, "require": { "ext-openssl": "*", "ext-pcre": "*", - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "psr/log": "^1.0", - "symfony/phpunit-bridge": "^4.2 || ^5", - "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8 || ^9", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, "type": "library", "extra": { @@ -64,7 +64,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.2.11" + "source": "https://github.com/composer/ca-bundle/tree/1.5.9" }, "funding": [ { @@ -74,67 +74,148 @@ { "url": "https://github.com/composer", "type": "github" + } + ], + "time": "2025-11-06T11:46:17+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.6.2", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "ba9f089655d4cdd64e762a6044f411ccdaec0076" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/ba9f089655d4cdd64e762a6044f411ccdaec0076", + "reference": "ba9f089655d4cdd64e762a6044f411ccdaec0076", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.6.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" }, { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" + "url": "https://github.com/composer", + "type": "github" } ], - "time": "2021-09-25T20:32:43+00:00" + "time": "2025-08-20T18:52:43+00:00" }, { "name": "composer/composer", - "version": "2.2.12", + "version": "2.9.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "ba61e768b410736efe61df01b61f1ec44f51474f" + "reference": "35cb6d47d03b0cae52dc12d686f941365b20f08b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/ba61e768b410736efe61df01b61f1ec44f51474f", - "reference": "ba61e768b410736efe61df01b61f1ec44f51474f", + "url": "https://api.github.com/repos/composer/composer/zipball/35cb6d47d03b0cae52dc12d686f941365b20f08b", + "reference": "35cb6d47d03b0cae52dc12d686f941365b20f08b", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.0", + "composer/ca-bundle": "^1.5", + "composer/class-map-generator": "^1.4.0", "composer/metadata-minifier": "^1.0", - "composer/pcre": "^1.0", - "composer/semver": "^3.0", - "composer/spdx-licenses": "^1.2", - "composer/xdebug-handler": "^2.0 || ^3.0", - "justinrainbow/json-schema": "^5.2.11", - "php": "^5.3.2 || ^7.0 || ^8.0", - "psr/log": "^1.0 || ^2.0", - "react/promise": "^1.2 || ^2.7", + "composer/pcre": "^2.3 || ^3.3", + "composer/semver": "^3.3", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "ext-json": "*", + "justinrainbow/json-schema": "^6.5.1", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^3.3", "seld/jsonlint": "^1.4", - "seld/phar-utils": "^1.0", - "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", - "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", + "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" }, "require-dev": { - "phpspec/prophecy": "^1.10", - "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0" + "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "phpstan/phpstan-symfony": "^1.4.0", + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" }, "suggest": { - "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", - "ext-zip": "Enabling the zip extension allows you to unzip archives", - "ext-zlib": "Allow gzip compression of HTTP requests" + "ext-curl": "Provides HTTP support (will fallback to PHP streams if missing)", + "ext-openssl": "Enables access to repositories and packages over HTTPS", + "ext-zip": "Allows direct extraction of ZIP archives (unzip/7z binaries will be used instead if available)", + "ext-zlib": "Enables gzip for HTTP requests" }, "bin": [ "bin/composer" ], "type": "library", "extra": { + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] + }, "branch-alias": { - "dev-main": "2.2-dev" + "dev-main": "2.9-dev" } }, "autoload": { "psr-4": { - "Composer\\": "src/Composer" + "Composer\\": "src/Composer/" } }, "notification-url": "https://packagist.org/downloads/", @@ -163,7 +244,8 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.2.12" + "security": "https://github.com/composer/composer/security/policy", + "source": "https://github.com/composer/composer/tree/2.9.1" }, "funding": [ { @@ -173,13 +255,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2022-04-13T14:42:25+00:00" + "time": "2025-11-13T15:10:38+00:00" }, { "name": "composer/metadata-minifier", @@ -252,30 +330,38 @@ }, { "name": "composer/pcre", - "version": "1.0.1", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560" + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/67a32d7d6f9f560b726ab25a061b38ff3a80c560", - "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.3", - "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { - "dev-main": "1.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { @@ -303,7 +389,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/1.0.1" + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { @@ -319,28 +405,28 @@ "type": "tidelift" } ], - "time": "2022-01-21T20:24:37+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { "name": "composer/semver", - "version": "3.2.5", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/31f3ea725711245195f62e54ffa402d8ef2fdba9", - "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12.54", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { @@ -382,9 +468,9 @@ "versioning" ], "support": { - "irc": "irc://irc.freenode.org/composer", + "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.2.5" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -394,33 +480,30 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2021-05-24T12:41:47+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/spdx-licenses", - "version": "1.5.5", + "version": "1.5.9", "source": { "type": "git", "url": "https://github.com/composer/spdx-licenses.git", - "reference": "de30328a7af8680efdc03e396aad24befd513200" + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/de30328a7af8680efdc03e396aad24befd513200", - "reference": "de30328a7af8680efdc03e396aad24befd513200", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { @@ -461,9 +544,9 @@ "validator" ], "support": { - "irc": "irc://irc.freenode.org/composer", + "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.5" + "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" }, "funding": [ { @@ -479,29 +562,31 @@ "type": "tidelift" } ], - "time": "2020-12-03T16:04:16+00:00" + "time": "2025-05-12T21:07:07+00:00" }, { "name": "composer/xdebug-handler", - "version": "2.0.2", + "version": "3.0.5", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339" + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/84674dd3a7575ba617f5a76d7e9e29a7d3891339", - "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0", + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", "autoload": { @@ -525,9 +610,9 @@ "performance" ], "support": { - "irc": "irc://irc.freenode.org/composer", + "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/2.0.2" + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, "funding": [ { @@ -543,29 +628,34 @@ "type": "tidelift" } ], - "time": "2021-07-31T17:03:58+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { "name": "justinrainbow/json-schema", - "version": "5.2.11", + "version": "6.6.1", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa" + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "fd8e5c6b1badb998844ad34ce0abcd71a0aeb396" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa", - "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/fd8e5c6b1badb998844ad34ce0abcd71a0aeb396", + "reference": "fd8e5c6b1badb998844ad34ce0abcd71a0aeb396", "shasum": "" }, "require": { - "php": ">=5.3.3" + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "^23.2", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" }, "bin": [ "bin/validate-json" @@ -573,7 +663,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -604,35 +694,113 @@ } ], "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ "json", "schema" ], "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11" + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.1" + }, + "time": "2025-11-07T18:30:29+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" }, - "time": "2021-07-22T09:24:00+00:00" + "time": "2025-09-14T11:18:39+00:00" }, { "name": "psr/container", - "version": "1.1.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { "Psr\\Container\\": "src/" @@ -659,36 +827,36 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.1" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2021-03-05T17:36:06+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -709,38 +877,39 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "react/promise", - "version": "v2.8.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4" + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/f3cff96a19736714524ca0dd1d4130de73dbbbc4", - "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "^7.0 || ^6.5 || ^5.7 || ^4.8.36" + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", "autoload": { - "psr-4": { - "React\\Promise\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "React\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -749,7 +918,23 @@ "authors": [ { "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com" + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], "description": "A lightweight implementation of CommonJS Promises/A for PHP", @@ -759,29 +944,36 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v2.8.0" + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, - "time": "2020-05-12T15:16:56+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" }, { "name": "seld/jsonlint", - "version": "1.8.3", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57" + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9ad6ce79c342fbd44df10ea95511a1b24dee5b57", - "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", "shasum": "" }, "require": { "php": "^5.3 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" }, "bin": [ "bin/jsonlint" @@ -800,7 +992,7 @@ { "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "homepage": "https://seld.be" } ], "description": "JSON Linter", @@ -812,7 +1004,7 @@ ], "support": { "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.8.3" + "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" }, "funding": [ { @@ -824,20 +1016,20 @@ "type": "tidelift" } ], - "time": "2020-11-11T09:19:24+00:00" + "time": "2024-07-11T14:55:45+00:00" }, { "name": "seld/phar-utils", - "version": "1.1.2", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0" + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/749042a2315705d2dfbbc59234dd9ceb22bf3ff0", - "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", "shasum": "" }, "require": { @@ -870,9 +1062,70 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.1.2" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + }, + "time": "2022-08-31T10:31:18+00:00" + }, + { + "name": "seld/signal-handler", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\Signal\\": "src/" + } }, - "time": "2021-08-19T21:01:38+00:00" + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", + "keywords": [ + "posix", + "sigint", + "signal", + "sigterm", + "unix" + ], + "support": { + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" + }, + "time": "2023-09-03T09:24:00+00:00" }, { "name": "statamic/composer-test-example-dependency", @@ -890,52 +1143,47 @@ }, { "name": "symfony/console", - "version": "v5.3.7", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8b1008344647462ae6ec57559da166c2bfa5e16a" + "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8b1008344647462ae6ec57559da166c2bfa5e16a", - "reference": "8b1008344647462ae6ec57559da166c2bfa5e16a", + "url": "https://api.github.com/repos/symfony/console/zipball/13d3176cf8ad8ced24202844e9f95af11e2959fc", + "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.8", - "symfony/polyfill-php80": "^1.16", - "symfony/service-contracts": "^1.1|^2", - "symfony/string": "^5.1" + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" }, "conflict": { - "psr/log": ">=3", - "symfony/dependency-injection": "<4.4", - "symfony/dotenv": "<5.1", - "symfony/event-dispatcher": "<4.4", - "symfony/lock": "<4.4", - "symfony/process": "<4.4" + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" }, "provide": { - "psr/log-implementation": "1.0|2.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2", - "symfony/config": "^4.4|^5.0", - "symfony/dependency-injection": "^4.4|^5.0", - "symfony/event-dispatcher": "^4.4|^5.0", - "symfony/lock": "^4.4|^5.0", - "symfony/process": "^4.4|^5.0", - "symfony/var-dumper": "^4.4|^5.0" - }, - "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { @@ -964,12 +1212,12 @@ "homepage": "https://symfony.com", "keywords": [ "cli", - "command line", + "command-line", "console", "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.3.7" + "source": "https://github.com/symfony/console/tree/v6.4.27" }, "funding": [ { @@ -980,38 +1228,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-08-25T20:02:16+00:00" + "time": "2025-10-06T10:25:16+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627", - "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "2.4-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { @@ -1036,7 +1288,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -1052,26 +1304,29 @@ "type": "tidelift" } ], - "time": "2021-03-23T23:28:01+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/filesystem", - "version": "v5.3.4", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32" + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/343f4fe324383ca46792cae728a3b6e2f708fb32", - "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.1", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" }, "type": "library", "autoload": { @@ -1099,7 +1354,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.3.4" + "source": "https://github.com/symfony/filesystem/tree/v6.4.24" }, "funding": [ { @@ -1110,30 +1365,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-07-21T12:40:44+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "symfony/finder", - "version": "v5.3.7", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "a10000ada1e600d109a6c7632e9ac42e8bf2fb93" + "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/a10000ada1e600d109a6c7632e9ac42e8bf2fb93", - "reference": "a10000ada1e600d109a6c7632e9ac42e8bf2fb93", + "url": "https://api.github.com/repos/symfony/finder/zipball/a1b6aa435d2fba50793b994a839c32b6064f063b", + "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" }, "type": "library", "autoload": { @@ -1161,7 +1422,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.3.7" + "source": "https://github.com/symfony/finder/tree/v6.4.27" }, "funding": [ { @@ -1172,50 +1433,54 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-08-04T21:20:46+00:00" + "time": "2025-10-15T18:32:00+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.23.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" }, "suggest": { "ext-ctype": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1240,7 +1505,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -1251,50 +1516,51 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.23.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/16880ba9c5ebe3642d1995ab866db29270b36535", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1321,7 +1587,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -1332,50 +1598,51 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.23.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -1405,7 +1672,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -1416,50 +1683,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.23.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" }, "suggest": { "ext-mbstring": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1485,7 +1757,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -1496,47 +1768,48 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.23.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -1564,7 +1837,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" }, "funding": [ { @@ -1575,47 +1848,48 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.23.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/1100343ed1a92e3a38f9ae122fc0eb21602547be", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -1647,7 +1921,87 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" }, "funding": [ { @@ -1658,30 +2012,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-07-28T13:41:28+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v5.3.7", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "38f26c7d6ed535217ea393e05634cb0b244a1967" + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/38f26c7d6ed535217ea393e05634cb0b244a1967", - "reference": "38f26c7d6ed535217ea393e05634cb0b244a1967", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.1" }, "type": "library", "autoload": { @@ -1709,7 +2066,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.3.7" + "source": "https://github.com/symfony/process/tree/v6.4.26" }, "funding": [ { @@ -1720,48 +2077,56 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-08-04T21:20:46+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.4.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", - "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "symfony/service-implementation": "" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "2.4-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { "psr-4": { "Symfony\\Contracts\\Service\\": "" - } + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1788,7 +2153,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.4.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1799,49 +2164,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-04-01T10:43:52+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v5.3.7", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "8d224396e28d30f81969f083a58763b8b9ceb0a5" + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/8d224396e28d30f81969f083a58763b8b9ceb0a5", - "reference": "8d224396e28d30f81969f083a58763b8b9ceb0a5", + "url": "https://api.github.com/repos/symfony/string/zipball/5621f039a71a11c87c106c1c598bdcd04a19aeea", + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.1", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "~1.15" + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^4.4|^5.0", - "symfony/http-client": "^4.4|^5.0", - "symfony/translation-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.4|^5.0" + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { - "psr-4": { - "Symfony\\Component\\String\\": "" - }, "files": [ "Resources/functions.php" ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, "exclude-from-classmap": [ "/Tests/" ] @@ -1871,7 +2242,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.3.7" + "source": "https://github.com/symfony/string/tree/v6.4.26" }, "funding": [ { @@ -1882,12 +2253,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-08-26T08:00:08+00:00" + "time": "2025-09-11T14:32:46+00:00" } ], "packages-dev": [ @@ -1908,10 +2283,10 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/tests/Composer/__fixtures__/vendor.tar.gz b/tests/Composer/__fixtures__/vendor.tar.gz index 3536f890fa5..93b76ba479d 100644 Binary files a/tests/Composer/__fixtures__/vendor.tar.gz and b/tests/Composer/__fixtures__/vendor.tar.gz differ diff --git a/tests/Console/Commands/StaticWarmTest.php b/tests/Console/Commands/StaticWarmTest.php index daa5eb03d32..3520413a93d 100644 --- a/tests/Console/Commands/StaticWarmTest.php +++ b/tests/Console/Commands/StaticWarmTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StaticWarmJob; +use Statamic\Console\Commands\StaticWarmUncachedJob; use Statamic\Facades\Collection; use Statamic\StaticCaching\Cacher; use Tests\PreventSavingStacheItemsToDisk; @@ -44,7 +45,7 @@ public function it_warms_the_static_cache() } #[Test] - public function it_only_visits_uncached_urls_when_the_eco_option_is_used() + public function it_only_visits_uncached_urls_when_the_uncached_option_is_used() { $mock = Mockery::mock(Cacher::class); $mock->shouldReceive('hasCachedPage')->times(2)->andReturn(true, false); @@ -58,6 +59,99 @@ public function it_only_visits_uncached_urls_when_the_eco_option_is_used() ->assertExitCode(0); } + #[Test] + public function it_only_visits_included_urls() + { + config(['statamic.static_caching.strategy' => 'half']); + + $this->createPage('blog'); + $this->createPage('news'); + + Collection::make('blog') + ->routes('/blog/{slug}') + ->template('default') + ->save(); + + Collection::make('news') + ->routes('/news/{slug}') + ->template('default') + ->save(); + + EntryFactory::slug('post-1')->collection('blog')->id('blog-post-1')->create(); + EntryFactory::slug('post-2')->collection('blog')->id('blog-post-2')->create(); + EntryFactory::slug('article-1')->collection('news')->id('news-article-1')->create(); + EntryFactory::slug('article-2')->collection('news')->id('news-article-2')->create(); + EntryFactory::slug('article-3')->collection('news')->id('news-article-3')->create(); + + $this->artisan('statamic:static:warm', ['--include' => '/blog/post-1,/news/*']) + ->expectsOutput('Visiting 4 URLs...') + ->assertExitCode(0); + } + + #[Test] + public function it_doesnt_visit_excluded_urls() + { + config(['statamic.static_caching.strategy' => 'half']); + + $this->createPage('blog'); + $this->createPage('news'); + + Collection::make('blog') + ->routes('/blog/{slug}') + ->template('default') + ->save(); + + Collection::make('news') + ->routes('/news/{slug}') + ->template('default') + ->save(); + + EntryFactory::slug('post-1')->collection('blog')->id('blog-post-1')->create(); + EntryFactory::slug('post-2')->collection('blog')->id('blog-post-2')->create(); + EntryFactory::slug('article-1')->collection('news')->id('news-article-1')->create(); + EntryFactory::slug('article-2')->collection('news')->id('news-article-2')->create(); + EntryFactory::slug('article-3')->collection('news')->id('news-article-3')->create(); + + $this->artisan('statamic:static:warm', ['--exclude' => '/about,/contact,/blog/*,/news/article-2']) + ->expectsOutput('Visiting 4 URLs...') + ->assertExitCode(0); + } + + #[Test] + public function it_respects_max_depth() + { + config(['statamic.static_caching.strategy' => 'half']); + + Collection::make('blog') + ->routes('/awesome/blog/{slug}') + ->template('default') + ->save(); + + Collection::make('news') + ->routes('/news/{slug}') + ->template('default') + ->save(); + + EntryFactory::slug('post-1')->collection('blog')->id('blog-post-1')->create(); + EntryFactory::slug('post-2')->collection('blog')->id('blog-post-2')->create(); + EntryFactory::slug('post-3')->collection('blog')->id('blog-post-3')->create(); + EntryFactory::slug('article-1')->collection('news')->id('news-article-1')->create(); + + $this->artisan('statamic:static:warm', ['--max-depth' => 2]) + ->expectsOutput('Visiting 3 URLs...') + ->assertExitCode(0); + } + + #[Test] + public function it_limits_the_number_of_requests_when_max_requests_is_set() + { + config(['statamic.static_caching.strategy' => 'half']); + + $this->artisan('statamic:static:warm', ['--max-requests' => 1]) + ->expectsOutput('Visiting 1 URLs...') + ->assertExitCode(0); + } + #[Test] public function it_doesnt_queue_the_requests_when_connection_is_set_to_sync() { @@ -90,6 +184,59 @@ public function it_queues_the_requests() }); } + #[Test] + public function it_queues_the_request_when_the_uncached_option_is_used() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'queue.default' => 'redis', + ]); + + Queue::fake(); + + $this->artisan('statamic:static:warm', ['--queue' => true, '--uncached' => true]) + ->expectsOutputToContain('Adding 2 requests') + ->assertExitCode(0); + + Queue::assertCount(2); + + Queue::assertPushed(StaticWarmUncachedJob::class, function ($job) { + return $job->request->getUri()->getPath() === '/about'; + }); + Queue::assertPushed(StaticWarmUncachedJob::class, function ($job) { + return $job->request->getUri()->getPath() === '/contact'; + }); + } + + #[Test] + public function it_doesnt_queue_the_request_when_the_uncached_option_is_used_and_the_page_is_cached() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'queue.default' => 'redis', + ]); + + $mock = Mockery::mock(Cacher::class); + $mock->shouldReceive('hasCachedPage')->times(2)->andReturn(true, false); + $mock->allows('isExcluded')->andReturn(false); + app()->instance(Cacher::class, $mock); + + Queue::fake(); + + $this->artisan('statamic:static:warm', ['--queue' => true, '--uncached' => true]) + ->expectsOutputToContain('Adding 1 requests') + ->assertExitCode(0); + + Queue::assertCount(1); + + Queue::assertNotPushed(StaticWarmUncachedJob::class, function ($job) { + return $job->request->getUri()->getPath() === '/about'; + }); + Queue::assertPushed(StaticWarmUncachedJob::class, function ($job) { + return $job->request->getUri()->getPath() === '/contact'; + }); + } + #[Test, DataProvider('queueConnectionsProvider')] public function it_queues_the_requests_with_appropriate_queue_and_connection( $configuredQueue, @@ -124,6 +271,25 @@ public static function queueConnectionsProvider() ]; } + #[Test] + public function it_sets_custom_headers_on_requests() + { + config(['statamic.static_caching.strategy' => 'half']); + + $mock = Mockery::mock(\GuzzleHttp\Client::class); + $mock->shouldReceive('send')->andReturnUsing(function ($request) { + $this->assertEquals('Bearer testtoken', $request->getHeaderLine('Authorization')); + $this->assertEquals('Bar', $request->getHeaderLine('X-Foo')); + + return Mockery::mock(\GuzzleHttp\Psr7\Response::class); + }); + $this->app->instance(\GuzzleHttp\Client::class, $mock); + + $this->artisan('statamic:static:warm', [ + '--header' => ['Authorization: Bearer testtoken', 'X-Foo: Bar'], + ])->assertExitCode(0); + } + private function createPage($slug, $attributes = []) { $this->makeCollection()->save(); diff --git a/tests/Console/Commands/StaticWarmUncachedJobTest.php b/tests/Console/Commands/StaticWarmUncachedJobTest.php new file mode 100644 index 00000000000..5b5459a53bb --- /dev/null +++ b/tests/Console/Commands/StaticWarmUncachedJobTest.php @@ -0,0 +1,53 @@ + $handlerStack]); + + $job->handle(); + + $this->assertEquals('/about', $mock->getLastRequest()->getUri()->getPath()); + } + + #[Test] + public function it_does_not_send_a_request_if_the_page_is_cached() + { + $mockCacher = Mockery::mock(Cacher::class); + $mockCacher->shouldReceive('hasCachedPage')->once()->andReturn(true); + $mockCacher->allows('isExcluded')->andReturn(false); + app()->instance(Cacher::class, $mockCacher); + + $mock = new MockHandler([ + new Response(200), + ]); + + $handlerStack = HandlerStack::create($mock); + + $job = new StaticWarmUncachedJob(new Request('GET', '/about'), ['handler' => $handlerStack]); + + $job->handle(); + + $this->assertNull($mock->getLastRequest()); + } +} diff --git a/tests/Data/Assets/AssetQueryBuilderTest.php b/tests/Data/Assets/AssetQueryBuilderTest.php index f3cdc8297e8..7513bc9489b 100644 --- a/tests/Data/Assets/AssetQueryBuilderTest.php +++ b/tests/Data/Assets/AssetQueryBuilderTest.php @@ -407,6 +407,78 @@ public function assets_are_found_using_or_where_json_doesnt_contain() $this->assertEquals(['a', 'c', 'b', 'd'], $assets->map->filename()->all()); } + #[Test] + public function assets_are_found_using_where_json_overlaps() + { + Asset::find('test::a.jpg')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Asset::find('test::b.txt')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Asset::find('test::c.txt')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Asset::find('test::d.jpg')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Asset::find('test::e.jpg')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $assets = $this->container->queryAssets()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1', 'taxonomy-5'])->get(); + + $this->assertCount(3, $assets); + $this->assertEquals(['a', 'c', 'e'], $assets->map->filename()->all()); + + $assets = $this->container->queryAssets()->whereJsonOverlaps('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(2, $assets); + $this->assertEquals(['a', 'c'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_are_found_using_where_json_doesnt_overlap() + { + Asset::find('test::a.jpg')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Asset::find('test::b.txt')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Asset::find('test::c.txt')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Asset::find('test::d.jpg')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Asset::find('test::e.jpg')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + Asset::find('test::f.jpg')->data(['test_taxonomy' => ['taxonomy-1']])->save(); + + $assets = $this->container->queryAssets()->whereJsonDoesntOverlap('test_taxonomy', ['taxonomy-1'])->get(); + + $this->assertCount(3, $assets); + $this->assertEquals(['b', 'd', 'e'], $assets->map->filename()->all()); + + $assets = $this->container->queryAssets()->whereJsonDoesntOverlap('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(3, $assets); + $this->assertEquals(['b', 'd', 'e'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_are_found_using_or_where_json_overlaps() + { + Asset::find('test::a.jpg')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Asset::find('test::b.txt')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Asset::find('test::c.txt')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Asset::find('test::d.jpg')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Asset::find('test::e.jpg')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $assets = $this->container->queryAssets()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonOverlaps('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(3, $assets); + $this->assertEquals(['a', 'c', 'e'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_are_found_using_or_where_json_doesnt_overlap() + { + Asset::find('test::a.jpg')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Asset::find('test::b.txt')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Asset::find('test::c.txt')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Asset::find('test::d.jpg')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Asset::find('test::e.jpg')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + Asset::find('test::f.jpg')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $assets = $this->container->queryAssets()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonDoesntOverlap('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(4, $assets); + $this->assertEquals(['a', 'c', 'b', 'd'], $assets->map->filename()->all()); + } + #[Test] public function assets_are_found_using_where_json_length() { diff --git a/tests/Data/Entries/CollectionTest.php b/tests/Data/Entries/CollectionTest.php index a751ec419f6..44060a0d9af 100644 --- a/tests/Data/Entries/CollectionTest.php +++ b/tests/Data/Entries/CollectionTest.php @@ -501,9 +501,11 @@ public function it_saves_the_collection_through_the_api() Facades\Collection::shouldReceive('save')->with($collection)->once(); Facades\Collection::shouldReceive('handleExists')->with('test')->once(); + Facades\Blink::shouldReceive('forget')->with('collection-test-structure')->once(); Facades\Blink::shouldReceive('forget')->with('collection-handles')->once(); Facades\Blink::shouldReceive('forget')->with('mounted-collections')->once(); Facades\Blink::shouldReceive('flushStartingWith')->with('collection-test')->once(); + Facades\Blink::shouldReceive('once')->with('collection-test-structure', \Mockery::any())->andReturnNull(); $return = $collection->save(); diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index 75c6706dbe0..cfaf8b4c2e9 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -9,6 +9,8 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Query\Exceptions\MultipleRecordsFoundException; +use Statamic\Query\Exceptions\RecordsNotFoundException; use Statamic\Query\Scopes\Scope; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -89,6 +91,66 @@ public function entries_are_found_using_or_where_not_in() $this->assertEquals(['Post 3', 'Post 4'], $entries->map->title->all()); } + #[Test] + public function entries_are_found_using_where_in_with_null() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'category' => 'news'])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'category' => 'blog'])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3'])->create(); // category is null + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'category' => 'news'])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5'])->create(); // category is null + + $entries = Entry::query()->whereIn('category', ['news', null])->get(); + + $this->assertCount(4, $entries); + $this->assertEquals(['Post 1', 'Post 3', 'Post 4', 'Post 5'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_in_with_booleans() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'featured' => true])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'featured' => false])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3'])->create(); // featured is null + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'featured' => true])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'featured' => false])->create(); + + $entries = Entry::query()->whereIn('featured', [false, null])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['Post 2', 'Post 3', 'Post 5'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_not_in_with_null() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'category' => 'news'])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'category' => 'blog'])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3'])->create(); // category is null + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'category' => 'news'])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5'])->create(); // category is null + + $entries = Entry::query()->whereNotIn('category', ['news', null])->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Post 2'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_not_in_with_booleans() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'featured' => true])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'featured' => false])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3'])->create(); // featured is null + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'featured' => true])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'featured' => false])->create(); + + $entries = Entry::query()->whereNotIn('featured', [false, null])->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 4'], $entries->map->title->all()); + } + #[Test] public function entries_are_found_using_where_date() { @@ -475,6 +537,76 @@ public function entries_are_found_using_where_json_length() $this->assertEquals(['Post 2', 'Post 5', 'Post 4'], $entries->map->title->all()); } + #[Test] + public function entries_are_found_using_where_json_overlaps() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'test_taxonomy' => ['taxonomy-3']])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->create(); + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'test_taxonomy' => ['taxonomy-5']])->create(); + + $entries = Entry::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1', 'taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['Post 1', 'Post 3', 'Post 5'], $entries->map->title->all()); + + $entries = Entry::query()->whereJsonOverlaps('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_json_doesnt_overlap() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'test_taxonomy' => ['taxonomy-3']])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->create(); + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'test_taxonomy' => ['taxonomy-5']])->create(); + + $entries = Entry::query()->whereJsonDoesntOverlap('test_taxonomy', ['taxonomy-1'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['Post 2', 'Post 4', 'Post 5'], $entries->map->title->all()); + + $entries = Entry::query()->whereJsonDoesntOverlap('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['Post 2', 'Post 4', 'Post 5'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_or_where_json_overlaps() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'test_taxonomy' => ['taxonomy-3']])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->create(); + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'test_taxonomy' => ['taxonomy-5']])->create(); + + $entries = Entry::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonOverlaps('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['Post 1', 'Post 3', 'Post 5'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_or_where_json_doesnt_overlap() + { + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->create(); + EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'test_taxonomy' => ['taxonomy-3']])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->create(); + EntryFactory::id('4')->slug('post-4')->collection('posts')->data(['title' => 'Post 4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->create(); + EntryFactory::id('5')->slug('post-5')->collection('posts')->data(['title' => 'Post 5', 'test_taxonomy' => ['taxonomy-5']])->create(); + + $entries = Entry::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonDoesntOverlap('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(4, $entries); + $this->assertEquals(['Post 1', 'Post 3', 'Post 2', 'Post 4'], $entries->map->title->all()); + } + #[Test] public function entries_are_found_using_array_of_wheres() { @@ -696,6 +828,116 @@ public function entries_are_found_using_offset() $this->assertEquals(['Post 2', 'Post 3'], $entries->map->title->all()); } + #[Test] + public function entries_are_found_using_where_has_when_max_items_1() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries', 'max_items' => 1]]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => 'id-2', + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => 'id-1', + ]) + ->save(); + + $entries = Entry::query()->whereHas('entries_field')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Post 1'], $entries->map->title->all()); + + $entries = Entry::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 2', 'Post 3'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_has_when_max_items_not_1() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => ['id-2', 'id-1'], + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => ['id-1', 'id-2'], + ]) + ->save(); + + $entries = Entry::query()->whereHas('entries_field')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Post 2'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_relation() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => ['id-2', 'id-1'], + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => ['id-1', 'id-2'], + ]) + ->save(); + + $entries = Entry::query()->whereRelation('entries_field', 'title', 'Post 2')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + } + #[Test] #[DataProvider('likeProvider')] public function entries_are_found_using_like($like, $expected) @@ -922,6 +1164,112 @@ public function values_can_be_plucked() 'thing-2', ], Entry::query()->where('type', 'b')->pluck('slug')->all()); } + + #[Test] + public function entry_can_be_found_using_first_or_fail() + { + Collection::make('posts')->save(); + $entry = EntryFactory::collection('posts')->id('hoff')->slug('david-hasselhoff')->data(['title' => 'David Hasselhoff'])->create(); + + $firstOrFail = Entry::query() + ->where('collection', 'posts') + ->where('id', 'hoff') + ->firstOrFail(); + + $this->assertSame($entry, $firstOrFail); + } + + #[Test] + public function exception_is_thrown_when_entry_does_not_exist_using_first_or_fail() + { + $this->expectException(RecordsNotFoundException::class); + + Entry::query() + ->where('collection', 'posts') + ->where('id', 'ze-hoff') + ->firstOrFail(); + } + + #[Test] + public function entry_can_be_found_using_first_or() + { + Collection::make('posts')->save(); + $entry = EntryFactory::collection('posts')->id('hoff')->slug('david-hasselhoff')->data(['title' => 'David Hasselhoff'])->create(); + + $firstOrFail = Entry::query() + ->where('collection', 'posts') + ->where('id', 'hoff') + ->firstOr(function () { + return 'fallback'; + }); + + $this->assertSame($entry, $firstOrFail); + } + + #[Test] + public function callback_is_called_when_entry_does_not_exist_using_first_or() + { + $firstOrFail = Entry::query() + ->where('collection', 'posts') + ->where('id', 'hoff') + ->firstOr(function () { + return 'fallback'; + }); + + $this->assertSame('fallback', $firstOrFail); + } + + #[Test] + public function sole_entry_is_returned() + { + Collection::make('posts')->save(); + $entry = EntryFactory::collection('posts')->id('hoff')->slug('david-hasselhoff')->data(['title' => 'David Hasselhoff'])->create(); + + $sole = Entry::query() + ->where('collection', 'posts') + ->where('id', 'hoff') + ->sole(); + + $this->assertSame($entry, $sole); + } + + #[Test] + public function exception_is_thrown_by_sole_when_multiple_entries_are_returned_from_query() + { + Collection::make('posts')->save(); + EntryFactory::collection('posts')->id('hoff')->slug('david-hasselhoff')->data(['title' => 'David Hasselhoff'])->create(); + EntryFactory::collection('posts')->id('smoff')->slug('joe-hasselsmoff')->data(['title' => 'Joe Hasselsmoff'])->create(); + + $this->expectException(MultipleRecordsFoundException::class); + + Entry::query() + ->where('collection', 'posts') + ->sole(); + } + + #[Test] + public function exception_is_thrown_by_sole_when_no_entries_are_returned_from_query() + { + $this->expectException(RecordsNotFoundException::class); + + Entry::query() + ->where('collection', 'posts') + ->sole(); + } + + #[Test] + public function exists_returns_true_when_results_are_found() + { + $this->createDummyCollectionAndEntries(); + + $this->assertTrue(Entry::query()->exists()); + } + + #[Test] + public function exists_returns_false_when_no_results_are_found() + { + $this->assertFalse(Entry::query()->exists()); + } } class CustomScope extends Scope diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index f99532f921b..583fb05001b 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use Statamic\Contracts\Data\Augmentable; +use Statamic\Contracts\Entries\QueryBuilder; use Statamic\Data\AugmentedCollection; use Statamic\Entries\AugmentedEntry; use Statamic\Entries\Collection; @@ -391,6 +392,15 @@ public function it_gets_custom_computed_data() return $entry->get('title').' AND MORE!'; }); + Facades\Collection::computed('articles', [ + 'tags' => function ($entry) { + return ['music', 'pop']; + }, + 'featured' => function ($entry) { + return true; + }, + ]); + $collection = tap(Collection::make('articles'))->save(); $entry = (new Entry)->collection($collection)->data(['title' => 'Pop Rocks']); @@ -400,6 +410,8 @@ public function it_gets_custom_computed_data() $expectedComputedData = [ 'description' => 'Pop Rocks AND MORE!', + 'tags' => ['music', 'pop'], + 'featured' => true, ]; $expectedValues = array_merge($expectedData, $expectedComputedData); @@ -1495,6 +1507,69 @@ public function it_propagates_entry_if_configured() }); } + #[Test] + public function it_doesnt_fire_events_when_propagating_entry_and_saved_quietly() + { + Event::fake(); + + $this->setSites([ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => 'http://test.com/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => 'http://fr.test.com/'], + 'es' => ['name' => 'Spanish', 'locale' => 'es_ES', 'url' => 'http://test.com/es/'], + 'de' => ['name' => 'German', 'locale' => 'de_DE', 'url' => 'http://test.com/de/'], + ]); + + $collection = (new Collection) + ->handle('pages') + ->propagate(true) + ->sites(['en', 'fr', 'de']) + ->save(); + + $entry = (new Entry) + ->id('a') + ->locale('en') + ->collection($collection); + + $return = $entry->saveQuietly(); + + $this->assertIsObject($fr = $entry->descendants()->get('fr')); + $this->assertIsObject($de = $entry->descendants()->get('de')); + $this->assertNull($entry->descendants()->get('es')); // collection not configured for this site + + Event::assertDispatchedTimes(EntrySaving::class, 0); + Event::assertNotDispatched(EntrySaving::class, function ($event) use ($entry) { + return $event->entry === $entry; + }); + Event::assertNotDispatched(EntrySaving::class, function ($event) use ($fr) { + return $event->entry === $fr; + }); + Event::assertNotDispatched(EntrySaving::class, function ($event) use ($de) { + return $event->entry === $de; + }); + + Event::assertDispatchedTimes(EntryCreated::class, 0); + Event::assertNotDispatched(EntryCreated::class, function ($event) use ($entry) { + return $event->entry === $entry; + }); + Event::assertNotDispatched(EntryCreated::class, function ($event) use ($fr) { + return $event->entry === $fr; + }); + Event::assertNotDispatched(EntryCreated::class, function ($event) use ($de) { + return $event->entry === $de; + }); + + Event::assertDispatchedTimes(EntrySaved::class, 0); + Event::assertNotDispatched(EntrySaved::class, function ($event) use ($entry) { + return $event->entry === $entry; + }); + Event::assertNotDispatched(EntrySaved::class, function ($event) use ($fr) { + return $event->entry === $fr; + }); + Event::assertNotDispatched(EntrySaved::class, function ($event) use ($de) { + return $event->entry === $de; + }); + } + #[Test] public function it_propagates_entry_from_non_default_site_if_configured() { @@ -1727,7 +1802,12 @@ public function it_gets_file_contents_for_saving_a_localized_entry() $originEntry = $this->mock(Entry::class); $originEntry->shouldReceive('id')->andReturn('123'); - Facades\Entry::shouldReceive('find')->with('123')->andReturn($originEntry); + $builder = $this->mock(QueryBuilder::class); + $builder->shouldReceive('where')->with('collection', 'test')->andReturnSelf(); + $builder->shouldReceive('where')->with('id', 123)->andReturnSelf(); + $builder->shouldReceive('first')->andReturn($originEntry); + Facades\Entry::shouldReceive('query')->andReturn($builder); + $originEntry->shouldReceive('values')->andReturn(collect([])); $originEntry->shouldReceive('blueprint')->andReturn( $this->mock(Blueprint::class)->shouldReceive('handle')->andReturn('test')->getMock() @@ -1805,13 +1885,17 @@ public function the_blueprint_is_not_added_to_the_localized_file_contents() $originEntry = $this->mock(Entry::class); $originEntry->shouldReceive('id')->andReturn('123'); - - Facades\Entry::shouldReceive('find')->with('123')->andReturn($originEntry); $originEntry->shouldReceive('values')->andReturn(collect([])); $originEntry->shouldReceive('blueprint')->andReturn( $this->mock(Blueprint::class)->shouldReceive('handle')->andReturn('another')->getMock() ); + $builder = $this->mock(QueryBuilder::class); + $builder->shouldReceive('where')->with('collection', 'test')->andReturnSelf(); + $builder->shouldReceive('where')->with('id', 123)->andReturnSelf(); + $builder->shouldReceive('first')->andReturn($originEntry); + Facades\Entry::shouldReceive('query')->andReturn($builder); + $entry = (new Entry) ->collection('test') ->origin('123'); // do not set blueprint. @@ -1831,13 +1915,17 @@ public function the_blueprint_is_added_to_the_localized_file_contents_if_explici $originEntry = $this->mock(Entry::class); $originEntry->shouldReceive('id')->andReturn('123'); - - Facades\Entry::shouldReceive('find')->with('123')->andReturn($originEntry); $originEntry->shouldReceive('values')->andReturn(collect([])); $originEntry->shouldReceive('blueprint')->andReturn( $this->mock(Blueprint::class)->shouldReceive('handle')->andReturn('another')->getMock() ); + $builder = $this->mock(QueryBuilder::class); + $builder->shouldReceive('where')->with('collection', 'test')->andReturnSelf(); + $builder->shouldReceive('where')->with('id', 123)->andReturnSelf(); + $builder->shouldReceive('first')->andReturn($originEntry); + Facades\Entry::shouldReceive('query')->andReturn($builder); + $entry = (new Entry) ->collection('test') ->origin('123') @@ -2572,4 +2660,49 @@ public function initially_saved_entry_gets_put_into_events() ['7', '7'], ], $events->map(fn ($event) => [$event->entry->id(), $event->initiator->id()])->all()); } + + #[Test] + public function it_clones_internal_collections() + { + $entry = EntryFactory::collection('test')->create(); + $entry->set('foo', 'A'); + $entry->setSupplement('bar', 'A'); + + $clone = clone $entry; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $entry->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $entry->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } + + #[Test] + public function entries_can_be_serialized_after_resolving_values() + { + $entry = EntryFactory::id('entry-id') + ->collection('test') + ->slug('entry-slug') + ->create(); + + $customEntry = CustomEntry::fromEntry($entry); + + $serialized = serialize($customEntry); + $unserialized = unserialize($serialized); + + $this->assertSame('entry-slug', $unserialized->slug); + } +} + +class CustomEntry extends Entry +{ + public static function fromEntry(Entry $entry) + { + return (new static) + ->slug($entry->slug) + ->collection($entry->collection) + ->data($entry->data); + } } diff --git a/tests/Data/Globals/VariablesTest.php b/tests/Data/Globals/VariablesTest.php index a5dd9ec6892..5066b31ef0b 100644 --- a/tests/Data/Globals/VariablesTest.php +++ b/tests/Data/Globals/VariablesTest.php @@ -387,4 +387,23 @@ public function augment($values) 'charlie' => ['augmented c', 'augmented d'], ], Arr::only($variables->selectedQueryRelations(['charlie'])->toArray(), ['alfa', 'bravo', 'charlie'])); } + + #[Test] + public function it_clones_internal_collections() + { + $global = GlobalSet::make('test'); + $variables = $global->makeLocalization('en'); + $variables->set('foo', 'A'); + $variables->setSupplement('bar', 'A'); + + $clone = clone $variables; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $variables->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $variables->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/Data/StoresComputedFieldCallbacksTest.php b/tests/Data/StoresComputedFieldCallbacksTest.php index 326814e1804..b2caccbd98a 100644 --- a/tests/Data/StoresComputedFieldCallbacksTest.php +++ b/tests/Data/StoresComputedFieldCallbacksTest.php @@ -26,6 +26,26 @@ public function it_can_store_computed_callback() 'another_field' => $closureB, ], $repository->getComputedCallbacks()->all()); } + + #[Test] + public function it_can_store_multiple_computed_callbacks() + { + $repository = new FakeRepository; + + $repository->computed([ + 'some_field' => $closureA = function ($item, $value) { + // + }, + 'another_field' => $closureB = function ($item, $value) { + // + }, + ]); + + $this->assertEquals([ + 'some_field' => $closureA, + 'another_field' => $closureB, + ], $repository->getComputedCallbacks()->all()); + } } class FakeRepository diff --git a/tests/Data/StoresScopedComputedFieldCallbacksTest.php b/tests/Data/StoresScopedComputedFieldCallbacksTest.php index cbedb2ab8bf..4d8d5c9eee5 100644 --- a/tests/Data/StoresScopedComputedFieldCallbacksTest.php +++ b/tests/Data/StoresScopedComputedFieldCallbacksTest.php @@ -54,6 +54,63 @@ public function it_can_store_scoped_computed_callbacks_for_multiple_scopes() 'some_field' => $closure, ], $repository->getComputedCallbacks('articles')->all()); } + + #[Test] + public function it_can_store_multiple_scoped_computed_callbacks() + { + $repository = new FakeRepositoryWithScopedCallbacks; + + $repository->computed('events', [ + 'some_field' => $closureA = function ($item, $value) { + // + }, + ]); + + $repository->computed('articles', [ + 'some_field' => $closureB = function ($item, $value) { + // + }, + 'another_field' => $closureC = function ($item, $value) { + // + }, + ]); + + $this->assertEquals([ + 'some_field' => $closureA, + ], $repository->getComputedCallbacks('events')->all()); + + $this->assertEquals([ + 'some_field' => $closureB, + 'another_field' => $closureC, + ], $repository->getComputedCallbacks('articles')->all()); + + $this->assertEquals([], $repository->getComputedCallbacks('products')->all()); + } + + #[Test] + public function it_can_store_multiple_scoped_computed_callbacks_for_multiple_scopes() + { + $repository = new FakeRepositoryWithScopedCallbacks; + + $repository->computed(['events', 'articles'], [ + 'some_field' => $closureA = function ($item, $value) { + // + }, + 'another_field' => $closureB = function ($item, $value) { + // + }, + ]); + + $this->assertEquals([ + 'some_field' => $closureA, + 'another_field' => $closureB, + ], $repository->getComputedCallbacks('events')->all()); + + $this->assertEquals([ + 'some_field' => $closureA, + 'another_field' => $closureB, + ], $repository->getComputedCallbacks('articles')->all()); + } } class FakeRepositoryWithScopedCallbacks diff --git a/tests/Data/Structures/NavTest.php b/tests/Data/Structures/NavTest.php index 47270813c66..dd51840347a 100644 --- a/tests/Data/Structures/NavTest.php +++ b/tests/Data/Structures/NavTest.php @@ -216,6 +216,29 @@ public function collections_can_be_get_and_set() $this->assertEquals([$collectionOne, $collectionTwo], $collections->all()); } + #[Test] + public function collections_query_scopes_can_be_get_and_set() + { + $nav = $this->structure(); + + $this->assertEquals([], $nav->collectionsQueryScopes()); + + $return = $nav->collectionsQueryScopes(['scope_one', 'scope_two']); + + $this->assertSame($nav, $return); + $this->assertEquals(['scope_one', 'scope_two'], $nav->collectionsQueryScopes()); + } + + #[Test] + public function collections_query_scopes_are_normalized() + { + $nav = $this->structure(); + + $nav->collectionsQueryScopes(['ScopeOne', 'scope_two', '', null, 'ScopeOne']); + + $this->assertEquals(['scope_one', 'scope_two'], $nav->collectionsQueryScopes()); + } + #[Test] public function it_has_cp_urls() { diff --git a/tests/Data/Structures/PageTest.php b/tests/Data/Structures/PageTest.php index ea10a1d7d3c..83d42d3a630 100644 --- a/tests/Data/Structures/PageTest.php +++ b/tests/Data/Structures/PageTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Structures\Nav; use Statamic\Entries\Entry; +use Statamic\Facades\Collection as Collections; use Statamic\Facades\Entry as EntryAPI; use Statamic\Structures\CollectionStructure; use Statamic\Structures\Page; @@ -528,10 +529,37 @@ public function it_is_arrayable() ->each(fn ($value, $key) => $this->assertEquals($value, $page->{$key})) ->each(fn ($value, $key) => $this->assertEquals($value, $page[$key])); - $this->assertEquals($page->collection()->toArray(), $arr['collection']); + $this->assertEquals($page->collection->toArray(), $arr['collection']); $this->assertEquals($page->blueprint->toArray(), $arr['blueprint']); } + #[Test] + public function it_gets_collection_and_mounted_collection() + { + Collections::make('pages')->save(); + Collections::make('blog')->mount('blog-page')->save(); + Collections::make('events')->save(); + + $blogPageEntry = EntryFactory::id('blog-page')->collection('pages')->create(); + $blogPostEntry = EntryFactory::id('blog-post-one')->collection('blog')->create(); + $eventEntry = EntryFactory::id('event-one')->collection('events')->create(); + + $tree = $this->mock(Tree::class); + $tree->shouldReceive('entry')->with('blog-page')->andReturn($blogPageEntry); + $tree->shouldReceive('structure')->andReturnNull(); // just make the blueprint method quiet for now. + + $page = new Page; + $page->setTree($tree); + $page->setEntry($blogPageEntry); + $page->setId($blogPageEntry->id()); // In reality the tree would set this. + $this->assertEquals('blog', $page->mountedCollection()->handle()); + $this->assertEquals('pages', $page->collection->handle()); + + // This should be "pages" but cannot be fixed without being a breaking change. + // This will change in v6. + $this->assertEquals('blog', $page->collection()->handle()); + } + protected function newTree() { return new class extends Tree diff --git a/tests/Data/Taxonomies/TaxonomyTest.php b/tests/Data/Taxonomies/TaxonomyTest.php index 6d502da2626..05d685bb34f 100644 --- a/tests/Data/Taxonomies/TaxonomyTest.php +++ b/tests/Data/Taxonomies/TaxonomyTest.php @@ -18,7 +18,6 @@ use Statamic\Facades; use Statamic\Facades\Collection; use Statamic\Facades\Entry; -use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Fields\Blueprint; use Statamic\Taxonomies\Taxonomy; @@ -291,6 +290,24 @@ public function it_trucates_terms() $this->assertCount(0, $taxonomy->queryTerms()->get()); } + #[Test] + public function it_get_terms_count_from_multi_sites() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => '/', 'locale' => 'fr_FR', 'name' => 'French'], + 'de' => ['url' => '/', 'locale' => 'de_DE', 'name' => 'German'], + ]); + + $taxonomy = tap(Facades\Taxonomy::make('tags')->sites(['en', 'fr', 'de']))->save(); + Facades\Term::make()->taxonomy('tags')->slug('one')->data([])->save(); + Facades\Term::make()->taxonomy('tags')->slug('two')->data([])->save(); + Facades\Term::make()->taxonomy('tags')->slug('three')->data([])->save(); + + $this->assertCount(9, $taxonomy->queryTerms()->get()); + $this->assertEquals(3, $taxonomy->queryTerms()->pluck('slug')->unique()->count()); + } + #[Test] public function it_saves_through_the_api() { diff --git a/tests/Data/Taxonomies/TermQueryBuilderTest.php b/tests/Data/Taxonomies/TermQueryBuilderTest.php index b7136ede318..5d1afe80f8e 100644 --- a/tests/Data/Taxonomies/TermQueryBuilderTest.php +++ b/tests/Data/Taxonomies/TermQueryBuilderTest.php @@ -573,6 +573,80 @@ public function terms_are_found_using_or_where_json_doesnt_contain() $this->assertEquals(['1', '3', '2', '4'], $entries->map->slug()->all()); } + #[Test] + public function terms_are_found_using_where_json_overlaps() + { + Taxonomy::make('tags')->save(); + Term::make('1')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Term::make('2')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Term::make('3')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Term::make('4')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Term::make('5')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = Term::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1', 'taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['1', '3', '5'], $entries->map->slug()->all()); + + $entries = Term::query()->whereJsonOverlaps('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['1', '3'], $entries->map->slug()->all()); + } + + #[Test] + public function terms_are_found_using_where_json_doesnt_overlap() + { + Taxonomy::make('tags')->save(); + Term::make('1')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Term::make('2')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Term::make('3')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Term::make('4')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Term::make('5')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = Term::query()->whereJsonDoesntOverlap('test_taxonomy', ['taxonomy-1'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['2', '4', '5'], $entries->map->slug()->all()); + + $entries = Term::query()->whereJsonDoesntOverlap('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['2', '4', '5'], $entries->map->slug()->all()); + } + + #[Test] + public function terms_are_found_using_or_where_json_overlaps() + { + Taxonomy::make('tags')->save(); + Term::make('1')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Term::make('2')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Term::make('3')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Term::make('4')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Term::make('5')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = Term::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonOverlaps('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['1', '3', '5'], $entries->map->slug()->all()); + } + + #[Test] + public function terms_are_found_using_or_where_json_doesnt_overlap() + { + Taxonomy::make('tags')->save(); + Term::make('1')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + Term::make('2')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3']])->save(); + Term::make('3')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + Term::make('4')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + Term::make('5')->taxonomy('tags')->data(['test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = Term::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonDoesntOverlap('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(4, $entries); + $this->assertEquals(['1', '3', '2', '4'], $entries->map->slug()->all()); + } + #[Test] public function terms_are_found_using_where_json_length() { @@ -618,6 +692,89 @@ public function terms_are_found_using_offset() $terms = Term::query()->offset(1)->get(); $this->assertEquals(['b', 'c'], $terms->map->slug()->all()); } + + #[Test] + public function terms_are_found_using_where_has_when_max_items_1() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'max_items' => 1, 'taxonomies' => ['tags']]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => 'a'])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => 'b'])->save(); + + $terms = Term::query()->whereHas('terms_field')->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['b', 'c'], $terms->map->slug->all()); + + $terms = Term::query()->whereHas('terms_field', function ($subquery) { + $subquery->where('title', 'a'); + }) + ->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['b'], $terms->map->slug->all()); + + $terms = Term::query()->whereDoesntHave('terms_field', function ($subquery) { + $subquery->where('title', 'a'); + }) + ->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['a', 'c'], $terms->map->slug->all()); + } + + #[Test] + public function terms_are_found_using_where_has_when_max_items_not_1() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'taxonomies' => ['tags']]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => ['a', 'c']])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => ['b', 'a']])->save(); + + $terms = Term::query()->whereHas('terms_field')->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['b', 'c'], $terms->map->slug->all()); + + $terms = Term::query()->whereHas('terms_field', function ($subquery) { + $subquery->where('slug', 'b'); + }) + ->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['c'], $terms->map->slug->all()); + + $terms = Term::query()->whereDoesntHave('terms_field', function ($subquery) { + $subquery->where('title', 'b'); + }) + ->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['a', 'b'], $terms->map->slug->all()); + } + + #[Test] + public function terms_are_found_using_where_relation() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'max_items' => 1, 'taxonomies' => ['tags']]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => ['a', 'c']])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => ['b', 'a']])->save(); + + $terms = Term::query()->whereRelation('terms_field', 'slug', 'b')->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['c'], $terms->map->slug->all()); + } } class CustomScope extends Scope diff --git a/tests/Data/Taxonomies/TermTest.php b/tests/Data/Taxonomies/TermTest.php index b2352c3bb7e..4397d6daa9e 100644 --- a/tests/Data/Taxonomies/TermTest.php +++ b/tests/Data/Taxonomies/TermTest.php @@ -481,4 +481,24 @@ public function it_deletes_quietly() $this->assertTrue($return); } + + #[Test] + public function it_clones_internal_collections() + { + $taxonomy = (new TaxonomiesTaxonomy)->handle('tags')->save(); + $term = (new Term)->taxonomy('tags')->slug('foo')->data(['foo' => 'bar'])->inDefaultLocale(); + + $term->set('foo', 'A'); + $term->setSupplement('bar', 'A'); + + $clone = clone $term; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $term->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $term->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/Data/Taxonomies/ViewsTest.php b/tests/Data/Taxonomies/ViewsTest.php index b3175a7c7e9..45463a4e48d 100644 --- a/tests/Data/Taxonomies/ViewsTest.php +++ b/tests/Data/Taxonomies/ViewsTest.php @@ -87,6 +87,14 @@ public function it_loads_the_term_url_if_the_view_exists() $this->get('/tags/test')->assertOk()->assertSeeText('showing Test'); } + #[Test] + public function it_doesnt_load_the_term_url_if_there_are_additional_segments() + { + $this->viewShouldReturnRaw('tags.show', 'showing {{ title }}'); + + $this->get('/tags/test/extra/segments')->assertNotFound(); + } + #[Test] public function it_loads_the_localized_term_url_if_the_view_exists() { @@ -139,6 +147,18 @@ public function the_collection_specific_taxonomy_url_404s_if_the_view_doesnt_exi $this->get('/the-blog/tags/test')->assertNotFound(); } + #[Test] + public function the_collection_specific_taxonomy_url_404s_if_the_collection_is_not_configured() + { + $this->mountBlogPageToBlogCollection(); + + $this->viewShouldReturnRaw('blog.tags.index', '{{ title }} index'); + + $this->blogCollection->taxonomies([])->save(); + + $this->get('/the-blog/tags')->assertNotFound(); + } + #[Test] public function it_loads_the_collection_specific_taxonomy_url_if_the_view_exists() { @@ -157,6 +177,18 @@ public function the_collection_specific_term_url_404s_if_the_view_doesnt_exist() $this->get('/the-blog/tags/test')->assertNotFound(); } + #[Test] + public function the_collection_specific_term_url_404s_if_the_collection_is_not_assigned_to_the_taxonomy() + { + $this->mountBlogPageToBlogCollection(); + + $this->viewShouldReturnRaw('blog.tags.show', 'showing {{ title }}'); + + $this->blogCollection->taxonomies([])->save(); + + $this->get('/the-blog/tags/test')->assertNotFound(); + } + #[Test] public function it_loads_the_collection_specific_term_url_if_the_view_exists() { diff --git a/tests/Data/Users/UserQueryBuilderTest.php b/tests/Data/Users/UserQueryBuilderTest.php index 1787094179e..2ce099cb07b 100644 --- a/tests/Data/Users/UserQueryBuilderTest.php +++ b/tests/Data/Users/UserQueryBuilderTest.php @@ -2,7 +2,10 @@ namespace Tests\Data\Users; +use Facades\Tests\Factories\EntryFactory; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Blueprint; +use Statamic\Facades\Collection; use Statamic\Facades\Role; use Statamic\Facades\User; use Statamic\Facades\UserGroup; @@ -213,6 +216,93 @@ public function users_are_found_using_tap() $this->assertEquals(['Gandalf'], $users->map->name->all()); } + #[Test] + public function users_are_found_using_where_has_when_max_items_1() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries', 'max_items' => 1]]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => 2])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => 1])->save(); + + $entries = User::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Gandalf'], $entries->map->name->all()); + + $entries = User::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Smeagol', 'Frodo'], $entries->map->name->all()); + } + + #[Test] + public function users_are_found_using_where_has_when_max_items_not_1() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => [2, 1]])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => [1, 2]])->save(); + + $users = User::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEquals(['Gandalf', 'Frodo'], $users->map->name->all()); + + $users = User::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals(['Smeagol'], $users->map->name->all()); + } + + #[Test] + public function users_are_found_using_where_relation() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => [2, 1]])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => [1, 2]])->save(); + + $users = User::query()->whereRelation('entries_field', 'title', 'Post 2')->get(); + + $this->assertCount(2, $users); + $this->assertEquals(['Gandalf', 'Frodo'], $users->map->name->all()); + } + + private function createDummyCollectionAndEntries() + { + Collection::make('posts')->save(); + + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'author' => 'John Doe'])->create(); + $entry = EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'author' => 'John Doe'])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'author' => 'John Doe'])->create(); + + return $entry; + } + #[Test] public function users_are_found_using_where_group() { diff --git a/tests/Dictionaries/CurrenciesTest.php b/tests/Dictionaries/CurrenciesTest.php index 0328dce849b..f8b7f57c6fc 100644 --- a/tests/Dictionaries/CurrenciesTest.php +++ b/tests/Dictionaries/CurrenciesTest.php @@ -15,7 +15,7 @@ public function it_gets_options() { $options = (new Currencies)->options(); - $this->assertCount(119, $options); + $this->assertCount(114, $options); $option = $options['USD']; $this->assertEquals('US Dollar (USD)', $option); } @@ -51,7 +51,6 @@ public static function searchProvider() 'USD' => 'US Dollar (USD)', 'BND' => 'Brunei Dollar (BND)', 'TWD' => 'New Taiwan Dollar (TWD)', - 'ZWL' => 'Zimbabwean Dollar (ZWL)', ], ], 'dollar symbol' => [ @@ -72,15 +71,15 @@ public static function searchProvider() 'MOP' => 'Macanese Pataca (MOP)', 'MXN' => 'Mexican Peso (MXN)', 'NAD' => 'Namibian Dollar (NAD)', - 'NIO' => "Nicaraguan C\u00f3rdoba (NIO)", + 'NIO' => 'Nicaraguan Córdoba (NIO)', 'NZD' => 'New Zealand Dollar (NZD)', 'SGD' => 'Singapore Dollar (SGD)', - 'TOP' => "Tongan Pa\u02bbanga (TOP)", + 'TOP' => 'Tongan Paʻanga (TOP)', 'TTD' => 'Trinidad and Tobago Dollar (TTD)', 'TWD' => 'New Taiwan Dollar (TWD)', 'USD' => 'US Dollar (USD)', 'UYU' => 'Uruguayan Peso (UYU)', - 'ZWL' => 'Zimbabwean Dollar (ZWL)', + 'ZWG' => 'Zimbabwe Gold (ZWG)', ], ], 'pound symbol' => [ diff --git a/tests/Extend/AddonTest.php b/tests/Extend/AddonTest.php index 5e5001a40f0..1b05010798d 100644 --- a/tests/Extend/AddonTest.php +++ b/tests/Extend/AddonTest.php @@ -279,6 +279,7 @@ private function makeFromPackage($attributes = []) 'developerUrl' => 'http://test-developer.com', 'version' => '1.0', 'editions' => ['foo', 'bar'], + 'marketplaceId' => null, ], $attributes)); } } diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php new file mode 100644 index 00000000000..867aaf15407 --- /dev/null +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -0,0 +1,72 @@ +assertFalse(URL::isExternal(null)); } + #[Test] + #[DataProvider('externalUrlProvider')] + public function it_determines_if_external_url_to_application($url, $expected) + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], + 'c' => ['name' => 'C', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $this->assertEquals($expected, URL::isExternalToApplication($url)); + } + #[Test] #[DataProvider('ancestorProvider')] public function it_checks_whether_a_url_is_an_ancestor_of_another($child, $parent, $isAncestor) diff --git a/tests/Feature/AssetContainers/ListAssetContainersTest.php b/tests/Feature/AssetContainers/ListAssetContainersTest.php index 71a96c8fd85..d7bad5eda18 100644 --- a/tests/Feature/AssetContainers/ListAssetContainersTest.php +++ b/tests/Feature/AssetContainers/ListAssetContainersTest.php @@ -53,11 +53,8 @@ public function containerArray() [ 'id' => 'three', 'title' => 'Three', - 'allow_downloading' => true, - 'allow_moving' => true, - 'allow_renaming' => true, - 'allow_uploads' => true, - 'create_folders' => true, + 'allow_uploads' => false, + 'create_folders' => false, 'edit_url' => 'http://localhost/cp/asset-containers/three/edit', 'delete_url' => 'http://localhost/cp/asset-containers/three', 'blueprint_url' => 'http://localhost/cp/asset-containers/three/blueprint', @@ -67,11 +64,8 @@ public function containerArray() [ 'id' => 'two', 'title' => 'Two', - 'allow_downloading' => true, - 'allow_moving' => true, - 'allow_renaming' => true, - 'allow_uploads' => true, - 'create_folders' => true, + 'allow_uploads' => false, + 'create_folders' => false, 'edit_url' => 'http://localhost/cp/asset-containers/two/edit', 'delete_url' => 'http://localhost/cp/asset-containers/two', 'blueprint_url' => 'http://localhost/cp/asset-containers/two/blueprint', diff --git a/tests/Feature/Assets/ClearAssetGlideCacheTest.php b/tests/Feature/Assets/ClearAssetGlideCacheTest.php index 9540c2a347a..381f07df8c1 100644 --- a/tests/Feature/Assets/ClearAssetGlideCacheTest.php +++ b/tests/Feature/Assets/ClearAssetGlideCacheTest.php @@ -42,7 +42,7 @@ public function it_clears_when_reuploading() $asset = Mockery::mock(Asset::class); Glide::shouldReceive('clearAsset')->with($asset)->once(); - app(ClearAssetGlideCache::class)->handleReuploaded(new AssetReuploaded($asset)); + app(ClearAssetGlideCache::class)->handleReuploaded(new AssetReuploaded($asset, 'foo.jpg')); } #[Test] diff --git a/tests/Feature/Assets/DownloadAssetTest.php b/tests/Feature/Assets/DownloadAssetTest.php new file mode 100644 index 00000000000..c61323c718b --- /dev/null +++ b/tests/Feature/Assets/DownloadAssetTest.php @@ -0,0 +1,85 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_downloads() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.txt') + ->upload(UploadedFile::fake()->create('one.txt')); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::one.txt').'/download') + ->assertSuccessful() + ->assertDownload('one.txt'); + } + + #[Test] + public function it_404s_when_the_asset_doesnt_exist() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::unknown.txt').'/download') + ->assertNotFound(); + } + + #[Test] + public function it_denies_access_without_permission_to_view_asset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.txt') + ->upload(UploadedFile::fake()->create('one.txt')); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::one.txt').'/download') + ->assertForbidden(); + } +} diff --git a/tests/Feature/Assets/ImageThumbnailTest.php b/tests/Feature/Assets/ImageThumbnailTest.php new file mode 100644 index 00000000000..ba1c65a5575 --- /dev/null +++ b/tests/Feature/Assets/ImageThumbnailTest.php @@ -0,0 +1,84 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_returns_thumbnail() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.png') + ->upload(UploadedFile::fake()->image('one.png')); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/thumbnails/'.base64_encode('test::one.png')) + ->assertSuccessful(); + } + + #[Test] + public function it_404s_when_the_asset_doesnt_exist() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/thumbnails/'.base64_encode('test::unknown.png')) + ->assertNotFound(); + } + + #[Test] + public function it_denies_access_without_permission_to_view_asset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.png') + ->upload(UploadedFile::fake()->image('one.png')); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/thumbnails/'.base64_encode('test::one.png')) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Assets/PdfThumbnailTest.php b/tests/Feature/Assets/PdfThumbnailTest.php new file mode 100644 index 00000000000..61883e1f6bd --- /dev/null +++ b/tests/Feature/Assets/PdfThumbnailTest.php @@ -0,0 +1,84 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_returns_thumbnail() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.pdf') + ->upload(UploadedFile::fake()->createWithContent('one.pdf', ' ')); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/pdfs/'.base64_encode('test::one.pdf')) + ->assertSuccessful(); + } + + #[Test] + public function it_404s_when_the_asset_doesnt_exist() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/pdfs/'.base64_encode('test::unknown.pdf')) + ->assertNotFound(); + } + + #[Test] + public function it_denies_access_without_permission_to_view_asset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.pdf') + ->upload(UploadedFile::fake()->createWithContent('one.pdf', ' ')); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/pdfs/'.base64_encode('test::one.pdf')) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Assets/ReuploadAssetTest.php b/tests/Feature/Assets/ReuploadAssetTest.php index 6d2571cb2b7..46e1ea59a34 100644 --- a/tests/Feature/Assets/ReuploadAssetTest.php +++ b/tests/Feature/Assets/ReuploadAssetTest.php @@ -75,6 +75,6 @@ public function glide_cache_is_cleared_and_presets_are_regenerated_when_reupload Glide::shouldReceive('clearAsset')->withArgs(fn ($arg1) => $arg1->id() === $asset->id())->once()->globally()->ordered(); $this->mock(PresetGenerator::class)->shouldReceive('generate')->withArgs(fn ($arg1) => $arg1->id() === $asset->id())->once()->globally()->ordered(); - AssetReuploaded::dispatch($asset); + AssetReuploaded::dispatch($asset, 'test.jpg'); } } diff --git a/tests/Feature/Assets/ShowAssetTest.php b/tests/Feature/Assets/ShowAssetTest.php new file mode 100644 index 00000000000..1bcd2fa5c19 --- /dev/null +++ b/tests/Feature/Assets/ShowAssetTest.php @@ -0,0 +1,85 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_returns_json() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.txt') + ->upload(UploadedFile::fake()->create('one.txt')); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::one.txt')) + ->assertSuccessful() + ->assertJson(['data' => ['id' => 'test::one.txt']]); + } + + #[Test] + public function it_404s_when_the_asset_doesnt_exist() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::unknown.txt')) + ->assertNotFound(); + } + + #[Test] + public function it_denies_access_without_permission_to_view_asset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.txt') + ->upload(UploadedFile::fake()->create('one.txt')); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/assets/'.base64_encode('test::one.txt')) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Assets/StoreAssetTest.php b/tests/Feature/Assets/StoreAssetTest.php index c417a6d0776..bba5ce6c3e0 100644 --- a/tests/Feature/Assets/StoreAssetTest.php +++ b/tests/Feature/Assets/StoreAssetTest.php @@ -171,7 +171,7 @@ public function it_can_upload_and_overwrite() #[Test] public function it_can_upload_and_append_timestamp() { - Carbon::setTestNow(Carbon::createFromTimestamp(1697379288)); + Carbon::setTestNow(Carbon::createFromTimestamp(1697379288, config('app.timezone'))); Storage::disk('test')->put('path/to/test.jpg', 'contents'); Storage::disk('test')->assertExists('path/to/test.jpg'); $this->assertCount(1, Storage::disk('test')->files('path/to')); diff --git a/tests/Feature/Assets/SvgThumbnailTest.php b/tests/Feature/Assets/SvgThumbnailTest.php new file mode 100644 index 00000000000..13651baf44c --- /dev/null +++ b/tests/Feature/Assets/SvgThumbnailTest.php @@ -0,0 +1,84 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_returns_thumbnail() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.png') + ->upload(UploadedFile::fake()->createWithContent('one.svg', '')); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/svgs/'.base64_encode('test::one.svg')) + ->assertSuccessful(); + } + + #[Test] + public function it_404s_when_the_asset_doesnt_exist() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/svgs/'.base64_encode('test::unknown.svg')) + ->assertNotFound(); + } + + #[Test] + public function it_denies_access_without_permission_to_view_asset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + $container + ->makeAsset('one.svg') + ->upload(UploadedFile::fake()->createWithContent('one.svg', '')); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->getJson('/cp/svgs/'.base64_encode('test::one.svg')) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Entries/AddsHeadersToLivePreviewTest.php b/tests/Feature/Entries/AddsHeadersToLivePreviewTest.php new file mode 100644 index 00000000000..928044a50ee --- /dev/null +++ b/tests/Feature/Entries/AddsHeadersToLivePreviewTest.php @@ -0,0 +1,79 @@ + 'file']); + + EntryFactory::collection('test')->id('1')->slug('alfa')->data(['title' => 'Alfa', 'foo' => 'Alfa foo'])->create(); + + $this->withFakeViews(); + + $this->viewShouldReturnRaw('test', ''); + } + + protected function resolveApplicationConfiguration($app) + { + parent::resolveApplicationConfiguration($app); + + // Use our View::make() to make sure the Cascade is used. + // We'd use Route::statamic() but it isn't available at this point. + Route::get('/test', fn () => View::make('test'))->middleware('statamic.web'); + } + + #[Test] + public function it_doesnt_set_header_when_single_site() + { + $this->setSites(['en' => ['url' => 'http://localhost/', 'locale' => 'en']]); + $substitute = EntryFactory::collection('test')->id('2')->slug('charlie')->data(['title' => 'Substituted title', 'foo' => 'Substituted foo'])->make(); + + LivePreview::tokenize('test-token', $substitute); + + $this->get('/test?token=test-token') + ->assertHeader('X-Statamic-Live-Preview', true) + ->assertHeaderMissing('Content-Security-Policy', true); + } + + #[Test] + public function it_sets_header_when_multisite() + { + config()->set('statamic.system.multisite', true); + + $this->setSites([ + 'one' => ['url' => 'http://withport.com:8080/', 'locale' => 'en'], + 'two' => ['url' => 'http://withport.com:8080/fr/', 'locale' => 'fr'], + 'three' => ['url' => 'http://withoutport.com/', 'locale' => 'en'], + 'four' => ['url' => 'http://withoutport.com/fr/', 'locale' => 'fr'], + 'five' => ['url' => 'http://third.com/', 'locale' => 'en'], + 'six' => ['url' => 'http://third.com/fr/', 'locale' => 'fr'], + ]); + + $substitute = EntryFactory::collection('test')->id('2')->slug('charlie')->data(['title' => 'Substituted title', 'foo' => 'Substituted foo'])->make(); + + LivePreview::tokenize('test-token', $substitute); + + $this->get('/test?token=test-token') + ->assertHeader('X-Statamic-Live-Preview', true) + ->assertHeader('Content-Security-Policy', 'frame-ancestors http://withport.com:8080 http://withoutport.com http://third.com'); + } +} diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index e50009df679..d9d032ead25 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -283,7 +283,7 @@ public function it_restores_a_published_entrys_working_copy_to_another_revision( $revision = tap((new Revision) ->key('collections/blog/en/123') - ->date(Carbon::createFromTimestamp('1553546421')) + ->date(Carbon::createFromTimestamp('1553546421', config('app.timezone'))) ->attributes([ 'published' => false, 'slug' => 'existing-slug', @@ -345,7 +345,7 @@ public function it_restores_an_unpublished_entrys_contents_to_another_revision() $revision = tap((new Revision) ->key('collections/blog/en/123') - ->date(Carbon::createFromTimestamp('1553546421')) + ->date(Carbon::createFromTimestamp('1553546421', config('app.timezone'))) ->attributes([ 'published' => true, 'slug' => 'existing-slug', diff --git a/tests/Feature/Fieldtypes/FilesTest.php b/tests/Feature/Fieldtypes/FilesTest.php index 70b13539ab2..38e9ce2fdbf 100644 --- a/tests/Feature/Fieldtypes/FilesTest.php +++ b/tests/Feature/Fieldtypes/FilesTest.php @@ -44,7 +44,7 @@ public function it_uploads_a_file($container, $isImage, $expectedPath, $expected ? UploadedFile::fake()->image('test.jpg', 50, 75) : UploadedFile::fake()->create('test.txt'); - Date::setTestNow(Date::createFromTimestamp(1671484636)); + Date::setTestNow(Date::createFromTimestamp(1671484636, config('app.timezone'))); $disk = Storage::fake('local'); diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php new file mode 100644 index 00000000000..30cf1ed8795 --- /dev/null +++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php @@ -0,0 +1,68 @@ +collection = Collection::make('test')->save(); + + app('statamic.scopes')[StartsWithC::handle()] = StartsWithC::class; + } + + #[Test] + public function it_filters_entries_by_query_scopes() + { + Entry::make()->collection('test')->slug('apple')->data(['title' => 'Apple'])->save(); + Entry::make()->collection('test')->slug('carrot')->data(['title' => 'Carrot'])->save(); + Entry::make()->collection('test')->slug('cherry')->data(['title' => 'Cherry'])->save(); + Entry::make()->collection('test')->slug('banana')->data(['title' => 'Banana'])->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode([ + 'type' => 'entries', + 'collections' => ['test'], + 'query_scopes' => ['starts_with_c'], + ])); + + $response = $this + ->actingAs($user) + ->get("/cp/fieldtypes/relationship?config={$config}&collections[0]=test") + ->assertOk(); + + $titles = collect($response->json('data'))->pluck('title')->all(); + + $this->assertCount(2, $titles); + $this->assertContains('Carrot', $titles); + $this->assertContains('Cherry', $titles); + $this->assertNotContains('Apple', $titles); + $this->assertNotContains('Banana', $titles); + } +} + +class StartsWithC extends Scope +{ + public function apply($query, $params) + { + $query->where('title', 'like', 'C%'); + } +} diff --git a/tests/Feature/Forms/UpdateFormTest.php b/tests/Feature/Forms/UpdateFormTest.php index 65c1717f0ae..8f449c97f6a 100644 --- a/tests/Feature/Forms/UpdateFormTest.php +++ b/tests/Feature/Forms/UpdateFormTest.php @@ -108,7 +108,7 @@ public function it_updates_emails() ], $updated->email()); } - /** @test */ + #[Test] public function it_updates_data() { $form = tap(Form::make('test'))->save(); diff --git a/tests/Feature/GraphQL/CustomMutationTest.php b/tests/Feature/GraphQL/CustomMutationTest.php new file mode 100644 index 00000000000..74f51b83a87 --- /dev/null +++ b/tests/Feature/GraphQL/CustomMutationTest.php @@ -0,0 +1,142 @@ +instance('mutation-count', 0); + } + + #[Test] + public function custom_mutation_does_not_yet_exist() + { + $this + ->post('/graphql', ['query' => 'mutation { createItem(name: "test") }']) + ->assertJson(['errors' => [[ + 'message' => 'Schema is not configured for mutations.', + ]]]); + } + + #[Test] + #[DefineEnvironment('addCustomMutationsThroughConfig')] + public function a_custom_mutation_can_be_added_to_the_default_schema_through_config() + { + $this + ->post('/graphql', ['query' => 'mutation { createItem(name: "test") }']) + ->assertGqlOk() + ->assertExactJson(['data' => ['createItem' => 'Item created: test']]); + } + + #[Test] + #[DefineEnvironment('addCustomMutationsThroughConfig')] + public function multiple_custom_mutations_can_be_added() + { + $this + ->post('/graphql', ['query' => 'mutation { createItem(name: "first") }']) + ->assertGqlOk() + ->assertExactJson(['data' => ['createItem' => 'Item created: first']]); + + $this + ->post('/graphql', ['query' => 'mutation { updateItem(id: 1, name: "updated") }']) + ->assertGqlOk() + ->assertExactJson(['data' => ['updateItem' => 'Item 1 updated: updated']]); + } + + #[Test] + #[DefineEnvironment('addCustomMutationsThroughConfig')] + public function mutations_are_not_cached() + { + $this + ->post('/graphql', ['query' => 'mutation { createItem(name: "test") }']) + ->assertGqlOk() + ->assertExactJson(['data' => ['createItem' => 'Item created: test']]); + + $this + ->post('/graphql', ['query' => 'mutation { createItem(name: "test") }']) + ->assertGqlOk() + ->assertExactJson(['data' => ['createItem' => 'Item created: test']]); + + $this->assertEquals(2, app('mutation-count')); + } + + protected function addCustomMutationsThroughConfig($app) + { + $app['config']->set('statamic.graphql.mutations', [ + CreateItemMutation::class, + UpdateItemMutation::class, + ]); + } +} + +class CreateItemMutation extends Mutation +{ + protected $attributes = [ + 'name' => 'createItem', + ]; + + public function type(): Type + { + return GraphQL::string(); + } + + public function args(): array + { + return [ + 'name' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The name of the item to create', + ], + ]; + } + + public function resolve($root, $args) + { + app()->instance('mutation-count', app('mutation-count') + 1); + + return 'Item created: '.$args['name']; + } +} + +class UpdateItemMutation extends Mutation +{ + protected $attributes = [ + 'name' => 'updateItem', + ]; + + public function type(): Type + { + return GraphQL::string(); + } + + public function args(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::int()), + 'description' => 'The ID of the item to update', + ], + 'name' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'The new name of the item', + ], + ]; + } + + public function resolve($root, $args) + { + return "Item {$args['id']} updated: {$args['name']}"; + } +} diff --git a/tests/Feature/GraphQL/EntriesTest.php b/tests/Feature/GraphQL/EntriesTest.php index 93fca8c4ee8..e69e57368da 100644 --- a/tests/Feature/GraphQL/EntriesTest.php +++ b/tests/Feature/GraphQL/EntriesTest.php @@ -11,6 +11,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Query\Scopes\Scope; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -949,4 +950,103 @@ public function it_filters_out_past_entries_from_past_private_collection() ['id' => 'a'], ]]]]); } + + #[Test] + public function it_cannot_use_query_scopes_by_default() + { + $this->createEntries(); + + $query = <<<'GQL' +{ + entries(query_scope: { test_scope: { operator: "is", value: 1 }}) { + data { + id + title + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertJson([ + 'errors' => [[ + 'message' => 'validation', + 'extensions' => [ + 'validation' => [ + 'query_scope' => ['Forbidden: test_scope'], + ], + ], + ]], + 'data' => [ + 'entries' => null, + ], + ]); + } + + #[Test] + public function it_can_use_a_query_scope_when_configuration_allows_for_it() + { + $this->createEntries(); + + ResourceAuthorizer::shouldReceive('isAllowed')->with('graphql', 'collections')->andReturnTrue(); + ResourceAuthorizer::shouldReceive('allowedSubResources')->with('graphql', 'collections')->andReturn(Collection::handles()->all()); + ResourceAuthorizer::makePartial(); + + app('statamic.scopes')['test_scope'] = TestScope::class; + + config()->set('statamic.graphql.resources.collections.pages', [ + 'allowed_query_scopes' => ['test_scope'], + ]); + + $query = <<<'GQL' +{ + entries(query_scope: { test_scope: { operator: "is", value: 1 }}) { + data { + id + title + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => ['entries' => ['data' => [ + ['id' => '1', 'title' => 'Standard Blog Post'], + ]]]]); + + $query = <<<'GQL' +{ + entries(query_scope: { test_scope: { operator: "isnt", value: 1 }}) { + data { + id + title + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => ['entries' => ['data' => [ + ['id' => '2', 'title' => 'Art Directed Blog Post'], + ['id' => '3', 'title' => 'Event One'], + ['id' => '4', 'title' => 'Event Two'], + ['id' => '5', 'title' => 'Hamburger'], + ]]]]); + } +} + +class TestScope extends Scope +{ + public function apply($query, $values) + { + $query->where('id', $values['operator'] == 'is' ? '=' : '!=', $values['value']); + } } diff --git a/tests/Feature/GraphQL/EntryTest.php b/tests/Feature/GraphQL/EntryTest.php index c4593d1da15..e54dacdb72d 100644 --- a/tests/Feature/GraphQL/EntryTest.php +++ b/tests/Feature/GraphQL/EntryTest.php @@ -4,6 +4,7 @@ use Facades\Statamic\API\FilterAuthorizer; use Facades\Statamic\API\ResourceAuthorizer; +use Facades\Statamic\CP\LivePreview; use Facades\Statamic\Fields\BlueprintRepository; use Facades\Tests\Factories\EntryFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -755,4 +756,44 @@ public function it_only_shows_published_entries_by_default() 'title' => 'That will be so rad!', ]]]); } + + #[Test] + public function it_only_shows_unpublished_entries_with_token() + { + FilterAuthorizer::shouldReceive('allowedForSubResources') + ->andReturn(['published', 'status']); + + $entry = EntryFactory::collection('blog') + ->id('6') + ->slug('that-was-so-rad') + ->data(['title' => 'That was so rad!']) + ->published(false) + ->create(); + + LivePreview::tokenize('test-token', $entry); + + $query = <<<'GQL' +{ + entry(id: "6") { + id + title + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => ['entry' => null]]); + + $this + ->withoutExceptionHandling() + ->post('/graphql?token=test-token', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => ['entry' => [ + 'id' => '6', + 'title' => 'That was so rad!', + ]]]); + } } diff --git a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php index 4cf18632ce9..776cfee9b2a 100644 --- a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php +++ b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php @@ -5,6 +5,8 @@ use Illuminate\Support\Carbon; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +use ReflectionClass; +use Statamic\Support\Arr; #[Group('graphql')] class DateFieldtypeTest extends FieldtypeTestCase @@ -14,7 +16,17 @@ public function setUp(): void parent::setUp(); Carbon::macro('getToStringFormat', function () { - return static::$toStringFormat; + // Carbon 2.x + if (property_exists(static::this(), 'toStringFormat')) { + return static::$toStringFormat; + } + + // Carbon 3.x + $reflection = new ReflectionClass(self::this()); + $factory = $reflection->getMethod('getFactory'); + $factory->setAccessible(true); + + return Arr::get($factory->invoke(self::this())->getSettings(), 'toStringFormat'); }); } diff --git a/tests/Feature/GraphQL/Fieldtypes/FloatvalFieldtypeTest.php b/tests/Feature/GraphQL/Fieldtypes/FloatvalFieldtypeTest.php new file mode 100644 index 00000000000..bb268c14e7e --- /dev/null +++ b/tests/Feature/GraphQL/Fieldtypes/FloatvalFieldtypeTest.php @@ -0,0 +1,30 @@ +createEntryWithFields([ + 'filled' => [ + 'value' => 7.34, + 'field' => ['type' => 'float'], + ], + 'undefined' => [ + 'value' => null, + 'field' => ['type' => 'float'], + ], + ]); + + $this->assertGqlEntryHas('filled, undefined', [ + 'filled' => 7.34, + 'undefined' => null, + ]); + } +} diff --git a/tests/Feature/GraphQL/FormTest.php b/tests/Feature/GraphQL/FormTest.php index 6ebe30ad225..d3ce69622d4 100644 --- a/tests/Feature/GraphQL/FormTest.php +++ b/tests/Feature/GraphQL/FormTest.php @@ -6,6 +6,7 @@ use Facades\Statamic\Fields\BlueprintRepository; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +use Statamic\Contracts\GraphQL\CastableToValidationString; use Statamic\Facades\Blueprint; use Statamic\Facades\Form; use Tests\PreventSavingStacheItemsToDisk; @@ -121,8 +122,8 @@ public function it_queries_the_fields() 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output', 'width' => 50, ], - 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']], - 'message' => ['type' => 'textarea', 'width' => 33], + 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House'], 'if' => ['name' => 'not empty']], + 'message' => ['type' => 'textarea', 'width' => 33, 'unless' => ['subject' => 'equals spam']], ]); BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint); @@ -137,6 +138,8 @@ public function it_queries_the_fields() instructions width config + if + unless } } } @@ -158,6 +161,8 @@ public function it_queries_the_fields() 'config' => [ 'placeholder' => 'Type here...', ], + 'if' => null, + 'unless' => null, ], [ 'handle' => 'subject', @@ -168,6 +173,8 @@ public function it_queries_the_fields() 'config' => [ 'options' => ['disco' => 'Disco', 'house' => 'House'], ], + 'if' => ['name' => 'not empty'], + 'unless' => null, ], [ 'handle' => 'message', @@ -176,6 +183,8 @@ public function it_queries_the_fields() 'instructions' => null, 'width' => 33, 'config' => [], + 'if' => null, + 'unless' => ['subject' => 'equals spam'], ], ], ], @@ -217,4 +226,161 @@ public function it_queries_the_validation_rules() ], ]]); } + + #[Test] + public function it_queries_the_sections() + { + Form::make('contact')->title('Contact Us')->save(); + + $blueprint = Blueprint::makeFromFields([ + 'name' => [ + 'type' => 'text', + 'display' => 'Your Name', + 'instructions' => 'Enter your name', + 'placeholder' => 'Type here...', + 'invalid' => 'This isnt in the fieldtypes config fields so it shouldnt be output', + 'width' => 50, + ], + 'subject' => ['type' => 'select', 'options' => ['disco' => 'Disco', 'house' => 'House']], + 'message' => ['type' => 'textarea', 'width' => 33], + ]); + + // Set section display and instructions. You wouldn't really do this for a form blueprint, + // but this is just to test the section type which doesn't get tested anywhere else. + $contents = $blueprint->contents(); + $contents['tabs']['main']['sections'][0]['display'] = 'My Section'; + $contents['tabs']['main']['sections'][0]['instructions'] = 'The section instructions'; + $blueprint->setContents($contents); + + BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint); + + $query = <<<'GQL' +{ + form(handle: "contact") { + sections { + display + instructions + fields { + handle + type + display + instructions + width + config + } + } + } +} +GQL; + + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'form' => [ + 'sections' => [ + [ + 'display' => 'My Section', + 'instructions' => 'The section instructions', + 'fields' => [ + [ + 'handle' => 'name', + 'type' => 'text', + 'display' => 'Your Name', + 'instructions' => 'Enter your name', + 'width' => 50, + 'config' => [ + 'placeholder' => 'Type here...', + ], + ], + [ + 'handle' => 'subject', + 'type' => 'select', + 'display' => 'Subject', + 'instructions' => null, + 'width' => 100, + 'config' => [ + 'options' => ['disco' => 'Disco', 'house' => 'House'], + ], + ], + [ + 'handle' => 'message', + 'type' => 'textarea', + 'display' => 'Message', + 'instructions' => null, + 'width' => 33, + 'config' => [], + ], + ], + ], + ], + ], + ]]); + } + + #[Test] + public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dimension_size_and_image() + { + Form::make('contact')->title('Contact Us')->save(); + + $blueprint = Blueprint::makeFromFields([ + 'name' => [ + 'type' => 'assets', + 'display' => 'Asset', + 'validate' => [ + 'mimes:image/jpeg,image/png', + 'mimetypes:image/jpeg,image/png', + 'dimensions:1024', + 'size:1000', + 'image:jpeg', + 'new Tests\Feature\GraphQL\TestValidationRuleWithToString', + 'new Tests\Feature\GraphQL\TestValidationRuleWithoutToString', + ], + ], + ]); + + BlueprintRepository::shouldReceive('find')->with('forms.contact')->andReturn($blueprint); + + $query = <<<'GQL' +{ + form(handle: "contact") { + rules + } +} +GQL; + $this + ->withoutExceptionHandling() + ->post('/graphql', ['query' => $query]) + ->assertGqlOk() + ->assertExactJson(['data' => [ + 'form' => [ + 'rules' => [ + 'name' => [ + 'mimes:image/jpeg,image/png', + 'mimetypes:image/jpeg,image/png', + 'dimensions:1024', + 'size:1000', + 'image:jpeg', + 'thevalidationrule:foo,bar', + 'Tests\\Feature\\GraphQL\\TestValidationRuleWithoutToString::class', + 'array', + 'nullable', + ], + ], + ], + ]]); + } +} + +class TestValidationRuleWithToString implements CastableToValidationString +{ + public function toGqlValidationString(): string + { + return 'thevalidationrule:foo,bar'; + } +} + +class TestValidationRuleWithoutToString +{ } diff --git a/tests/Feature/Navigation/MocksStructures.php b/tests/Feature/Navigation/MocksStructures.php index 73b434561d4..874edddf130 100644 --- a/tests/Feature/Navigation/MocksStructures.php +++ b/tests/Feature/Navigation/MocksStructures.php @@ -19,6 +19,7 @@ private function createNav($handle) $s->shouldReceive('editUrl')->andReturn('/nav-edit-url'); $s->shouldReceive('deleteUrl')->andReturn('/nav-delete-url'); $s->shouldReceive('collections')->andReturn(collect()); + $s->shouldReceive('collectionsQueryScopes')->andReturn([]); $s->shouldReceive('expectsRoot')->andReturnFalse(); $s->shouldReceive('maxDepth')->andReturnNull(); $s->shouldReceive('canSelectAcrossSites')->andReturnFalse(); diff --git a/tests/Feature/SlugTest.php b/tests/Feature/SlugTest.php index d9a017e628e..c78798f98a5 100644 --- a/tests/Feature/SlugTest.php +++ b/tests/Feature/SlugTest.php @@ -40,6 +40,9 @@ public static function slugProvider() 'german characters' => ['Björn Müller', '-', 'de', 'bjoern-mueller'], 'arabic characters' => ['صباح الخير', '-', 'ar', 'sbah-alkhyr'], 'alternate separator' => ['one two three', '_', 'en', 'one_two_three'], + 'null string' => ['null', '-', 'en', 'null'], + 'zero string' => ['0', '-', 'en', '0'], + 'false string' => ['false', '-', 'en', 'false'], ]; } } diff --git a/tests/Fields/BlueprintRepositoryTest.php b/tests/Fields/BlueprintRepositoryTest.php index a7004a74cde..80571d8c0f7 100644 --- a/tests/Fields/BlueprintRepositoryTest.php +++ b/tests/Fields/BlueprintRepositoryTest.php @@ -21,7 +21,7 @@ public function setUp(): void parent::setUp(); $this->repo = app(BlueprintRepository::class) - ->setDirectory('/path/to/resources/blueprints'); + ->setDirectories('/path/to/resources/blueprints'); Facades\Blueprint::swap($this->repo); } @@ -439,4 +439,31 @@ public function find_or_fail_throws_exception_when_blueprint_does_not_exist() $this->repo->findOrFail('does-not-exist'); } + + /** @test */ + public function it_gets_a_blueprint_from_split_repository() + { + $repo = (new BlueprintRepository()) + ->setDirectories([ + 'default' => '/path/to/resources/blueprints', + 'forms' => '/path/to/content/forms/blueprints', + ]); + + $contents = <<<'EOT' +title: Test +tabs: + main: + fields: + - one + - two +EOT; + File::shouldReceive('exists')->with('/path/to/resources/blueprints/globals/test.yaml')->once()->andReturnTrue(); + File::shouldReceive('get')->with('/path/to/resources/blueprints/globals/test.yaml')->once()->andReturn($contents); + + File::shouldReceive('exists')->with('/path/to/content/forms/blueprints/test.yaml')->once()->andReturnTrue(); + File::shouldReceive('get')->with('/path/to/content/forms/blueprints/test.yaml')->once()->andReturn($contents); + + $repo->find('globals.test'); + $repo->find('forms.test'); + } } diff --git a/tests/Fields/BlueprintTest.php b/tests/Fields/BlueprintTest.php index a13c952be82..2be10d841da 100644 --- a/tests/Fields/BlueprintTest.php +++ b/tests/Fields/BlueprintTest.php @@ -869,6 +869,7 @@ public function it_ensures_a_field_has_config() [ 'fields' => [ ['handle' => 'the_field', 'field' => 'the_partial.the_field', 'config' => ['type' => 'text', 'do_not_touch_other_config' => true]], + ['handle' => 'imported_field_without_config_key', 'field' => 'the_partial.the_field'], ], ], ], @@ -878,6 +879,7 @@ public function it_ensures_a_field_has_config() $fields = $blueprint ->ensureFieldHasConfig('author', ['visibility' => 'read_only']) ->ensureFieldHasConfig('the_field', ['visibility' => 'read_only']) + ->ensureFieldHasConfig('imported_field_without_config_key', ['visibility' => 'read_only']) ->fields(); $this->assertEquals(['type' => 'text'], $fields->get('title')->config()); @@ -891,6 +893,7 @@ public function it_ensures_a_field_has_config() $this->assertEquals($expectedConfig, $fields->get('author')->config()); $this->assertEquals($expectedConfig, $fields->get('the_field')->config()); + $this->assertEquals($expectedConfig, $fields->get('imported_field_without_config_key')->config()); } // todo: duplicate or tweak above test but make the target field not in the first section. @@ -1038,6 +1041,55 @@ public function it_merges_config_overrides_for_previously_undefined_keys_when_en $this->assertEquals(['type' => 'text', 'foo' => 'bar'], $blueprint->fields()->get('from_partial')->config()); } + #[Test] + public function it_merges_configs_in_correct_priority_order_when_ensuring_a_referenced_field_with_overrides() + { + FieldsetRepository::shouldReceive('find')->with('the_partial')->andReturn( + (new Fieldset)->setContents(['fields' => [ + [ + 'handle' => 'the_field', + 'field' => ['type' => 'text', 'display' => 'The Field'], + ], + ]]) + ); + + $blueprint = (new Blueprint)->setContents(['tabs' => [ + 'tab_one' => [ + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'from_partial', 'field' => 'the_partial.the_field', 'config' => ['visibility' => 'read_only', 'validate' => 'max:543']], + ], + ], + ], + ], + ]]); + + $blueprint->ensureField('from_partial', ['validate' => 'max:200', 'required' => true]); + + $this->assertEquals(['tabs' => [ + 'tab_one' => [ + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'from_partial', 'field' => 'the_partial.the_field', 'config' => ['visibility' => 'read_only', 'validate' => 'max:543', 'required' => true]], + ], + ], + ], + ], + ]], $blueprint->contents()); + + $fieldConfig = $blueprint->fields()->get('from_partial')->config(); + + $this->assertEquals(true, $fieldConfig['required']); + + $this->assertEquals('text', $fieldConfig['type']); + $this->assertEquals('The Field', $fieldConfig['display']); + + $this->assertEquals('max:543', $fieldConfig['validate']); + $this->assertEquals('read_only', $fieldConfig['visibility']); + } + #[Test] public function it_merges_undefined_config_overrides_when_ensuring_a_field_that_already_exists_inside_an_imported_fieldset() { diff --git a/tests/Fields/FieldTest.php b/tests/Fields/FieldTest.php index 62c92217484..7c685b705cb 100644 --- a/tests/Fields/FieldTest.php +++ b/tests/Fields/FieldTest.php @@ -605,6 +605,31 @@ public function toGqlType() $this->assertInstanceOf(\GraphQL\Type\Definition\FloatType::class, $type['type']->getWrappedType()); } + #[Test] + #[Group('graphql')] + public function it_keeps_the_graphql_type_nullable_if_its_sometimes_required() + { + $fieldtype = new class extends Fieldtype + { + public function toGqlType() + { + return new \GraphQL\Type\Definition\FloatType; + } + }; + + FieldtypeRepository::shouldReceive('find') + ->with('fieldtype') + ->andReturn($fieldtype); + + $field = new Field('test', ['type' => 'fieldtype', 'validate' => 'required|sometimes']); + + $type = $field->toGql(); + + $this->assertIsArray($type); + $this->assertInstanceOf(\GraphQL\Type\Definition\NullableType::class, $type['type']); + $this->assertInstanceOf(\GraphQL\Type\Definition\FloatType::class, $type['type']); + } + #[Test] public function it_gets_the_path_of_handles_for_nested_fields() { diff --git a/tests/Fields/FieldsetTest.php b/tests/Fields/FieldsetTest.php index 4fef1e73fc7..ff8fb006d08 100644 --- a/tests/Fields/FieldsetTest.php +++ b/tests/Fields/FieldsetTest.php @@ -167,6 +167,32 @@ public function gets_a_single_field() $this->assertNull($fieldset->field('unknown')); } + #[Test] + public function it_can_check_if_has_field() + { + FieldsetRepository::shouldReceive('find') + ->with('partial') + ->andReturn((new Fieldset)->setContents([ + 'fields' => [ + ['handle' => 'two', 'field' => ['type' => 'text']], + ], + ])) + ->once(); + + $fieldset = new Fieldset; + + $fieldset->setContents([ + 'fields' => [ + ['handle' => 'one', 'field' => ['type' => 'text']], + ['import' => 'partial'], + ], + ]); + + $this->assertTrue($fieldset->hasField('one')); + $this->assertTrue($fieldset->hasField('two')); + $this->assertFalse($fieldset->hasField('three')); + } + #[Test] public function gets_blueprints_importing_fieldset() { diff --git a/tests/Fields/FieldtypeRepositoryTest.php b/tests/Fields/FieldtypeRepositoryTest.php index 85f5b5d8b33..5ea3ccaca6c 100644 --- a/tests/Fields/FieldtypeRepositoryTest.php +++ b/tests/Fields/FieldtypeRepositoryTest.php @@ -62,6 +62,27 @@ public function it_throw_exception_when_finding_invalid_fieldtype() $this->expectExceptionMessage('Fieldtype [test] not found'); $this->repo->find('test'); } + + #[Test] + public function it_makes_fields_selectable_in_forms() + { + $this->assertFalse($this->repo->hasBeenMadeSelectableInForms('test-selectable')); + + $this->repo->makeSelectableInForms('test-selectable'); + $this->assertTrue($this->repo->hasBeenMadeSelectableInForms('test-selectable')); + $this->assertTrue($this->repo->selectableInFormIsOverriden('test-selectable')); + } + + #[Test] + public function it_makes_fields_unselectable_in_forms() + { + $this->repo->makeSelectableInForms('test-unselectable'); + $this->assertTrue($this->repo->hasBeenMadeSelectableInForms('test-unselectable')); + + $this->repo->makeUnselectableInForms('test-unselectable'); + $this->assertFalse($this->repo->hasBeenMadeSelectableInForms('test-unselectable')); + $this->assertTrue($this->repo->selectableInFormIsOverriden('test-unselectable')); + } } class FooFieldtype extends Fieldtype diff --git a/tests/Fields/FieldtypeTest.php b/tests/Fields/FieldtypeTest.php index 8ff3ca73e89..417ec8be918 100644 --- a/tests/Fields/FieldtypeTest.php +++ b/tests/Fields/FieldtypeTest.php @@ -555,16 +555,35 @@ public function it_can_make_a_fieldtype_selectable_in_forms() { $fieldtype = new class extends Fieldtype { - public static $handle = 'test'; + public static $handle = 'test-selectable'; + protected $selectableInForms = false; }; $this->assertFalse($fieldtype->selectableInForms()); - $this->assertFalse(FieldtypeRepository::hasBeenMadeSelectableInForms('test')); $fieldtype::makeSelectableInForms(); $this->assertTrue($fieldtype->selectableInForms()); - $this->assertTrue(FieldtypeRepository::hasBeenMadeSelectableInForms('test')); + $this->assertTrue(FieldtypeRepository::hasBeenMadeSelectableInForms('test-selectable')); + $this->assertTrue(FieldtypeRepository::selectableInFormIsOverriden('test-selectable')); + } + + #[Test] + public function it_can_make_a_fieldtype_unselectable_in_forms() + { + $fieldtype = new class extends Fieldtype + { + public static $handle = 'test-unselectable'; + protected $selectableInForms = true; + }; + + $this->assertTrue($fieldtype->selectableInForms()); + + $fieldtype::makeUnselectableInForms(); + + $this->assertFalse($fieldtype->selectableInForms()); + $this->assertFalse(FieldtypeRepository::hasBeenMadeSelectableInForms('test-unselectable')); + $this->assertTrue(FieldtypeRepository::selectableInFormIsOverriden('test-unselectable')); } } diff --git a/tests/Fields/ValueTest.php b/tests/Fields/ValueTest.php index 6ec9af75667..407ba9ff5d8 100644 --- a/tests/Fields/ValueTest.php +++ b/tests/Fields/ValueTest.php @@ -10,6 +10,7 @@ use Statamic\Fields\Value; use Statamic\Fields\Values; use Statamic\Query\Builder; +use Statamic\View\Antlers\AntlersString; use Tests\TestCase; class ValueTest extends TestCase @@ -322,6 +323,36 @@ public function it_can_iterate_over_values() ], $arr); } + #[Test] + public function it_can_check_isset_on_properties() + { + $val = new Value((object) [ + 'a' => 'alfa', + 'b' => '', + 'c' => null, + ]); + + $this->assertTrue(isset($val->a)); + $this->assertTrue(isset($val->b)); + $this->assertFalse(isset($val->c)); + $this->assertFalse(isset($val->d)); + } + + #[Test] + public function it_can_check_emptiness_on_properties() + { + $val = new Value((object) [ + 'a' => 'alfa', + 'b' => '', + 'c' => null, + ]); + + $this->assertFalse(empty($val->a)); + $this->assertTrue(empty($val->b)); + $this->assertTrue(empty($val->c)); + $this->assertTrue(empty($val->d)); + } + #[Test] public function it_can_proxy_methods_to_value() { @@ -357,6 +388,43 @@ public function it_can_proxy_property_access_to_value() $this->assertEquals('foo', $value->bar); $this->assertEquals('nope', $value->baz ?? 'nope'); } + + #[Test] + public function it_parses_from_raw_string() + { + $fieldtype = new class extends Fieldtype + { + public function augment($data) + { + // if we are being asked to augment an already parsed antlers string + // then we return the correct value + if ($data instanceof AntlersString) { + return 'augmented_value'; + } + + return 'not_augmented_value'; + } + + public function config(?string $key = null, $fallback = null) + { + if ($key == 'antlers') { + return true; + } + + return parent::config($key, $fallback); + } + + public function shouldParseAntlersFromRawString(): bool + { + return true; + } + }; + + $value = new Value('raw_value', null, $fieldtype); + $value = $value->antlersValue(app(\Statamic\Contracts\View\Antlers\Parser::class), []); + + $this->assertEquals('augmented_value', (string) $value); + } } class DummyAugmentable implements \Statamic\Contracts\Data\Augmentable diff --git a/tests/Fields/ValuesTest.php b/tests/Fields/ValuesTest.php index c82045a80ed..8daa189869c 100644 --- a/tests/Fields/ValuesTest.php +++ b/tests/Fields/ValuesTest.php @@ -148,6 +148,10 @@ public function property_access() 'charlie' => new Value('delta', null, $this->fieldtype), ]); + $this->assertTrue(isset($values->alfa)); + $this->assertFalse(empty($values->alfa)); + $this->assertFalse(isset($values->missing)); + $this->assertTrue(empty($values->missing)); $this->assertEquals('bravo', $values->alfa); $this->assertEquals('delta (augmented)', $values->charlie); $this->assertIsString($values->charlie); @@ -182,6 +186,20 @@ public function raw_values() $this->assertNull($values->raw('missing')); } + #[Test] + public function to_raw_array() + { + $values = new Values([ + 'alfa' => 'bravo', + 'charlie' => $value = new Value('delta', null, $this->fieldtype), + ]); + + $this->assertEquals([ + 'alfa' => 'bravo', + 'charlie' => 'delta', + ], $values->toRawArray()); + } + #[Test] #[DataProvider('queryBuilderProvider')] public function it_gets_a_query($builder) @@ -274,6 +292,7 @@ public function augment($ids) class TestAugmentableObject implements Augmentable { use HasAugmentedData; + protected $data = []; public function __construct($data) diff --git a/tests/Fieldtypes/BardTest.php b/tests/Fieldtypes/BardTest.php index c81004c0672..cf43e330fe9 100644 --- a/tests/Fieldtypes/BardTest.php +++ b/tests/Fieldtypes/BardTest.php @@ -1338,6 +1338,54 @@ public function it_calls_hooks() $this->assertArrayHasKey('custom_field', $bard->extraValidationAttributes($data)); } + #[Test] + public function it_localizes_when_select_across_sites_setting_is_disabled() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + Facades\Site::setCurrent('fr'); + + tap(Facades\Collection::make('blog')->routes('blog/{slug}'))->sites(['en', 'fr'])->save(); + + EntryFactory::id('parent')->collection('blog')->slug('theparent')->id(123)->locale('en')->create(); + EntryFactory::id('123-fr')->origin('123')->locale('fr')->collection('blog')->slug('one-fr')->data(['title' => 'Le One', 'test' => ['type' => 'link', 'attrs' => ['href' => 'statamic://entry::123-fr']]])->create(); + + $field = (new Bard)->setField(new Field('test', array_merge(['type' => 'bard'], ['select_across_sites' => false]))); + + $augmented = $field->augment([ + ['type' => 'text', 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://entry::123-fr']]], 'text' => 'The One'], + ]); + + $this->assertEquals('The One', $augmented); + } + + #[Test] + public function it_doesnt_localize_when_select_across_sites_setting_is_enabled() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + Facades\Site::setCurrent('en'); + + tap(Facades\Collection::make('blog')->routes('blog/{slug}'))->sites(['en', 'fr'])->save(); + + EntryFactory::id('parent')->collection('blog')->slug('theparent')->id(123)->locale('en')->create(); + EntryFactory::id('123-fr')->origin('123')->locale('fr')->collection('blog')->slug('one-fr')->data(['title' => 'Le One', 'test' => ['type' => 'link', 'attrs' => ['href' => 'statamic://entry::123-fr']]])->create(); + + $field = (new Bard)->setField(new Field('test', array_merge(['type' => 'bard'], ['select_across_sites' => true]))); + + $augmented = $field->augment([ + ['type' => 'text', 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://entry::123-fr']]], 'text' => 'The One'], + ]); + + $this->assertEquals('The One', $augmented); + } + private function bard($config = []) { return (new Bard)->setField(new Field('test', array_merge(['type' => 'bard', 'sets' => ['one' => []]], $config))); diff --git a/tests/Fieldtypes/Concerns/ResolvesStatamicUrlsTest.php b/tests/Fieldtypes/Concerns/ResolvesStatamicUrlsTest.php new file mode 100644 index 00000000000..5fbab8345e2 --- /dev/null +++ b/tests/Fieldtypes/Concerns/ResolvesStatamicUrlsTest.php @@ -0,0 +1,136 @@ +testClass = new class + { + use ResolvesStatamicUrls; + + public function resolve(string $content) + { + return $this->resolveStatamicUrls($content); + } + }; + } + + #[Test] + public function it_calls_data_find_with_correct_id() + { + $data = Mockery::mock(); + $data->shouldReceive('url')->andReturn('/some/url'); + + DataFacade::shouldReceive('find') + ->once() + ->with('foo::bar/baz.ext') + ->andReturn($data); + + $content = '[link](statamic://foo::bar/baz.ext)'; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link](/some/url)', $result); + } + + #[Test] + public function it_handles_non_existent_data() + { + DataFacade::shouldReceive('find') + ->once() + ->with('non-existent') + ->andReturn(null); + + $content = '[link](statamic://non-existent)'; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link]()', $result); + } + + #[Test] + public function it_handles_multiple_urls() + { + $data1 = Mockery::mock(); + $data1->shouldReceive('url')->andReturn('/url-1'); + + $data2 = Mockery::mock(); + $data2->shouldReceive('url')->andReturn('/url-2'); + + DataFacade::shouldReceive('find') + ->once() + ->with('id-1') + ->andReturn($data1); + + DataFacade::shouldReceive('find') + ->once() + ->with('id-2') + ->andReturn($data2); + + $content = '[link1](statamic://id-1) and '; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link1](/url-1) and ', $result); + } + + #[Test] + public function it_maintains_hash_fragments() + { + $data = Mockery::mock(); + $data->shouldReceive('url')->andReturn('/some/page'); + + DataFacade::shouldReceive('find') + ->once() + ->with('entry::123') + ->andReturn($data); + + $content = '[link](statamic://entry::123#section)'; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link](/some/page#section)', $result); + } + + #[Test] + public function it_maintains_query_strings() + { + $data = Mockery::mock(); + $data->shouldReceive('url')->andReturn('/some/page'); + + DataFacade::shouldReceive('find') + ->once() + ->with('entry::123') + ->andReturn($data); + + $content = '[link](statamic://entry::123?foo=bar)'; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link](/some/page?foo=bar)', $result); + } + + #[Test] + public function it_maintains_query_strings_and_hash_fragments() + { + $data = Mockery::mock(); + $data->shouldReceive('url')->andReturn('/some/page'); + + DataFacade::shouldReceive('find') + ->once() + ->with('entry::123') + ->andReturn($data); + + $content = '[link](statamic://entry::123?foo=bar#section)'; + $result = $this->testClass->resolve($content); + + $this->assertEquals('[link](/some/page?foo=bar#section)', $result); + } +} diff --git a/tests/Fieldtypes/DictionaryFieldsTest.php b/tests/Fieldtypes/DictionaryFieldsTest.php index f2b71041f29..11e5a103aa5 100644 --- a/tests/Fieldtypes/DictionaryFieldsTest.php +++ b/tests/Fieldtypes/DictionaryFieldsTest.php @@ -116,6 +116,23 @@ public function it_processes_dictionary_fields_into_a_string_when_dictionary_has $this->assertEquals('fake_dictionary', $process); } + #[Test] + public function it_processes_dictionary_fields_and_filters_out_null_values() + { + $fieldtype = FieldtypeRepository::find('dictionary_fields'); + + $process = $fieldtype->process([ + 'type' => 'fake_dictionary', + 'category' => 'foo', + 'foo' => null, + ]); + + $this->assertEquals([ + 'type' => 'fake_dictionary', + 'category' => 'foo', + ], $process); + } + #[Test] public function it_returns_validation_rules() { diff --git a/tests/Fieldtypes/HasSelectOptionsTests.php b/tests/Fieldtypes/HasSelectOptionsTests.php index 16719bf2cd3..849209c6f1c 100644 --- a/tests/Fieldtypes/HasSelectOptionsTests.php +++ b/tests/Fieldtypes/HasSelectOptionsTests.php @@ -14,26 +14,30 @@ public function it_preloads_options($options, $expected) $field = $this->field(['options' => $options]); $this->assertArrayHasKey('options', $preloaded = $field->preload()); - $this->assertEquals($expected, $preloaded['options']); + $this->assertSame($expected, $preloaded['options']); } public static function optionsProvider() { return [ 'list' => [ - ['one', 'two', 'three'], + ['one', 'two', 'three', 50, '100'], [ ['value' => 'one', 'label' => 'one'], ['value' => 'two', 'label' => 'two'], ['value' => 'three', 'label' => 'three'], + ['value' => 50, 'label' => 50], + ['value' => '100', 'label' => '100'], ], ], 'associative' => [ - ['one' => 'One', 'two' => 'Two', 'three' => 'Three'], + ['one' => 'One', 'two' => 'Two', 'three' => 'Three', 50 => '50', '100' => 100], [ ['value' => 'one', 'label' => 'One'], ['value' => 'two', 'label' => 'Two'], ['value' => 'three', 'label' => 'Three'], + ['value' => 50, 'label' => '50'], + ['value' => 100, 'label' => 100], ], ], 'multidimensional' => [ @@ -41,11 +45,15 @@ public static function optionsProvider() ['key' => 'one', 'value' => 'One'], ['key' => 'two', 'value' => 'Two'], ['key' => 'three', 'value' => 'Three'], + ['key' => 50, 'value' => 50], + ['key' => '100', 'value' => 100], ], [ ['value' => 'one', 'label' => 'One'], ['value' => 'two', 'label' => 'Two'], ['value' => 'three', 'label' => 'Three'], + ['value' => 50, 'label' => 50], + ['value' => '100', 'label' => 100], ], ], ]; diff --git a/tests/Fieldtypes/ListTest.php b/tests/Fieldtypes/ListTest.php new file mode 100644 index 00000000000..05d4c83bbf3 --- /dev/null +++ b/tests/Fieldtypes/ListTest.php @@ -0,0 +1,27 @@ +assertSame( + ['a', 2, 3, '4 and a half', 5.7, 8.3], + $this->field()->process(['a', '2', 3, '4 and a half', '5.7', 8.3]) + ); + } + + private function field($config = []) + { + $ft = new Lists; + + return $ft->setField(new Field('test', array_merge($config, ['type' => $ft->handle()]))); + } +} diff --git a/tests/Fieldtypes/MarkdownTest.php b/tests/Fieldtypes/MarkdownTest.php index 8d74f0e5799..ae4848da8b7 100644 --- a/tests/Fieldtypes/MarkdownTest.php +++ b/tests/Fieldtypes/MarkdownTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades; use Statamic\Fields\Field; +use Statamic\Fields\Value; use Statamic\Fieldtypes\Markdown; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -170,6 +171,24 @@ public function it_converts_statamic_asset_urls() $this->assertEquals($expected, $this->fieldtype()->augment($markdown)); } + #[Test] + public function it_converts_to_smartypants_after_antlers_is_parsed() + { + $md = $this->fieldtype(['smartypants' => true, 'antlers' => true]); + + $value = <<<'EOT' +{{ "this is a string" | replace(" is ", " isnt ") | reverse }} +EOT; + + $value = new Value($value, 'markdown', $md); + + $expected = <<<'EOT' +

gnirts a tnsi siht

+EOT; + + $this->assertEqualsTrimmed($expected, $value->antlersValue(app(\Statamic\Contracts\View\Antlers\Parser::class), [])); + } + private function fieldtype($config = []) { return (new Markdown)->setField(new Field('test', array_merge(['type' => 'markdown'], $config))); diff --git a/tests/Fieldtypes/TaggableTest.php b/tests/Fieldtypes/TaggableTest.php new file mode 100644 index 00000000000..8f3e30d6363 --- /dev/null +++ b/tests/Fieldtypes/TaggableTest.php @@ -0,0 +1,27 @@ +assertEquals(['foo'], $this->field()->preProcess('foo')); + $this->assertEquals(['foo'], $this->field()->preProcess(['foo'])); + $this->assertEquals(['foo', 'bar'], $this->field()->preProcess(['foo', 'bar'])); + $this->assertEquals([], $this->field()->preProcess(null)); + } + + private function field($config = []) + { + $ft = new Taggable; + + return $ft->setField(new Field('test', array_merge($config, ['type' => $ft->handle()]))); + } +} diff --git a/tests/Fieldtypes/TextTest.php b/tests/Fieldtypes/TextTest.php index b679a4652f6..9f483409e52 100644 --- a/tests/Fieldtypes/TextTest.php +++ b/tests/Fieldtypes/TextTest.php @@ -33,4 +33,29 @@ public static function processValuesProvider() 'number' => ['number', [0, 3, 3, 3.14, null]], ]; } + + #[Test] + #[DataProvider('preProcessIndexProvider')] + public function it_pre_processes_index_values($config, $value, $expected) + { + $field = (new Text)->setField(new Field('test', array_merge([ + 'type' => 'text', + ], $config))); + + $this->assertSame($expected, $field->preProcessIndex($value)); + } + + public static function preProcessIndexProvider() + { + return [ + 'string value' => [[], 'hello', 'hello'], + 'null value' => [[], null, null], + 'zero integer' => [[], 0, '0'], + 'zero string' => [[], '0', '0'], + 'zero with prepend' => [['prepend' => '$'], 0, '$0'], + 'zero with append' => [['append' => '%'], 0, '0%'], + 'zero with prepend and append' => [['prepend' => '$', 'append' => '%'], 0, '$0%'], + 'string with prepend' => [['prepend' => '$'], 'hello', '$hello'], + ]; + } } diff --git a/tests/Fieldtypes/ToggleTest.php b/tests/Fieldtypes/ToggleTest.php index d9089020449..7798bcfaf42 100644 --- a/tests/Fieldtypes/ToggleTest.php +++ b/tests/Fieldtypes/ToggleTest.php @@ -28,4 +28,14 @@ public function it_processes_to_a_boolean_only_when_value_is_actually_set_or_sub $this->assertFalse($field->process(false)); $this->assertNull($field->process(null)); } + + #[Test] + public function queryable_value_is_a_boolean() + { + $field = (new Toggle)->setField(new Field('test', ['type' => 'toggle'])); + + $this->assertTrue($field->toQueryableValue(true)); + $this->assertFalse($field->toQueryableValue(false)); + $this->assertFalse($field->toQueryableValue(null)); + } } diff --git a/tests/Fieldtypes/UsersTest.php b/tests/Fieldtypes/UsersTest.php index f4812de3a37..41b82bbe9ef 100644 --- a/tests/Fieldtypes/UsersTest.php +++ b/tests/Fieldtypes/UsersTest.php @@ -2,6 +2,7 @@ namespace Tests\Fieldtypes; +use Illuminate\Http\Request; use Illuminate\Support\Collection; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\UserCollection; @@ -11,12 +12,14 @@ use Statamic\Facades; use Statamic\Fields\Field; use Statamic\Fieldtypes\Users; +use Tests\FakesRoles; use Tests\Fieldtypes\Concerns\TestsQueryableValueWithMaxItems; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class UsersTest extends TestCase { + use FakesRoles; use PreventSavingStacheItemsToDisk; use TestsQueryableValueWithMaxItems; @@ -26,6 +29,7 @@ public function setUp(): void Facades\User::make()->id('123')->set('name', 'One')->email('one@domain.com')->save(); Facades\User::make()->id('456')->set('name', 'Two')->email('two@domain.com')->save(); + Facades\User::make()->id('789')->email('nameless@domain.com')->save(); } #[Test] @@ -94,6 +98,53 @@ public function it_shallow_augments_to_a_single_user_when_max_items_is_one() ], $augmented->toArray()); } + #[Test] + public function it_hides_email_from_index_items_without_view_users_permission() + { + $this->actingAs($this->cpUserWithPermissions(['access cp'])); + + $items = $this->fieldtype()->getIndexItems(new Request(['paginate' => false])); + $namelessUser = $items->firstWhere('id', '789'); + + $this->assertArrayNotHasKey('email', $namelessUser); + $this->assertEquals('789', $namelessUser['title']); + } + + #[Test] + public function it_includes_email_in_index_items_with_view_users_permission() + { + $this->actingAs($this->cpUserWithPermissions(['access cp', 'view users'])); + + $items = $this->fieldtype()->getIndexItems(new Request(['paginate' => false])); + $namelessUser = $items->firstWhere('id', '789'); + + $this->assertEquals('nameless@domain.com', $namelessUser['title']); + $this->assertEquals('nameless@domain.com', $namelessUser['email']); + } + + #[Test] + public function it_hides_the_email_column_without_view_users_permission() + { + $this->actingAs($this->cpUserWithPermissions(['access cp'])); + + $columns = $this->getColumns($this->fieldtype()); + + $this->assertCount(1, $columns); + $this->assertEquals('title', $columns[0]->field); + } + + #[Test] + public function it_includes_the_email_column_with_view_users_permission() + { + $this->actingAs($this->cpUserWithPermissions(['access cp', 'view users'])); + + $columns = $this->getColumns($this->fieldtype()); + + $this->assertCount(2, $columns); + $this->assertEquals('title', $columns[0]->field); + $this->assertEquals('email', $columns[1]->field); + } + public function fieldtype($config = []) { $field = new Field('test', array_merge([ @@ -102,4 +153,19 @@ public function fieldtype($config = []) return (new Users)->setField($field); } + + private function cpUserWithPermissions(array $permissions) + { + $this->setTestRoles(['test' => $permissions]); + + return tap(Facades\User::make()->id(uniqid())->assignRole('test'))->save(); + } + + private function getColumns(Users $fieldtype): array + { + $method = new \ReflectionMethod($fieldtype, 'getColumns'); + $method->setAccessible(true); + + return $method->invoke($fieldtype); + } } diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php index 8974d4dd1ae..75a81d9cadc 100644 --- a/tests/Forms/EmailTest.php +++ b/tests/Forms/EmailTest.php @@ -149,6 +149,7 @@ public function it_adds_data_to_the_view() 'social', // manual "system" vars added to email + 'form_config', 'email_config', 'config', 'date', diff --git a/tests/Forms/FormRepositoryTest.php b/tests/Forms/FormRepositoryTest.php index 3f3011ad0e9..08cca2e1c89 100644 --- a/tests/Forms/FormRepositoryTest.php +++ b/tests/Forms/FormRepositoryTest.php @@ -43,7 +43,7 @@ public function test_find_or_fail_throws_exception_when_form_does_not_exist() $this->repo->findOrFail('does-not-exist'); } - /** @test */ + #[Test] public function it_registers_config() { $this->repo->appendConfigFields('test_form', 'Test Config', [ diff --git a/tests/Forms/FormTest.php b/tests/Forms/FormTest.php index 46cc05daa98..8401c3adb63 100644 --- a/tests/Forms/FormTest.php +++ b/tests/Forms/FormTest.php @@ -250,4 +250,22 @@ public function it_deletes_quietly() $this->assertTrue($return); } + + #[Test] + public function it_clones_internal_collections() + { + $form = Form::make('contact_us'); + $form->set('foo', 'A'); + $form->setSupplement('bar', 'A'); + + $clone = clone $form; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $form->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $form->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/Forms/SendEmailsTest.php b/tests/Forms/SendEmailsTest.php index 45371c97a3f..0360d85f685 100644 --- a/tests/Forms/SendEmailsTest.php +++ b/tests/Forms/SendEmailsTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Bus; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Contracts\Forms\SubmissionRepository; use Statamic\Facades\Form as FacadesForm; use Statamic\Facades\Site; use Statamic\Forms\DeleteTemporaryAttachments; @@ -115,6 +116,29 @@ public function it_dispatches_delete_attachments_job_after_dispatching_email_job ]); } + #[Test] + public function delete_attachments_job_only_saves_submission_when_enabled() + { + $form = tap(FacadesForm::make('attachments_test')->email([ + 'from' => 'first@sender.com', + 'to' => 'first@recipient.com', + 'foo' => 'bar', + ]))->save(); + + $form + ->store(false) + ->blueprint() + ->ensureField('attachments', ['type' => 'files'])->save(); + + $submission = $form->makeSubmission(); + + (new DeleteTemporaryAttachments($submission))->handle(); + + $submissions = app(SubmissionRepository::class)->all(); + + $this->assertEmpty($submissions); + } + #[Test] #[DataProvider('noEmailsProvider')] public function no_email_jobs_are_queued_if_none_are_configured($emailConfig) diff --git a/tests/Forms/SubmissionQueryBuilderTest.php b/tests/Forms/SubmissionQueryBuilderTest.php index 51ab55718de..d9edc6d37b0 100644 --- a/tests/Forms/SubmissionQueryBuilderTest.php +++ b/tests/Forms/SubmissionQueryBuilderTest.php @@ -357,6 +357,80 @@ public function submissions_are_found_using_or_where_json_doesnt_contain() $this->assertEquals(['1', '3', '2', '4'], $entries->map->get('id')->all()); } + #[Test] + public function submissions_are_found_using_where_json_overlaps() + { + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['id' => '1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + FormSubmission::make()->form($form)->data(['id' => '2', 'test_taxonomy' => ['taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + FormSubmission::make()->form($form)->data(['id' => '5', 'test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = FormSubmission::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1', 'taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['1', '3', '5'], $entries->map->get('id')->all()); + + $entries = FormSubmission::query()->whereJsonOverlaps('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['1', '3'], $entries->map->get('id')->all()); + } + + #[Test] + public function submissions_are_found_using_where_json_doesnt_overlap() + { + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['id' => '1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + FormSubmission::make()->form($form)->data(['id' => '2', 'test_taxonomy' => ['taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + FormSubmission::make()->form($form)->data(['id' => '5', 'test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = FormSubmission::query()->whereJsonDoesntOverlap('test_taxonomy', ['taxonomy-1'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['2', '4', '5'], $entries->map->get('id')->all()); + + $entries = FormSubmission::query()->whereJsonDoesntOverlap('test_taxonomy', 'taxonomy-1')->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['2', '4', '5'], $entries->map->get('id')->all()); + } + + #[Test] + public function submissions_are_found_using_or_where_json_overlaps() + { + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['id' => '1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + FormSubmission::make()->form($form)->data(['id' => '2', 'test_taxonomy' => ['taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + FormSubmission::make()->form($form)->data(['id' => '5', 'test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = FormSubmission::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonOverlaps('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(3, $entries); + $this->assertEquals(['1', '3', '5'], $entries->map->get('id')->all()); + } + + #[Test] + public function submissions_are_found_using_or_where_json_doesnt_overlap() + { + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['id' => '1', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-2']])->save(); + FormSubmission::make()->form($form)->data(['id' => '2', 'test_taxonomy' => ['taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '3', 'test_taxonomy' => ['taxonomy-1', 'taxonomy-3']])->save(); + FormSubmission::make()->form($form)->data(['id' => '4', 'test_taxonomy' => ['taxonomy-3', 'taxonomy-4']])->save(); + FormSubmission::make()->form($form)->data(['id' => '5', 'test_taxonomy' => ['taxonomy-5']])->save(); + + $entries = FormSubmission::query()->whereJsonOverlaps('test_taxonomy', ['taxonomy-1'])->orWhereJsonDoesntOverlap('test_taxonomy', ['taxonomy-5'])->get(); + + $this->assertCount(4, $entries); + $this->assertEquals(['1', '3', '2', '4'], $entries->map->get('id')->all()); + } + #[Test] public function submissions_are_found_using_where_json_length() { diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index c9d255ad3a9..e631fcbe35e 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -208,4 +208,24 @@ public function it_deletes_quietly() $this->assertTrue($return); } + + #[Test] + public function it_clones_internal_collections() + { + $form = Form::make('contact_us'); + $form->save(); + $submission = $form->makeSubmission(); + $submission->set('foo', 'A'); + $submission->setSupplement('bar', 'A'); + + $clone = clone $submission; + $clone->set('foo', 'B'); + $clone->setSupplement('bar', 'B'); + + $this->assertEquals('A', $submission->get('foo')); + $this->assertEquals('B', $clone->get('foo')); + + $this->assertEquals('A', $submission->getSupplement('bar')); + $this->assertEquals('B', $clone->getSupplement('bar')); + } } diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index 418fa02a942..15d2229e344 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Auth\Protect\ProtectorManager; +use Statamic\Auth\Protect\Protectors\Protector; use Statamic\Events\ResponseCreated; use Statamic\Facades\Blueprint; use Statamic\Facades\Cascade; @@ -373,6 +375,45 @@ public function header_is_added_to_protected_responses() ->assertHeader('X-Statamic-Protected', true); } + #[Test] + public function header_is_not_added_to_cacheable_protected_responses() + { + // config(['statamic.protect.default' => 'test']); + config(['statamic.protect.schemes.test' => [ + 'driver' => 'test', + ]]); + + app(ProtectorManager::class)->extend('test', function ($app) { + return new class() extends Protector + { + public function protect() + { + // + } + + public function cacheable() + { + return true; + } + }; + }); + + $page = $this->createPage('about'); + + $this + ->get('/about') + ->assertOk() + ->assertHeaderMissing('X-Statamic-Protected'); + + $page->set('protect', 'test')->save(); + + $this + ->actingAs(User::make()) + ->get('/about') + ->assertOk() + ->assertHeaderMissing('X-Statamic-Protected'); + } + #[Test] public function key_variables_key_added() { @@ -423,8 +464,8 @@ public function changes_content_type_to_xml() { $this->createPage('about', ['with' => ['content_type' => 'xml']]); - // Laravel adds utf-8 if the content-type starts with text/ - $this->get('about')->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + // Symfony adds utf-8 if the content-type starts with text/ + $this->get('about')->assertContentType('text/xml; charset=utf-8'); } #[Test] @@ -432,8 +473,8 @@ public function changes_content_type_to_atom() { $this->createPage('about', ['with' => ['content_type' => 'atom']]); - // Laravel adds utf-8 if the content-type starts with text/ - $this->get('about')->assertHeader('Content-Type', 'application/atom+xml; charset=UTF-8'); + // Symfony adds utf-8 if the content-type starts with text/ + $this->get('about')->assertContentType('application/atom+xml; charset=utf-8'); } #[Test] @@ -441,7 +482,7 @@ public function changes_content_type_to_json() { $this->createPage('about', ['with' => ['content_type' => 'json']]); - $this->get('about')->assertHeader('Content-Type', 'application/json'); + $this->get('about')->assertContentType('application/json'); } #[Test] @@ -449,8 +490,8 @@ public function changes_content_type_to_text() { $this->createPage('about', ['with' => ['content_type' => 'text']]); - // Laravel adds utf-8 if the content-type starts with text/ - $this->get('about')->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + // Symfony adds utf-8 if the content-type starts with text/ + $this->get('about')->assertContentType('text/plain; charset=utf-8'); } #[Test] @@ -463,7 +504,7 @@ public function xml_antlers_template_with_xml_layout_will_use_both_and_change_th $response = $this ->get('about') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -478,7 +519,7 @@ public function xml_antlers_template_with_non_xml_layout_will_change_content_typ $response = $this ->get('about') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -493,7 +534,7 @@ public function xml_antlers_layout_will_change_the_content_type() $response = $this ->get('about') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -510,7 +551,7 @@ public function xml_blade_template_will_not_change_content_type() $response = $this ->get('about') - ->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + ->assertContentType('text/html; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -525,7 +566,7 @@ public function xml_template_with_custom_content_type_does_not_change_to_xml() $this ->get('about') - ->assertHeader('Content-Type', 'application/json'); + ->assertContentType('application/json'); } #[Test] diff --git a/tests/Git/GitEventTest.php b/tests/Git/GitEventTest.php index 28cd7478251..9be27949c94 100644 --- a/tests/Git/GitEventTest.php +++ b/tests/Git/GitEventTest.php @@ -442,6 +442,7 @@ public function it_commits_when_asset_is_reuploaded() $file = Mockery::mock(ReplacementFile::class); $file->shouldReceive('extension')->andReturn('txt'); + $file->shouldReceive('basename')->andReturn('file.txt'); $file->shouldReceive('writeTo'); $this->makeAsset()->reupload($file); diff --git a/tests/Git/GitProcessTest.php b/tests/Git/GitProcessTest.php index b1faa6267bd..dfb23204127 100644 --- a/tests/Git/GitProcessTest.php +++ b/tests/Git/GitProcessTest.php @@ -153,6 +153,14 @@ public function it_doesnt_log_processed_references_as_error_output() $this->simulateLoggableErrorOutput('remote: Processed 1 references in total'); } + #[Test] + public function it_doesnt_log_auto_packing_as_error_output() + { + Log::shouldReceive('error')->never(); + + $this->simulateLoggableErrorOutput('Error: Auto packing the repository in background for optimum performance.'); + } + private function showLastCommit($path) { return Process::create($path)->run('git show'); diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index a06d6bbac28..498856f3338 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -19,7 +19,7 @@ class GitTest extends TestCase private $files; - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -266,6 +266,65 @@ public function it_shell_escapes_git_user_name_and_email() $this->assertStringContainsString($expectedMessage, $lastCommit); } + #[Test] + public function it_commits_with_spaces_in_paths() + { + $this->files->put(base_path('content/collections/file with spaces.yaml'), 'title: File with spaces in path!'); + $this->files->makeDirectory(base_path('content/collections/folder with spaces')); + $this->files->put(base_path('content/collections/folder with spaces/file.yaml'), 'title: Folder with spaces in path!'); + + $expectedContentStatus = <<<'EOT' +?? "collections/file with spaces.yaml" +?? "collections/folder with spaces/" +EOT; + + $this->assertEquals($expectedContentStatus, GitProcess::create(Path::resolve(base_path('content')))->status()); + + $this->assertStringContainsString('Initial commit.', $this->showLastCommit(base_path('content'))); + + Git::commit(); + + $this->assertStringContainsString('Content saved', $commit = $this->showLastCommit(base_path('content'))); + $this->assertStringContainsString('Spock ', $commit); + $this->assertStringContainsString('collections/file with spaces.yaml', $commit); + $this->assertStringContainsString('title: File with spaces in path!', $commit); + $this->assertStringContainsString('collections/folder with spaces/file.yaml', $commit); + $this->assertStringContainsString('title: Folder with spaces in path!', $commit); + } + + #[Test] + public function it_commits_with_spaces_in_explicitly_configured_paths() + { + Config::set('statamic.git.paths', [ + 'content/path with spaces', + ]); + + $this->files->makeDirectory(base_path('content/path with spaces')); + $this->files->put(base_path('content/path with spaces/file.yaml'), 'title: File with spaces in path!'); + $this->files->put(base_path('content/path with spaces/nested file with spaces.yaml'), 'title: Nested file with spaces in path!'); + $this->files->makeDirectory(base_path('content/path with spaces/nested folder with spaces')); + $this->files->put(base_path('content/path with spaces/nested folder with spaces/file.yaml'), 'title: Nested folder with spaces in path!'); + + $expectedStatus = <<<'EOT' +?? "path with spaces/" +EOT; + + $this->assertEquals($expectedStatus, GitProcess::create(Path::resolve(base_path('content')))->status()); + + $this->assertStringContainsString('Initial commit.', $this->showLastCommit(base_path('content'))); + + Git::commit(); + + $this->assertStringContainsString('Content saved', $commit = $this->showLastCommit(base_path('content'))); + $this->assertStringContainsString('Spock ', $commit); + $this->assertStringContainsString('path with spaces/file.yaml', $commit); + $this->assertStringContainsString('title: File with spaces in path!', $commit); + $this->assertStringContainsString('path with spaces/nested file with spaces.yaml', $commit); + $this->assertStringContainsString('title: Nested file with spaces in path!', $commit); + $this->assertStringContainsString('path with spaces/nested folder with spaces/file.yaml', $commit); + $this->assertStringContainsString('title: Nested folder with spaces in path!', $commit); + } + #[Test] public function it_can_commit_with_custom_commit_message() { diff --git a/tests/Http/Middleware/SelectedSiteTest.php b/tests/Http/Middleware/SelectedSiteTest.php index c778d686e0a..33e0464c7e9 100644 --- a/tests/Http/Middleware/SelectedSiteTest.php +++ b/tests/Http/Middleware/SelectedSiteTest.php @@ -3,6 +3,7 @@ namespace Tests\Http\Middleware; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Site; use Statamic\Facades\User; @@ -81,6 +82,54 @@ public function it_doesnt_do_anything_when_there_are_no_authorized_sites() $this->assertEquals('de', Site::selected()->handle()); } + #[Test, DataProvider('firstLoginProvider')] + public function it_sets_the_correct_site_when_first_logging_in($existingSelection, $url, $expectedSelection) + { + $this->setSites([ + 'en' => ['url' => 'https://en.test', 'locale' => 'en'], + 'fr' => ['url' => 'https://fr.test', 'locale' => 'fr'], + ]); + + $this->setTestRoles(['test' => [ + // no authorized sites + ]]); + $user = tap(User::make()->assignRole('test'))->save(); + + $this->actingAs($user); + $request = $this->createRequest($url); + $handled = false; + + $this->session(['statamic.cp.selected-site' => $existingSelection]); + + (new SelectedSite())->handle($request, function () use (&$handled) { + $handled = true; + + return new Response; + }); + + $this->assertTrue($handled); + $this->assertEquals($expectedSelection, Site::selected()->handle()); + } + + public static function firstLoginProvider() + { + $enUrl = 'https://en.test/cp/foo'; + $frUrl = 'https://fr.test/cp/foo'; + $unknownUrl = 'https://unknown.test/cp/foo'; + + return [ + 'en site: fresh session' => [null, $enUrl, 'en'], + 'en site: existing en session' => ['en', $enUrl, 'en'], + 'en site: existing fr session' => ['fr', $enUrl, 'fr'], + 'fr site: fresh session' => [null, $frUrl, 'fr'], + 'fr site: existing fr session' => ['fr', $frUrl, 'fr'], + 'fr site: existing en session' => ['en', $frUrl, 'en'], + 'unknown site: fresh session' => [null, $unknownUrl, 'en'], + 'unknown site: existing en session' => ['en', $unknownUrl, 'en'], + 'unknown site: existing fr session' => ['fr', $unknownUrl, 'fr'], + ]; + } + #[Test] public function middleware_attached_to_routes() { diff --git a/tests/Imaging/GlideUrlBuilderTest.php b/tests/Imaging/GlideUrlBuilderTest.php index a1dcb42df6f..a38bba37f6a 100644 --- a/tests/Imaging/GlideUrlBuilderTest.php +++ b/tests/Imaging/GlideUrlBuilderTest.php @@ -5,6 +5,7 @@ use Statamic\Assets\Asset; use Statamic\Assets\AssetContainer; use Statamic\Imaging\GlideUrlBuilder; +use Statamic\Support\Str; use Tests\TestCase; class GlideUrlBuilderTest extends TestCase @@ -54,7 +55,7 @@ public function testAsset() $asset->container((new AssetContainer)->handle('main')); $asset->path('img/foo.jpg'); - $encoded = base64_encode('main/img/foo.jpg'); + $encoded = Str::toBase64Url('main/img/foo.jpg'); $this->assertEquals( "/img/asset/$encoded/foo.jpg?w=100", @@ -64,7 +65,7 @@ public function testAsset() public function testId() { - $encoded = base64_encode('main/img/foo.jpg'); + $encoded = Str::toBase64Url('main/img/foo.jpg'); $this->assertEquals( "/img/asset/$encoded?w=100", @@ -78,7 +79,7 @@ public function testConfigAddsFilename() $asset->container((new AssetContainer)->handle('main')); $asset->path('img/foo.jpg'); - $encoded = base64_encode('main/img/foo.jpg'); + $encoded = Str::toBase64Url('main/img/foo.jpg'); $this->assertEquals( "/img/asset/$encoded/foo.jpg?w=100", @@ -92,7 +93,7 @@ public function testMarkWithAsset() $asset->container((new AssetContainer)->handle('main')); $asset->path('img/foo.jpg'); - $encoded = rawurlencode(base64_encode('main/img/foo.jpg')); + $encoded = rawurlencode(Str::toBase64Url('main/img/foo.jpg')); $this->assertEquals( "/img/foo.jpg?w=100&mark=asset%3A%3A$encoded", diff --git a/tests/Imaging/ImageGeneratorTest.php b/tests/Imaging/ImageGeneratorTest.php index 44bbaf745d3..bfe5f753f34 100644 --- a/tests/Imaging/ImageGeneratorTest.php +++ b/tests/Imaging/ImageGeneratorTest.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; use League\Flysystem\Local\LocalFilesystemAdapter; +use League\Flysystem\UnableToReadFile; use League\Glide\Manipulators\Watermark; use League\Glide\Server; use PHPUnit\Framework\Attributes\DataProvider; @@ -85,6 +86,22 @@ public function it_generates_an_image_by_asset() Event::assertDispatchedTimes(GlideImageGenerated::class, 1); } + #[Test] + public function it_throws_unable_to_read_file_when_asset_is_not_a_valid_image() + { + Storage::fake('test'); + $file = UploadedFile::fake()->create('foo/hoff.jpg', 100); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + $asset = tap($container->makeAsset('foo/hoff.jpg'))->save(); + + ImageValidator::shouldReceive('isValidImage')->andReturnFalse(); + + $this->expectException(UnableToReadFile::class); + + $this->makeGenerator()->generateByAsset($asset, ['w' => 100]); + } + #[Test] public function it_generates_cache_manifest_for_multiple_asset_manipulations() { @@ -211,6 +228,56 @@ public function it_generates_an_image_by_external_url() Event::assertDispatchedTimes(GlideImageGenerated::class, 1); } + #[Test] + public function it_generates_an_image_by_external_url_with_query_string() + { + Event::fake(); + + $cacheKey = 'url::https://example.com/foo/hoff.jpg?query=david::4dbc41d8e3ba1ccd302641e509b48768'; + $this->assertNull(Glide::cacheStore()->get($cacheKey)); + + $this->assertCount(0, $this->generatedImagePaths()); + + $this->app->bind('statamic.imaging.guzzle', function () { + $file = UploadedFile::fake()->image('', 30, 60); + $contents = file_get_contents($file->getPathname()); + + $response = new Response(200, [], $contents); + + // Glide, Flysystem, or the Guzzle adapter will try to perform the requests + // at different points to check if the file exists or to get the content + // of it. Here we'll just mock the same response multiple times. + return new Client(['handler' => new MockHandler([ + $response, $response, $response, + ])]); + }); + + // Generate the image twice to make sure it's cached. + foreach (range(1, 2) as $i) { + $path = $this->makeGenerator()->generateByUrl( + 'https://example.com/foo/hoff.jpg?query=david', + $userParams = ['w' => 100, 'h' => 100] + ); + } + + $qsHash = md5('query=david'); + + // Since we can't really mock the server, we'll generate the md5 hash the same + // way it does. It will not include the fit parameter since it's not an asset. + $md5 = $this->getGlideMd5("foo/hoff-{$qsHash}.jpg", $userParams); + + // While writing this test I noticed that we don't include the domain in the + // cache path, so the same file path on two different domains will conflict. + // TODO: Fix this. + $expectedPath = "http/foo/hoff-{$qsHash}.jpg/{$md5}/hoff-{$qsHash}.jpg"; + + $this->assertEquals($expectedPath, $path); + $this->assertCount(1, $paths = $this->generatedImagePaths()); + $this->assertContains($expectedPath, $paths); + $this->assertEquals($expectedPath, Glide::cacheStore()->get($cacheKey)); + Event::assertDispatchedTimes(GlideImageGenerated::class, 1); + } + #[Test] public function the_watermark_disk_is_the_public_directory_by_default() { diff --git a/tests/Imaging/ManagerTest.php b/tests/Imaging/ManagerTest.php index 72aa061cf41..4e2434605d3 100644 --- a/tests/Imaging/ManagerTest.php +++ b/tests/Imaging/ManagerTest.php @@ -71,12 +71,24 @@ public function it_gets_manipulation_presets() 'cp_thumbnail_small_square' => ['w' => '400', 'h' => '400'], ], $this->manager->cpManipulationPresets()); + $this->manager->registerCustomManipulationPresets([ + 'og_image' => ['w' => 1146, 'h' => 600], + 'twitter_image' => ['w' => 1200, 'h' => 600], + ]); + + $this->assertEquals([ + 'og_image' => ['w' => 1146, 'h' => 600], + 'twitter_image' => ['w' => 1200, 'h' => 600], + ], $this->manager->customManipulationPresets()); + $this->assertEquals([ 'alfa' => ['w' => 100, 'h' => 200, 'q' => 50], 'bravo' => ['w' => 200, 'h' => 100, 'q' => 20], 'cp_thumbnail_small_landscape' => ['w' => '400', 'h' => '400', 'fit' => 'contain'], 'cp_thumbnail_small_portrait' => ['h' => '400', 'fit' => 'contain'], 'cp_thumbnail_small_square' => ['w' => '400', 'h' => '400'], + 'og_image' => ['w' => 1146, 'h' => 600], + 'twitter_image' => ['w' => 1200, 'h' => 600], ], $this->manager->manipulationPresets()); } } diff --git a/tests/Listeners/UpdateAssetReferencesTest.php b/tests/Listeners/UpdateAssetReferencesTest.php index cbad0daa093..e5ff6cdb7a4 100644 --- a/tests/Listeners/UpdateAssetReferencesTest.php +++ b/tests/Listeners/UpdateAssetReferencesTest.php @@ -4,10 +4,12 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\LazyCollection; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\AssetFolder; use Statamic\Facades; +use Statamic\Listeners\UpdateAssetReferences; use Statamic\Support\Arr; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -707,6 +709,56 @@ public function it_updates_nested_asset_fields_within_grid_fields() $this->assertEquals(['content/norris.jpg', 'lee.jpg'], Arr::get($entry->fresh()->data(), 'griddy.1.pics')); } + #[Test] + public function it_updates_nested_asset_fields_within_group_fields() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'group_field', + 'field' => [ + 'type' => 'group', + 'fields' => [ + [ + 'handle' => 'product', + 'field' => [ + 'type' => 'assets', + 'container' => 'test_container', + 'max_files' => 1, + ], + ], + [ + 'handle' => 'pics', + 'field' => [ + 'type' => 'assets', + 'container' => 'test_container', + ], + ], + ], + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'group_field' => [ + 'product' => 'hoff.jpg', + 'pics' => ['hoff.jpg', 'norris.jpg', 'lee.jpg'], + ], + ]))->save(); + + $this->assertEquals('hoff.jpg', Arr::get($entry->data(), 'group_field.product')); + $this->assertEquals(['hoff.jpg', 'norris.jpg', 'lee.jpg'], Arr::get($entry->data(), 'group_field.pics')); + + $this->assetNorris->path('content/norris.jpg')->save(); + $this->assetHoff->delete(); + + $this->assertFalse(Arr::has($entry->fresh()->data(), 'group_field.product')); + $this->assertEquals(['content/norris.jpg', 'lee.jpg'], Arr::get($entry->fresh()->data(), 'group_field.pics')); + } + #[Test] public function it_updates_nested_asset_fields_within_bard_fields() { @@ -1802,6 +1854,19 @@ public function it_only_saves_items_when_there_is_something_to_update() $this->assetHoff->path('hoff-new.jpg')->save(); } + #[Test] + public function it_gets_items_from_a_hook() + { + UpdateAssetReferences::hook('additional', function () { + return LazyCollection::make(['additional-1', 'additional-2']); + }); + + $items = ((new UpdateAssetReferences)->getItemsContainingData())->all(); + + $this->assertContains('additional-1', $items); + $this->assertContains('additional-2', $items); + } + protected function setSingleBlueprint($namespace, $blueprintContents) { $blueprint = tap(Facades\Blueprint::make('single-blueprint')->setContents($blueprintContents))->save(); diff --git a/tests/Markdown/Fixtures/HeadingRenderer.php b/tests/Markdown/Fixtures/HeadingRenderer.php new file mode 100644 index 00000000000..1d457c4794c --- /dev/null +++ b/tests/Markdown/Fixtures/HeadingRenderer.php @@ -0,0 +1,20 @@ +{$childRenderer->renderNodes($node->children())}"; + } +} diff --git a/tests/Markdown/Fixtures/LinkRenderer.php b/tests/Markdown/Fixtures/LinkRenderer.php new file mode 100644 index 00000000000..c7c0c69a174 --- /dev/null +++ b/tests/Markdown/Fixtures/LinkRenderer.php @@ -0,0 +1,20 @@ +getUrl()}\" title=\"{$node->getTitle()}\">{$childRenderer->renderNodes($node->children())}"; + } +} diff --git a/tests/Markdown/MarkdownTest.php b/tests/Markdown/MarkdownTest.php index a5331dfabd4..b7304d26b95 100644 --- a/tests/Markdown/MarkdownTest.php +++ b/tests/Markdown/MarkdownTest.php @@ -274,7 +274,7 @@ public function it_uses_table_of_contents_on_demand()

Baz qux.

EOT, $markdown); - $this->assertEquals(<<<'EOT' + $expected = <<<'EOT'
  • Alfa Bravo @@ -287,8 +287,12 @@ public function it_uses_table_of_contents_on_demand()

    Foo bar.

    Charlie Delta

    Baz qux.

    -EOT, - rtrim(Markdown::withTableOfContents()->parse($markdown)) +EOT; + + // Make assertion without newlines because they differ between versions of commonmark. + $this->assertEquals( + str($expected)->replace("\n", ''), + str(Markdown::withTableOfContents()->parse($markdown))->trim()->replace("\n", '') ); } } diff --git a/tests/Markdown/ParserTest.php b/tests/Markdown/ParserTest.php index 32d4f674f12..3003e08748a 100644 --- a/tests/Markdown/ParserTest.php +++ b/tests/Markdown/ParserTest.php @@ -2,7 +2,11 @@ namespace Tests\Markdown; +use League\CommonMark\Extension\CommonMark\Node\Block\Heading; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fields\Field; +use Statamic\Fields\Value; use Statamic\Markdown; use Tests\TestCase; @@ -47,6 +51,38 @@ public function it_adds_extensions_using_an_array() $this->assertEquals("

    smile 😀 frown 🙁

    \n", $this->parser->parse('smile :) frown :(')); } + #[Test] + public function it_adds_a_renderer() + { + $this->assertEquals("

    test

    \n", $this->parser->parse('[test](http://example.com)')); + + $this->parser->addRenderer(function () { + return [ + Link::class, + new Fixtures\LinkRenderer, + ]; + }); + + $this->assertEquals("

    test

    \n", $this->parser->parse('[test](http://example.com)')); + } + + #[Test] + public function it_adds_renderers_using_an_array() + { + $this->assertEquals("

    test

    \n", $this->parser->parse('[test](http://example.com)')); + $this->assertEquals("

    Hello world

    \n", $this->parser->parse('# Hello world')); + + $this->parser->addRenderers(function () { + return [ + [Link::class, new Fixtures\LinkRenderer], + [Heading::class, new Fixtures\HeadingRenderer], + ]; + }); + + $this->assertEquals("

    test

    \n", $this->parser->parse('[test](http://example.com)')); + $this->assertEquals("

    Hello world

    \n", $this->parser->parse('# Hello world')); + } + #[Test] public function it_creates_a_new_instance_based_on_the_current_instance() { @@ -54,6 +90,13 @@ public function it_creates_a_new_instance_based_on_the_current_instance() return new Fixtures\SmileyExtension; }); + $this->parser->addRenderer(function () { + return [ + Link::class, + new Fixtures\LinkRenderer, + ]; + }); + $this->assertEquals("\n", $this->parser->config('renderer/block_separator')); $this->assertEquals("\n", $this->parser->config('renderer/inner_separator')); $this->assertEquals('allow', $this->parser->config('html_input')); @@ -71,11 +114,46 @@ public function it_creates_a_new_instance_based_on_the_current_instance() return new Fixtures\FrownyExtension; }); + $newParser->addRenderer(function () { + return [ + Heading::class, + new Fixtures\HeadingRenderer, + ]; + }); + $this->assertNotSame($this->parser, $newParser); $this->assertEquals("\n", $newParser->config('renderer/block_separator')); $this->assertEquals('foo', $newParser->config('renderer/inner_separator')); $this->assertEquals('strip', $newParser->config('html_input')); $this->assertCount(2, $newParser->extensions()); $this->assertCount(1, $this->parser->extensions()); + $this->assertCount(2, $newParser->renderers()); + $this->assertCount(1, $this->parser->renderers()); + } + + #[Test] + public function it_returns_instances_of_custom_parsers() + { + $markdown = new \Statamic\Fieldtypes\Markdown; + $field = new Field('test', [ + 'type' => 'markdown', + 'parser' => 'custom', + ]); + + $markdown->setField($field); + + $markdownValue = new Value('A Test', 'test', $markdown); + + $customParser = new class extends Markdown\Parser + { + public function parse(string $markdown): string + { + return strtolower($markdown); + } + }; + + \Statamic\Facades\Markdown::extend('custom', fn () => $customParser); + + $this->assertSame('a test', $markdownValue->value()); } } diff --git a/tests/Modifiers/BardTextTest.php b/tests/Modifiers/BardTextTest.php index 7a65612ba5d..0208bf5aba8 100644 --- a/tests/Modifiers/BardTextTest.php +++ b/tests/Modifiers/BardTextTest.php @@ -128,6 +128,34 @@ public function it_skips_nodes_with_no_type() $this->assertEquals($expected, $this->modify($data)); } + #[Test] + public function it_preserves_text_without_adding_spaces_between_marks() + { + $data = [ + [ + 'type' => 'paragraph', + 'content' => [ + ['type' => 'text', 'text' => 'The "stat" in '], + ['type' => 'text', 'marks' => [['type' => 'bold']], 'text' => 'Stat'], + ['type' => 'text', 'text' => 'amic stands for "static".'], + ], + ], + [ + // no type + ], + [ + 'type' => 'paragraph', + 'content' => [ + ['type' => 'text', 'text' => 'Another paragraph.'], + ], + ], + ]; + + $expected = 'The "stat" in Statamic stands for "static". Another paragraph.'; + + $this->assertEquals($expected, $this->modify($data)); + } + public function modify($arr, ...$args) { return Modify::value($arr)->bard_text($args)->fetch(); diff --git a/tests/Modifiers/DaysAgoTest.php b/tests/Modifiers/DaysAgoTest.php new file mode 100644 index 00000000000..06541a51c3d --- /dev/null +++ b/tests/Modifiers/DaysAgoTest.php @@ -0,0 +1,44 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same time' => ['2025-02-20 00:00', 0], + 'less than a day ago' => ['2025-02-19 11:00', 0], + '1 day ago' => ['2025-02-19 00:00', 1], + '2 days ago' => ['2025-02-18 00:00', 2], + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one day from now' => ['2025-02-21 00:00', 1], + 'less than a day from now' => ['2025-02-20 13:00', 0], + 'more than a day from now' => ['2025-02-21 13:00', 1], + ]; + } + + public function modify($value) + { + return Modify::value($value)->daysAgo()->fetch(); + } +} diff --git a/tests/Modifiers/DoesntOverlapTest.php b/tests/Modifiers/DoesntOverlapTest.php new file mode 100644 index 00000000000..4a270fb4f1d --- /dev/null +++ b/tests/Modifiers/DoesntOverlapTest.php @@ -0,0 +1,62 @@ +modify($haystack, ['b'], []); + $this->assertFalse($modified); + } + + /** @test */ + public function it_returns_true_if_needle_is_not_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['d'], []); + $this->assertTrue($modified); + } + + /** @test */ + public function it_returns_true_if_haystack_is_not_an_array(): void + { + $haystack = 'this is a string'; + + $modified = $this->modify($haystack, ['d'], []); + $this->assertTrue($modified); + } + + /** @test */ + public function it_returns_false_if_needle_is_an_array_and_is_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['array'], ['array' => ['a', 'b']]); + $this->assertFalse($modified); + } + + /** @test */ + public function it_returns_false_if_needle_is_an_array_and_some_are_not_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['array'], ['array' => ['d', 'b']]); + $this->assertFalse($modified); + } + + private function modify($value, array $params, array $context) + { + return Modify::value($value)->context($context)->doesntOverlap($params)->fetch(); + } +} diff --git a/tests/Modifiers/EmbedUrlTest.php b/tests/Modifiers/EmbedUrlTest.php index c452a54fc68..6061c170f3f 100644 --- a/tests/Modifiers/EmbedUrlTest.php +++ b/tests/Modifiers/EmbedUrlTest.php @@ -84,6 +84,35 @@ public function it_transforms_youtube_urls() ); } + #[Test] + public function it_ensures_url_with_query_parameters_are_valid() + { + $embedUrl = 'https://www.youtube-nocookie.com/embed/s72r_wu_NVY?pp=player_params'; + + $this->assertEquals( + $embedUrl, + $this->embed('https://www.youtube.com/watch?v=s72r_wu_NVY&pp=player_params'), + 'It transforms the youtube video link with additional query string params' + ); + $this->assertEquals( + $embedUrl, + $this->embed('https://youtu.be/s72r_wu_NVY?pp=player_params'), + 'It transforms shortened youtube video sharing links with additional query string params' + ); + + $this->assertEquals( + 'https://www.youtube-nocookie.com/embed/s72r_wu_NVY?start=559&pp=player_params', + $this->embed('https://youtu.be/s72r_wu_NVY?t=559&pp=player_params'), + 'It transforms the start time parameter of shortened sharing links with additional query string params' + ); + + $this->assertEquals( + 'https://www.youtube-nocookie.com/embed/hyJ7CBs_2RQ?start=2&pp=player_params', + $this->embed('https://www.youtube.com/watch?v=hyJ7CBs_2RQ&t=2&pp=player_params'), + 'It transforms the start time parameter of full youtube links with additional query string params' + ); + } + public function embed($url) { return Modify::value($url)->embedUrl()->fetch(); diff --git a/tests/Modifiers/FirstTest.php b/tests/Modifiers/FirstTest.php index bfdbe1400fe..1992133be1c 100644 --- a/tests/Modifiers/FirstTest.php +++ b/tests/Modifiers/FirstTest.php @@ -45,6 +45,27 @@ public static function arrayProvider() ]; } + #[Test] + #[DataProvider('collectionProvider')] + public function it_gets_the_first_value_of_a_collection($value, $expected) + { + $this->assertEquals($expected, $this->modify($value)); + } + + public static function collectionProvider() + { + return [ + 'list' => [ + collect(['alfa', 'bravo', 'charlie']), + 'alfa', + ], + 'associative' => [ + collect(['alfa' => 'bravo', 'charlie' => 'delta']), + 'bravo', + ], + ]; + } + private function modify($value, $arg = null) { return Modify::value($value)->first($arg)->fetch(); diff --git a/tests/Modifiers/HoursAgoTest.php b/tests/Modifiers/HoursAgoTest.php new file mode 100644 index 00000000000..761bc16fb02 --- /dev/null +++ b/tests/Modifiers/HoursAgoTest.php @@ -0,0 +1,44 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same time' => ['2025-02-20 13:10:00', 0], // 0.0 + 'less than a hour ago' => ['2025-02-20 13:00:00', 0], // 0.17 + '1 hour ago' => ['2025-02-20 12:10:00', 1], // 1.0 + '2 hours ago' => ['2025-02-20 11:10:00', 2], // 2.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one hour from now' => ['2025-02-20 14:10:00', 1], // -1.0 + 'less than a hour from now' => ['2025-02-20 13:30:00', 0], // -0.33 + 'more than a hour from now' => ['2025-02-20 15:10:00', 2], // -2.0 + ]; + } + + public function modify($value) + { + return Modify::value($value)->hoursAgo()->fetch(); + } +} diff --git a/tests/Modifiers/MinutesAgoTest.php b/tests/Modifiers/MinutesAgoTest.php new file mode 100644 index 00000000000..b564eebc39e --- /dev/null +++ b/tests/Modifiers/MinutesAgoTest.php @@ -0,0 +1,44 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same time' => ['2025-02-20 13:10:00', 0], // 0.0 + 'less than a minute ago' => ['2025-02-20 13:09:30', 0], // 0.5 + '1 minute ago' => ['2025-02-20 13:09:00', 1], // 1.0 + '2 minutes ago' => ['2025-02-20 13:08:00', 2], // 2.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one minute from now' => ['2025-02-20 13:11:00', 1], // -1.0 + 'less than a minute from now' => ['2025-02-20 13:10:30', 0], // -0.5 + 'more than a minute from now' => ['2025-02-20 13:11:30', 1], // -1.5 + ]; + } + + public function modify($value) + { + return Modify::value($value)->minutesAgo()->fetch(); + } +} diff --git a/tests/Modifiers/MonthsAgoTest.php b/tests/Modifiers/MonthsAgoTest.php new file mode 100644 index 00000000000..338e5db0248 --- /dev/null +++ b/tests/Modifiers/MonthsAgoTest.php @@ -0,0 +1,44 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same month' => ['2025-02-20', 0], // 0.0 + 'less than a month ago' => ['2025-02-10', 0], // 0.36 + '1 month ago' => ['2025-01-20', 1], // 1.0 + '2 months ago' => ['2024-12-20', 2], // 2.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one month from now' => ['2025-03-20', 1], // -1.0 + 'less than a month from now' => ['2025-02-25', 0], // -0.18 + 'more than a month from now' => ['2025-04-20', 2], // -2.0 + ]; + } + + public function modify($value) + { + return Modify::value($value)->monthsAgo()->fetch(); + } +} diff --git a/tests/Modifiers/OverlapsTest.php b/tests/Modifiers/OverlapsTest.php new file mode 100644 index 00000000000..877e5c84e69 --- /dev/null +++ b/tests/Modifiers/OverlapsTest.php @@ -0,0 +1,62 @@ +modify($haystack, ['b'], []); + $this->assertTrue($modified); + } + + #[Test] + public function it_returns_false_if_needle_is_not_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['d'], []); + $this->assertFalse($modified); + } + + #[Test] + public function it_returns_false_if_haystack_is_not_an_array(): void + { + $haystack = 'this is a string'; + + $modified = $this->modify($haystack, ['d'], []); + $this->assertFalse($modified); + } + + #[Test] + public function it_returns_true_if_needle_is_an_array_and_is_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['array'], ['array' => ['a', 'b']]); + $this->assertTrue($modified); + } + + #[Test] + public function it_returns_true_if_needle_is_an_array_and_some_are_not_found_in_array(): void + { + $haystack = ['a', 'b', 'c']; + + $modified = $this->modify($haystack, ['array'], ['array' => ['d', 'b']]); + $this->assertTrue($modified); + } + + private function modify($value, array $params, array $context) + { + return Modify::value($value)->context($context)->overlaps($params)->fetch(); + } +} diff --git a/tests/Modifiers/ResolveTest.php b/tests/Modifiers/ResolveTest.php new file mode 100644 index 00000000000..2467a609c58 --- /dev/null +++ b/tests/Modifiers/ResolveTest.php @@ -0,0 +1,64 @@ +data(['title' => 'Famous Gandalf quotes']) + ->create(); + + $modified = $this->modify(Entry::query()->where('collection', $collection)); + $this->assertIsArray($modified); + $this->assertEquals($entry, $modified[0]); + + $modified = $this->modify(Entry::query()->where('collection', $collection), [0]); + $this->assertEquals($entry, $modified); + } + + #[Test] + public function it_resolves_a_collection(): void + { + $modified = $this->modify(collect(['You shall not pass!', 'Fool of a Took'])); + $this->assertIsArray($modified); + $this->assertEquals(['You shall not pass!', 'Fool of a Took'], $modified); + + $modified = $this->modify(collect(['You shall not pass!', 'Fool of a Took']), [1]); + $this->assertEquals('Fool of a Took', $modified); + + $modified = $this->modify(collect(['one' => 'You shall not pass!', 'two' => 'Fool of a Took']), ['two']); + $this->assertEquals('Fool of a Took', $modified); + } + + #[Test] + public function it_resolves_an_array(): void + { + $modified = $this->modify(['You shall not pass!', 'Fool of a Took']); + $this->assertIsArray($modified); + $this->assertEquals(['You shall not pass!', 'Fool of a Took'], $modified); + + $modified = $this->modify(['You shall not pass!', 'Fool of a Took'], [1]); + $this->assertEquals('Fool of a Took', $modified); + + $modified = $this->modify(['one' => 'You shall not pass!', 'two' => 'Fool of a Took'], ['two']); + $this->assertEquals('Fool of a Took', $modified); + } + + private function modify($value, array $params = []) + { + return Modify::value($value)->resolve($params)->fetch(); + } +} diff --git a/tests/Modifiers/SecondsAgoTest.php b/tests/Modifiers/SecondsAgoTest.php new file mode 100644 index 00000000000..4a9ba572c46 --- /dev/null +++ b/tests/Modifiers/SecondsAgoTest.php @@ -0,0 +1,42 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same second' => ['2025-02-20 13:10:30', 0], // 0.0 + '1 second ago' => ['2025-02-20 13:10:29', 1], // 1.0 + '2 seconds ago' => ['2025-02-20 13:10:28', 2], // 2.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one second from now' => ['2025-02-20 13:10:31', 1], // -1.0 + 'two seconds from now' => ['2025-02-20 13:10:32', 2], // -2.0 + ]; + } + + public function modify($value) + { + return Modify::value($value)->secondsAgo()->fetch(); + } +} diff --git a/tests/Modifiers/SelectTest.php b/tests/Modifiers/SelectTest.php index 2a8b5cd58f4..f0a32d7bc16 100644 --- a/tests/Modifiers/SelectTest.php +++ b/tests/Modifiers/SelectTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Query\Builder; use Statamic\Entries\EntryCollection; use Statamic\Modifiers\Modify; @@ -12,7 +13,7 @@ class SelectTest extends TestCase { - /** @test */ + #[Test] public function it_selects_certain_values_from_array_of_items() { $items = $this->items(); @@ -38,7 +39,7 @@ public function it_selects_certain_values_from_array_of_items() ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_collections_of_items() { $items = Collection::make($this->items()); @@ -64,7 +65,7 @@ public function it_selects_certain_values_from_collections_of_items() ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_query_builder() { $builder = Mockery::mock(Builder::class); @@ -91,7 +92,7 @@ public function it_selects_certain_values_from_query_builder() ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_array_of_items_with_origins() { $items = $this->itemsWithOrigins(); @@ -121,7 +122,7 @@ public function it_selects_certain_values_from_array_of_items_with_origins() ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_collections_of_items_with_origins() { $items = EntryCollection::make($this->itemsWithOrigins()); @@ -151,7 +152,7 @@ public function it_selects_certain_values_from_collections_of_items_with_origins ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_array_of_items_of_type_array() { $items = $this->itemsOfTypeArray(); @@ -177,7 +178,7 @@ public function it_selects_certain_values_from_array_of_items_of_type_array() ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_collections_of_items_of_type_array() { $items = EntryCollection::make($this->itemsOfTypeArray()); @@ -203,7 +204,7 @@ public function it_selects_certain_values_from_collections_of_items_of_type_arra ); } - /** @test */ + #[Test] public function it_selects_certain_values_from_array_of_items_of_type_arrayaccess() { $items = $this->itemsOfTypeArrayAccess(); diff --git a/tests/Modifiers/TrackableEmbedUrlTest.php b/tests/Modifiers/TrackableEmbedUrlTest.php index e606651b3f2..a87171230fb 100644 --- a/tests/Modifiers/TrackableEmbedUrlTest.php +++ b/tests/Modifiers/TrackableEmbedUrlTest.php @@ -50,6 +50,34 @@ public function it_transforms_youtube_urls() ); } + public function it_ensures_url_with_query_parameters_are_valid() + { + $embedUrl = 'https://www.youtube-nocookie.com/embed/s72r_wu_NVY?pp=player_params'; + + $this->assertEquals( + $embedUrl, + $this->embed('https://www.youtube.com/watch?v=s72r_wu_NVY&pp=player_params'), + 'It transforms the youtube video link with additional query string params' + ); + $this->assertEquals( + $embedUrl, + $this->embed('https://youtu.be/s72r_wu_NVY?pp=player_params'), + 'It transforms shortened youtube video sharing links with additional query string params' + ); + + $this->assertEquals( + 'https://www.youtube-nocookie.com/embed/s72r_wu_NVY?start=559&pp=player_params', + $this->embed('https://youtu.be/s72r_wu_NVY?t=559&pp=player_params'), + 'It transforms the start time parameter of shortened sharing links with additional query string params' + ); + + $this->assertEquals( + 'https://www.youtube-nocookie.com/embed/hyJ7CBs_2RQ?start=2&pp=player_params', + $this->embed('https://www.youtube.com/watch?v=hyJ7CBs_2RQ&t=2&pp=player_params'), + 'It transforms the start time parameter of full youtube links with additional query string params' + ); + } + public function embed($url) { return Modify::value($url)->trackableEmbedUrl()->fetch(); diff --git a/tests/Modifiers/WeeksAgoTest.php b/tests/Modifiers/WeeksAgoTest.php new file mode 100644 index 00000000000..cb9c993ace8 --- /dev/null +++ b/tests/Modifiers/WeeksAgoTest.php @@ -0,0 +1,45 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + 'same day' => ['2025-02-20', 0], // 0.0 + 'same week' => ['2025-02-19', 0], // 0.14 + 'less than a week ago' => ['2025-02-17', 0], // 0.43 + '1 week ago' => ['2025-02-13', 1], // 1.0 + '2 weeks ago' => ['2025-02-06', 2], // 2.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + 'one week from now' => ['2025-02-27', 1], // -1.0 + 'less than a week from now' => ['2025-02-22', 0], // -0.29 + 'more than a week from now' => ['2025-03-08', 2], // -2.29 + ]; + } + + public function modify($value) + { + return Modify::value($value)->weeksAgo()->fetch(); + } +} diff --git a/tests/Modifiers/YearsAgoTest.php b/tests/Modifiers/YearsAgoTest.php new file mode 100644 index 00000000000..24c58946370 --- /dev/null +++ b/tests/Modifiers/YearsAgoTest.php @@ -0,0 +1,42 @@ +assertSame($expected, $this->modify(Carbon::parse($input))); + } + + public static function dateProvider() + { + return [ + // Carbon 3 would return floats but to preserve backwards compatibility + // with Carbon 2 we will cast to integers. + '2 years' => ['2023-02-20', 2], // 2.0 + 'not quite 3 years' => ['2022-08-20', 2], // 2.5 + '3 years' => ['2022-02-20', 3], // 3.0 + + // Future dates would return negative numbers in Carbon 3 but to preserve + // backwards compatibility with Carbon 2, we keep them positive. + '1 year from now' => ['2026-02-20', 1], // -1.0 + 'less than a year from now' => ['2025-12-20', 0], // -0.83 + ]; + } + + public function modify($value) + { + return Modify::value($value)->yearsAgo()->fetch(); + } +} diff --git a/tests/OAuth/OAuthRedirectTest.php b/tests/OAuth/OAuthRedirectTest.php new file mode 100644 index 00000000000..02212a49df6 --- /dev/null +++ b/tests/OAuth/OAuthRedirectTest.php @@ -0,0 +1,42 @@ + 'http://localhost/oauth/test?redirect=/dashboard']); + + $this->assertEquals('/dashboard', $this->getSuccessRedirectUrl()); + } + + #[Test] + public function it_does_not_redirect_to_external_url() + { + session(['_previous.url' => 'http://localhost/oauth/test?redirect=https://evil.com']); + + $this->assertEquals('/', $this->getSuccessRedirectUrl()); + } + + /** + * The successRedirectUrl() method is protected, so we need to new up a fake class to call it. + */ + private function getSuccessRedirectUrl(): string + { + $controller = new class extends OAuthController + { + public function getSuccessRedirectUrl() + { + return $this->successRedirectUrl(); + } + }; + + return $controller->getSuccessRedirectUrl(); + } +} diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index e5d2edecd99..917aba151e6 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -74,6 +74,8 @@ public function it_merges_data() $user = $this->user()->save(); + $this->assertEquals(['name' => 'foo', 'extra' => 'bar'], $user->data()->all()); + $provider->mergeUser($user, $this->socialite()); $this->assertEquals(['name' => 'Foo Bar', 'extra' => 'bar'], $user->data()->all()); @@ -122,20 +124,91 @@ public function it_creates_a_user() } #[Test] - public function it_finds_an_existing_user_by_email() + public function it_finds_an_existing_user_via_find_user_method() + { + $provider = $this->provider(); + + $savedUser = $this->user()->save(); + + $this->assertCount(1, UserFacade::all()); + $this->assertEquals([$savedUser], UserFacade::all()->all()); + + $foundUser = $provider->findUser($this->socialite()); + + $this->assertCount(1, UserFacade::all()); + $this->assertEquals([$savedUser], UserFacade::all()->all()); + $this->assertEquals($savedUser, $foundUser); + } + + #[Test] + public function it_does_not_find_or_create_a_user_via_find_user_method() + { + $this->assertCount(0, UserFacade::all()); + + $provider = $this->provider(); + $foundUser = $provider->findUser($this->socialite()); + + $this->assertNull($foundUser); + + $this->assertCount(0, UserFacade::all()); + $user = UserFacade::all()->get(0); + $this->assertNull($user); + } + + #[Test] + public function it_finds_an_existing_user_via_find_or_create_user_method() + { + $provider = $this->provider(); + + $savedUser = $this->user()->save(); + + $this->assertCount(1, UserFacade::all()); + $this->assertEquals([$savedUser], UserFacade::all()->all()); + $this->assertEquals('foo', $savedUser->name); + + $foundUser = $provider->findOrCreateUser($this->socialite()); + + $this->assertCount(1, UserFacade::all()); + $this->assertEquals([$savedUser], UserFacade::all()->all()); + $this->assertEquals($savedUser, $foundUser); + $this->assertEquals('Foo Bar', $savedUser->name); + } + + #[Test] + public function it_finds_an_existing_user_via_find_or_create_user_method_but_doesnt_merge_data() { + config(['statamic.oauth.merge_user_data' => false]); + $provider = $this->provider(); $savedUser = $this->user()->save(); $this->assertCount(1, UserFacade::all()); $this->assertEquals([$savedUser], UserFacade::all()->all()); + $this->assertEquals('foo', $savedUser->name); $foundUser = $provider->findOrCreateUser($this->socialite()); $this->assertCount(1, UserFacade::all()); $this->assertEquals([$savedUser], UserFacade::all()->all()); $this->assertEquals($savedUser, $foundUser); + $this->assertEquals('foo', $savedUser->name); + } + + #[Test] + public function it_creates_a_user_via_find_or_create_user_method() + { + $this->assertCount(0, UserFacade::all()); + + $provider = $this->provider(); + $provider->findOrCreateUser($this->socialite()); + + $this->assertCount(1, UserFacade::all()); + $user = UserFacade::all()->get(0); + $this->assertNotNull($user); + $this->assertEquals('foo@bar.com', $user->email()); + $this->assertEquals('Foo Bar', $user->name()); + $this->assertEquals($user->id(), $provider->getUserId('foo-bar')); } #[Test] diff --git a/tests/Policies/AssetContainerPolicyTest.php b/tests/Policies/AssetContainerPolicyTest.php new file mode 100644 index 00000000000..5015e609c31 --- /dev/null +++ b/tests/Policies/AssetContainerPolicyTest.php @@ -0,0 +1,69 @@ +userWithPermissions(['view alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('view', $containerA)); + $this->assertFalse($user->can('view', $containerB)); + } + + #[Test] + public function it_can_only_see_index_if_there_are_authorized_containers() + { + $userAlfa = $this->userWithPermissions(['view alfa assets']); + $userBravo = $this->userWithPermissions(['view bravo assets']); + $userAlfaBravo = $this->userWithPermissions(['view alfa assets', 'view bravo assets']); + $userNone = $this->userWithPermissions([]); + + tap(AssetContainer::make('alfa'))->save(); + tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($userAlfa->can('index', [ContainerContract::class])); + $this->assertTrue($userBravo->can('index', [ContainerContract::class])); + $this->assertTrue($userAlfaBravo->can('index', [ContainerContract::class])); + $this->assertFalse($userNone->can('index', [ContainerContract::class])); + } + + #[Test] + public function configure_permission_grants_access_to_everything_else() + { + $userWithPermission = $this->userWithPermissions(['configure asset containers']); + $userWithoutConfigurePermission = $this->userWithPermissions(['view alfa assets']); + $userWithoutAnyPermissions = $this->userWithPermissions([]); + + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertTrue($userWithPermission->can('view', $container)); + $this->assertTrue($userWithPermission->can('index', [ContainerContract::class])); + $this->assertTrue($userWithPermission->can('create', [ContainerContract::class])); + $this->assertTrue($userWithPermission->can('edit', $container)); + $this->assertTrue($userWithPermission->can('update', $container)); + $this->assertTrue($userWithPermission->can('delete', $container)); + + $this->assertTrue($userWithoutConfigurePermission->can('view', $container)); + $this->assertTrue($userWithoutConfigurePermission->can('index', [ContainerContract::class])); + $this->assertFalse($userWithoutConfigurePermission->can('create', [ContainerContract::class])); + $this->assertFalse($userWithoutConfigurePermission->can('edit', $container)); + $this->assertFalse($userWithoutConfigurePermission->can('update', $container)); + $this->assertFalse($userWithoutConfigurePermission->can('delete', $container)); + + $this->assertFalse($userWithoutAnyPermissions->can('view', $container)); + $this->assertFalse($userWithoutAnyPermissions->can('index', [ContainerContract::class])); + $this->assertFalse($userWithoutAnyPermissions->can('create', [ContainerContract::class])); + $this->assertFalse($userWithoutAnyPermissions->can('edit', $container)); + $this->assertFalse($userWithoutAnyPermissions->can('update', $container)); + $this->assertFalse($userWithoutAnyPermissions->can('delete', $container)); + } +} diff --git a/tests/Policies/AssetFolderPolicyTest.php b/tests/Policies/AssetFolderPolicyTest.php new file mode 100644 index 00000000000..394f51e5938 --- /dev/null +++ b/tests/Policies/AssetFolderPolicyTest.php @@ -0,0 +1,212 @@ + true]); + + $user = $this->userWithPermissions( + $hasPermission ? ['edit alfa folders'] : [] + ); + + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('create', [AssetFolder::class, $container])); + } + + #[Test] + #[DataProvider('createProvider')] + public function it_can_be_created($hasPermission, $createFolders, $expected) + { + $user = $this->userWithPermissions( + $hasPermission ? ['upload alfa assets'] : [] + ); + + $container = tap(AssetContainer::make('alfa')->createFolders($createFolders))->save(); + + $this->assertEquals($expected, $user->can('create', [AssetFolder::class, $container])); + } + + public static function createProvider() + { + return [ + 'with permission, can create' => [true, true, true], + 'with permission, cannot create' => [true, false, false], + 'without permission, can create' => [false, true, false], + 'without permission, cannot create' => [false, false, false], + ]; + } + + #[Test] + #[DataProvider('moveV6Provider')] + public function it_can_be_moved_v6($folder, $asset, $expected) + { + // The v6 way doesn't need to check the container for allowMoving() because it'll be removed. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(collect([ + $folder ? 'edit alfa folders' : null, + $asset ? 'move alfa assets' : null, + ])->filter()->all()); + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('move', $container->assetFolder('path/to/folder'))); + } + + public static function moveV6Provider() + { + return [ + 'folder, asset' => ['folder' => true, 'asset' => true, 'expected' => true], + 'folder only' => ['folder' => true, 'asset' => false, 'expected' => false], + 'asset only' => ['folder' => false, 'asset' => true, 'expected' => false], + 'none' => ['folder' => false, 'asset' => false, 'expected' => false], + ]; + } + + #[Test] + #[DataProvider('moveProvider')] + public function it_can_be_moved($hasPermission, $allowMoving, $expected) + { + $user = $this->userWithPermissions( + $hasPermission ? ['move alfa assets'] : [] + ); + + $container = tap(AssetContainer::make('alfa')->allowMoving($allowMoving))->save(); + + $this->assertEquals($expected, $user->can('move', $container->assetFolder('path/to/folder'))); + } + + public static function moveProvider() + { + return [ + 'with permission, can move' => [true, true, true], + 'with permission, cannot move' => [true, false, false], + 'without permission, can move' => [false, true, false], + 'without permission, cannot move' => [false, false, false], + ]; + } + + #[Test] + #[DataProvider('renameV6Provider')] + public function it_can_be_renamed_v6($folder, $asset, $expected) + { + // The v6 way doesn't need to check the container for allowRenaming() because it'll be removed. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(collect([ + $folder ? 'edit alfa folders' : null, + $asset ? 'rename alfa assets' : null, + ])->filter()->all()); + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('rename', $container->assetFolder('path/to/folder'))); + } + + public static function renameV6Provider() + { + return [ + 'folder, asset' => ['folder' => true, 'asset' => true, 'expected' => true], + 'folder only' => ['folder' => true, 'asset' => false, 'expected' => false], + 'asset only' => ['folder' => false, 'asset' => true, 'expected' => false], + 'none' => ['folder' => false, 'asset' => false, 'expected' => false], + ]; + } + + #[Test] + #[DataProvider('renameProvider')] + public function it_can_be_renamed() + { + $user = $this->userWithPermissions( + ['rename alfa assets'] + ); + + $container = tap(AssetContainer::make('alfa')->allowRenaming(true))->save(); + + $this->assertTrue($user->can('rename', $container->assetFolder('path/to/folder'))); + } + + public static function renameProvider() + { + return [ + 'with permission, can rename' => [true, true, true], + 'with permission, cannot rename' => [true, false, false], + 'without permission, can rename' => [false, true, false], + 'without permission, cannot rename' => [false, false, false], + ]; + } + + #[Test] + #[DataProvider('deleteV6Provider')] + public function it_can_be_deleted_v6($folder, $asset, $expected) + { + // the v6 way checks for both folder and asset delete permissions. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(collect([ + $folder ? 'edit alfa folders' : null, + $asset ? 'delete alfa assets' : null, + ])->filter()->all()); + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('delete', $container->assetFolder('path/to/folder'))); + } + + public static function deleteV6Provider() + { + return [ + 'folder, asset' => ['folder' => true, 'asset' => true, 'expected' => true], + 'folder only' => ['folder' => true, 'asset' => false, 'expected' => false], + 'asset only' => ['folder' => false, 'asset' => true, 'expected' => false], + 'none' => ['folder' => false, 'asset' => false, 'expected' => false], + ]; + } + + #[Test] + #[TestWith([true, true], 'with permission')] + #[TestWith([false, false], 'without permission')] + public function it_can_be_deleted($hasPermission, $expected) + { + // the legacy way is to check for asset delete permission + + $user = $this->userWithPermissions( + $hasPermission ? ['delete alfa assets'] : [] + ); + + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('delete', $container->assetFolder('path/to/folder'))); + } + + #[Test] + public function user_with_configure_permission_can_do_it_all() + { + $userWithPermission = $this->userWithPermissions(['configure asset containers']); + $userWithoutPermission = $this->userWithPermissions([]); + + $container = tap(AssetContainer::make('alfa'))->save(); + $folder = $container->assetFolder('path/to/folder'); + + $this->assertTrue($userWithPermission->can('create', [AssetFolder::class, $container])); + $this->assertTrue($userWithPermission->can('move', $folder)); + $this->assertTrue($userWithPermission->can('rename', $folder)); + $this->assertTrue($userWithPermission->can('delete', $folder)); + + $this->assertFalse($userWithoutPermission->can('create', [AssetFolder::class, $container])); + $this->assertFalse($userWithoutPermission->can('move', $folder)); + $this->assertFalse($userWithoutPermission->can('rename', $folder)); + $this->assertFalse($userWithoutPermission->can('delete', $folder)); + } +} diff --git a/tests/Policies/AssetPolicyTest.php b/tests/Policies/AssetPolicyTest.php new file mode 100644 index 00000000000..eda6f217147 --- /dev/null +++ b/tests/Policies/AssetPolicyTest.php @@ -0,0 +1,219 @@ +userWithPermissions(['view alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('view', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('view', $containerB->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_edited() + { + $user = $this->userWithPermissions(['edit alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('edit', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('edit', $containerB->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_stored_v6() + { + // The v6 way doesn't need to check the container for allowUploads() because it'll be removed. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(['upload alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('store', [Asset::class, $containerA])); + $this->assertFalse($user->can('store', [Asset::class, $containerB])); + } + + #[Test] + public function it_can_be_stored() + { + $user = $this->userWithPermissions([ + 'upload alfa assets', + 'upload charlie assets', + ]); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + $containerC = tap(AssetContainer::make('charlie')->allowUploads(false))->save(); + $containerD = tap(AssetContainer::make('delta')->allowUploads(false))->save(); + + $this->assertTrue($user->can('store', [Asset::class, $containerA])); + $this->assertFalse($user->can('store', [Asset::class, $containerB])); + $this->assertFalse($user->can('store', [Asset::class, $containerC])); + $this->assertFalse($user->can('store', [Asset::class, $containerD])); + } + + #[Test] + public function it_can_be_moved_v6() + { + // The v6 way doesn't need to check the container for allowMoving() because it'll be removed. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(['move alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('move', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('move', $containerB->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_moved() + { + $user = $this->userWithPermissions([ + 'move alfa assets', + 'move charlie assets', + ]); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + $containerC = tap(AssetContainer::make('charlie')->allowMoving(false))->save(); + $containerD = tap(AssetContainer::make('delta')->allowMoving(false))->save(); + + $this->assertTrue($user->can('move', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('move', $containerB->makeAsset('test.txt'))); + $this->assertFalse($user->can('move', $containerC->makeAsset('test.txt'))); + $this->assertFalse($user->can('move', $containerD->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_renamed_v6() + { + // The v6 way doesn't need to check the container for allowRenaming() because it'll be removed. + config(['statamic.assets.v6_permissions' => true]); + + $user = $this->userWithPermissions(['rename alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('rename', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('rename', $containerB->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_renamed() + { + $user = $this->userWithPermissions([ + 'rename alfa assets', + 'rename charlie assets', + ]); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + $containerC = tap(AssetContainer::make('charlie')->allowRenaming(false))->save(); + $containerD = tap(AssetContainer::make('delta')->allowRenaming(false))->save(); + + $this->assertTrue($user->can('rename', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('rename', $containerB->makeAsset('test.txt'))); + $this->assertFalse($user->can('rename', $containerC->makeAsset('test.txt'))); + $this->assertFalse($user->can('rename', $containerD->makeAsset('test.txt'))); + } + + #[Test] + public function it_can_be_deleted() + { + $user = $this->userWithPermissions(['delete alfa assets']); + $containerA = tap(AssetContainer::make('alfa'))->save(); + $containerB = tap(AssetContainer::make('bravo'))->save(); + + $this->assertTrue($user->can('delete', $containerA->makeAsset('test.txt'))); + $this->assertFalse($user->can('delete', $containerB->makeAsset('test.txt'))); + } + + #[Test] + #[DataProvider('replaceProvider')] + public function it_can_be_replaced($canEdit, $canStore, $canDelete, $expected) + { + $user = $this->userWithPermissions(collect([ + $canEdit ? 'edit alfa assets' : null, + $canStore ? 'upload alfa assets' : null, + $canDelete ? 'delete alfa assets' : null, + ])->filter()->all()); + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('replace', $container->makeAsset('test.txt'))); + } + + public static function replaceProvider() + { + return [ + 'edit, store, delete' => ['canEdit' => true, 'canStore' => true, 'canDelete' => true, 'expected' => true], + 'edit, store' => ['canEdit' => true, 'canStore' => true, 'canDelete' => false, 'expected' => false], + 'edit, delete' => ['canEdit' => true, 'canStore' => false, 'canDelete' => true, 'expected' => false], + 'store, delete' => ['canEdit' => false, 'canStore' => true, 'canDelete' => true, 'expected' => false], + 'edit only' => ['canEdit' => true, 'canStore' => false, 'canDelete' => false, 'expected' => false], + 'store only' => ['canEdit' => false, 'canStore' => true, 'canDelete' => false, 'expected' => false], + 'delete only' => ['canEdit' => false, 'canStore' => false, 'canDelete' => true, 'expected' => false], + 'none' => ['canEdit' => false, 'canStore' => false, 'canDelete' => false, 'expected' => false], + ]; + } + + #[Test] + #[DataProvider('reuploadProvider')] + public function it_can_be_reuploaded($canEdit, $canStore, $expected) + { + $user = $this->userWithPermissions(collect([ + $canEdit ? 'edit alfa assets' : null, + $canStore ? 'upload alfa assets' : null, + ])->filter()->all()); + $container = tap(AssetContainer::make('alfa'))->save(); + + $this->assertEquals($expected, $user->can('reupload', $container->makeAsset('test.txt'))); + } + + public static function reuploadProvider() + { + return [ + 'edit, store' => ['canEdit' => true, 'canStore' => true, 'expected' => true], + 'edit only' => ['canEdit' => true, 'canStore' => false, 'expected' => false], + 'store only' => ['canEdit' => false, 'canStore' => true, 'expected' => false], + 'none' => ['canEdit' => false, 'canStore' => false, 'expected' => false], + ]; + } + + #[Test] + public function user_with_configure_permission_can_do_it_all() + { + $userWithPermission = $this->userWithPermissions(['configure asset containers']); + $userWithoutPermission = $this->userWithPermissions([]); + + $container = tap(AssetContainer::make('alfa'))->save(); + $asset = $container->makeAsset('test.txt'); + + $this->assertTrue($userWithPermission->can('view', $asset)); + $this->assertTrue($userWithPermission->can('edit', $asset)); + $this->assertTrue($userWithPermission->can('store', [Asset::class, $container])); + $this->assertTrue($userWithPermission->can('move', $asset)); + $this->assertTrue($userWithPermission->can('rename', $asset)); + $this->assertTrue($userWithPermission->can('delete', $asset)); + $this->assertTrue($userWithPermission->can('replace', $asset)); + $this->assertTrue($userWithPermission->can('reupload', $asset)); + + $this->assertFalse($userWithoutPermission->can('view', $asset)); + $this->assertFalse($userWithoutPermission->can('edit', $asset)); + $this->assertFalse($userWithoutPermission->can('store', [Asset::class, $container])); + $this->assertFalse($userWithoutPermission->can('move', $asset)); + $this->assertFalse($userWithoutPermission->can('rename', $asset)); + $this->assertFalse($userWithoutPermission->can('delete', $asset)); + $this->assertFalse($userWithoutPermission->can('replace', $asset)); + $this->assertFalse($userWithoutPermission->can('reupload', $asset)); + } +} diff --git a/tests/Query/FakesQueriesTest.php b/tests/Query/FakesQueriesTest.php new file mode 100644 index 00000000000..b06726433e7 --- /dev/null +++ b/tests/Query/FakesQueriesTest.php @@ -0,0 +1,45 @@ +where('name', 'Jack'); + $this->assertSame('select * from users where name = ?', $query->toSql()); + } + + #[Test] + public function it_supports_to_raw_sql() + { + $query = User::query()->where('name', 'Jack'); + $this->assertSame("select * from users where name = 'Jack'", $query->toRawSql()); + } + + #[Test] + public function it_supports_dump_raw_sql() + { + $query = User::query()->where('name', 'Jack'); + $this->assertSame($query, $query->dumpRawSql()); + } + + #[Test] + public function it_supports_dd_raw_sql() + { + $query = User::query()->where('name', 'Jack'); + $this->assertIsCallable([$query, 'ddRawSql']); + } + + #[Test] + public function it_supports_ray() + { + $query = User::query()->where('name', 'Jack'); + $this->assertIsCallable([$query, 'ray']); + } +} diff --git a/tests/Revisions/RepositoryTest.php b/tests/Revisions/RepositoryTest.php index 0f8c1ccaee1..aa5a408fb48 100644 --- a/tests/Revisions/RepositoryTest.php +++ b/tests/Revisions/RepositoryTest.php @@ -28,4 +28,12 @@ public function it_gets_revisions_and_excludes_working_copies() $this->assertCount(2, $revisions); $this->assertContainsOnlyInstancesOf(Revision::class, $revisions); } + + #[Test] + public function it_can_call_to_array_on_a_revision_collection() + { + $revisions = $this->repo->whereKey('123'); + + $this->assertIsArray($revisions->toArray()); + } } diff --git a/tests/Routing/ResolveRedirectTest.php b/tests/Routing/ResolveRedirectTest.php index 62cd7ee7c6c..185bd410860 100644 --- a/tests/Routing/ResolveRedirectTest.php +++ b/tests/Routing/ResolveRedirectTest.php @@ -269,4 +269,15 @@ public function it_can_resolve_arrays_with_url_and_code() $this->assertEquals('/test', $resolver($arr)); $this->assertEquals('/test', $resolver->item($arr)); } + + #[Test] + public function it_can_resolve_an_arrayable_link() + { + $resolver = new ResolveRedirect; + + $arrayableLink = Mockery::mock(ArrayableLink::class)->shouldReceive('url')->times(2)->andReturns('/test', null)->getMock(); + + $this->assertEquals('/test', $resolver($arrayableLink)); + $this->assertEquals(404, $resolver($arrayableLink)); + } } diff --git a/tests/Routing/RoutesTest.php b/tests/Routing/RoutesTest.php index ba2d5a137b6..d311ff2d781 100644 --- a/tests/Routing/RoutesTest.php +++ b/tests/Routing/RoutesTest.php @@ -3,6 +3,7 @@ namespace Tests\Routing; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -31,18 +32,68 @@ protected function resolveApplicationConfiguration($app) parent::resolveApplicationConfiguration($app); $app->booted(function () { + Route::statamic('/basic-route'); + Route::statamic('/basic-route-with-data', 'test', ['hello' => 'world']); - Route::statamic('/basic-route-with-data-from-closure', 'test', function () { + Route::statamic('/basic-route-with-view-closure', function () { + return view('test', ['hello' => 'world']); + }); + + Route::statamic('/basic-route-with-view-closure-and-dependency-injection', function (Request $request, FooClass $foo) { + return view('test', ['hello' => "view closure dependencies: $request->value $foo->value"]); + }); + + Route::statamic('/basic-route-with-view-closure-and-custom-return', function () { + return ['message' => 'not a view instance']; + }); + + Route::statamic('/basic-route-with-data-closure', 'test', function () { return ['hello' => 'world']; }); + Route::statamic('/basic-route-with-data-closure-and-dependency-injection', 'test', function (Request $request, FooClass $foo) { + return ['hello' => "data closure dependencies: $request->value $foo->value"]; + }); + + Route::statamic('/you-cannot-use-data-param-with-view-closure', function () { + return view('test', ['hello' => 'world']); + }, 'hello'); + Route::statamic('/basic-route-without-data', 'test'); Route::statamic('/route/with/placeholders/{foo}/{bar}/{baz}', 'test'); - Route::statamic('/route/with/placeholders/closure/{foo}/{bar}/{baz}', 'test', function ($foo, $bar, $baz) { - return ['hello' => "$foo $bar $baz"]; + Route::statamic('/route/with/placeholders/view/closure/{foo}/{bar}/{baz}', function ($foo, $bar, $baz) { + return view('test', ['hello' => "view closure placeholders: $foo $bar $baz"]); + }); + + Route::statamic('/route/with/placeholders/view/closure-dependency-injection/{baz}/{qux}', function (Request $request, FooClass $foo, BarClass $bar, $baz, $qux) { + return view('test', ['hello' => "view closure dependencies: $request->value $foo->value $bar->value $baz $qux"]); + }); + + Route::statamic('/route/with/placeholders/view/closure-dependency-order-doesnt-matter/{baz}/{qux}', function (FooClass $foo, $baz, BarClass $bar, Request $request, $qux) { + return view('test', ['hello' => "view closure dependencies: $request->value $foo->value $bar->value $baz $qux"]); + }); + + Route::statamic('/route/with/placeholders/view/closure-primitive-type-hints/{name}/{age}', function (string $name, int $age) { + return view('test', ['hello' => "view closure placeholders: $name $age"]); + }); + + Route::statamic('/route/with/placeholders/data/closure/{foo}/{bar}/{baz}', 'test', function ($foo, $bar, $baz) { + return ['hello' => "data closure placeholders: $foo $bar $baz"]; + }); + + Route::statamic('/route/with/placeholders/data/closure-dependency-injection/{baz}/{qux}', 'test', function (Request $request, FooClass $foo, BarClass $bar, $baz, $qux) { + return ['hello' => "data closure dependencies: $request->value $foo->value $bar->value $baz $qux"]; + }); + + Route::statamic('/route/with/placeholders/data/closure-dependency-order-doesnt-matter/{baz}/{qux}', 'test', function (FooClass $foo, $baz, BarClass $bar, Request $request, $qux) { + return ['hello' => "data closure dependencies: $request->value $foo->value $bar->value $baz $qux"]; + }); + + Route::statamic('/route/with/placeholders/data/closure-primitive-type-hints/{name}/{age}', 'test', function (string $name, int $age) { + return ['hello' => "data closure placeholders: $name $age"]; }); Route::statamic('/route-with-custom-layout', 'test', [ @@ -100,6 +151,8 @@ protected function resolveApplicationConfiguration($app) }); }); + + Route::statamic('/callables-test', 'auth'); }); } @@ -115,16 +168,119 @@ public function it_renders_a_view() } #[Test] - public function it_renders_a_view_with_data_from_a_closure() + public function it_renders_a_view_implied_from_the_route() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('basic-route', 'Hello world'); + + $this->get('/basic-route') + ->assertOk() + ->assertSee('Hello world'); + } + + #[Test] + public function it_renders_a_view_using_a_view_closure() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/basic-route-with-view-closure') + ->assertOk() + ->assertSee('Hello world'); + } + + #[Test] + public function it_renders_a_view_using_a_view_closure_with_dependency_injection() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/basic-route-with-view-closure-and-dependency-injection?value=request_value') + ->assertOk() + ->assertSee('Hello view closure dependencies: request_value foo_class'); + } + + #[Test] + public function it_renders_a_view_using_a_view_closure_with_dependency_injection_from_container() { $this->viewShouldReturnRaw('layout', '{{ template_content }}'); $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); - $this->get('/basic-route-with-data-from-closure') + app()->bind(FooClass::class, function () { + $foo = new FooClass; + $foo->value = 'foo_modified'; + + return $foo; + }); + + $this->get('/basic-route-with-view-closure-and-dependency-injection?value=request_value') + ->assertOk() + ->assertSee('Hello view closure dependencies: request_value foo_modified'); + } + + #[Test] + public function it_renders_a_view_using_a_custom_view_closure_that_does_not_return_a_view_instance() + { + $this->get('/basic-route-with-view-closure-and-custom-return') + ->assertOk() + ->assertJson([ + 'message' => 'not a view instance', + ]); + } + + #[Test] + public function it_renders_a_view_using_a_data_closure() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/basic-route-with-data-closure') ->assertOk() ->assertSee('Hello world'); } + #[Test] + public function it_renders_a_view_using_a_data_closure_with_dependency_injection() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/basic-route-with-data-closure-and-dependency-injection?value=request_value') + ->assertOk() + ->assertSee('Hello data closure dependencies: request_value foo_class'); + } + + #[Test] + public function it_renders_a_view_using_a_data_closure_with_dependency_injection_from_container() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + app()->bind(FooClass::class, function () { + $foo = new FooClass; + $foo->value = 'foo_modified'; + + return $foo; + }); + + $this->get('/basic-route-with-data-closure-and-dependency-injection?value=request_value') + ->assertOk() + ->assertSee('Hello data closure dependencies: request_value foo_modified'); + } + + #[Test] + public function it_throws_exception_if_you_try_to_pass_data_parameter_when_using_view_closure() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $response = $this + ->get('/you-cannot-use-data-param-with-view-closure') + ->assertInternalServerError(); + + $this->assertEquals('Parameter [$data] not supported with [$view] closure!', $response->exception->getMessage()); + } + #[Test] public function it_renders_a_view_without_data() { @@ -148,14 +304,105 @@ public function it_renders_a_view_with_placeholders() } #[Test] - public function it_renders_a_view_with_placeholders_and_data_from_a_closure() + public function it_renders_a_view_with_placeholders_using_a_view_closure() { $this->viewShouldReturnRaw('layout', '{{ template_content }}'); $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); - $this->get('/route/with/placeholders/closure/one/two/three') + $this->get('/route/with/placeholders/view/closure/one/two/three') ->assertOk() - ->assertSee('Hello one two three'); + ->assertSee('Hello view closure placeholders: one two three'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_view_closure_with_dependency_injection() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/route/with/placeholders/view/closure-dependency-injection/one/two?value=request_value') + ->assertOk() + ->assertSee('Hello view closure dependencies: request_value foo_class bar_class one two'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_view_closure_and_dependency_order_doesnt_matter() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + app()->bind(BarClass::class, function () { + $foo = new BarClass; + $foo->value = 'bar_class_modified'; + + return $foo; + }); + + $this->get('/route/with/placeholders/view/closure-dependency-order-doesnt-matter/one/two?value=request_value') + ->assertOk() + ->assertSee('Hello view closure dependencies: request_value foo_class bar_class_modified one two'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_view_closure_using_primitive_type_hints() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/route/with/placeholders/view/closure-primitive-type-hints/darth/42') + ->assertOk() + ->assertSee('Hello view closure placeholders: darth 42'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_data_closure() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/route/with/placeholders/data/closure/one/two/three') + ->assertOk() + ->assertSee('Hello data closure placeholders: one two three'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_data_closure_with_dependency_injection() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/route/with/placeholders/data/closure-dependency-injection/one/two?value=request_value') + ->assertOk() + ->assertSee('Hello data closure dependencies: request_value foo_class bar_class one two'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_data_closure_and_dependency_order_doesnt_matter() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + app()->bind(BarClass::class, function () { + $foo = new BarClass; + $foo->value = 'bar_class_modified'; + + return $foo; + }); + + $this->get('/route/with/placeholders/data/closure-dependency-order-doesnt-matter/one/two?value=request_value') + ->assertOk() + ->assertSee('Hello data closure dependencies: request_value foo_class bar_class_modified one two'); + } + + #[Test] + public function it_renders_a_view_with_placeholders_using_a_data_closure_using_primitive_type_hints() + { + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); + + $this->get('/route/with/placeholders/data/closure-primitive-type-hints/darth/42') + ->assertOk() + ->assertSee('Hello data closure placeholders: darth 42'); } #[Test] @@ -174,7 +421,6 @@ public function it_renders_a_view_with_custom_layout() #[DataProvider('undefinedLayoutRouteProvider')] public function it_renders_a_view_without_a_layout($route) { - $this->withoutExceptionHandling(); $this->viewShouldReturnRaw('layout', 'The layout {{ template_content }}'); $this->viewShouldReturnRaw('test', 'Hello {{ hello }}'); @@ -222,13 +468,12 @@ public function it_loads_content_by_uri() #[Test] public function it_renders_a_view_with_custom_content_type() { - $this->withoutExceptionHandling(); $this->viewShouldReturnRaw('layout', '{{ template_content }}'); $this->viewShouldReturnRaw('test', '{"hello":"{{ hello }}"}'); $this->get('/route-with-custom-content-type') ->assertOk() - ->assertHeader('Content-Type', 'application/json') + ->assertContentType('application/json') ->assertExactJson(['hello' => 'world']); } @@ -241,7 +486,7 @@ public function xml_antlers_template_with_xml_layout_will_use_both_and_change_th $response = $this ->get('/xml') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -255,7 +500,7 @@ public function xml_antlers_template_with_non_xml_layout_will_change_content_typ $response = $this ->get('/xml') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -269,7 +514,7 @@ public function xml_antlers_layout_will_change_the_content_type() $response = $this ->get('/xml') - ->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + ->assertContentType('text/xml; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -285,7 +530,7 @@ public function xml_blade_template_will_not_change_content_type() $response = $this ->get('/xml') - ->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + ->assertContentType('text/html; charset=utf-8'); $this->assertEquals('', $response->getContent()); } @@ -299,7 +544,7 @@ public function xml_template_with_custom_content_type_does_not_change_to_xml() $this ->get('/xml-with-custom-type') - ->assertHeader('Content-Type', 'application/json'); + ->assertContentType('application/json'); } #[Test] @@ -353,4 +598,25 @@ public function it_uses_a_non_default_layout() ->assertOk() ->assertSee('Custom layout'); } + + #[Test] + public function it_checks_for_closure_instances_instead_of_callables() + { + $this->viewShouldReturnRaw('auth', 'Hello, world.'); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + + $this->get('/callables-test') + ->assertOk() + ->assertSee('Hello, world.'); + } +} + +class FooClass +{ + public $value = 'foo_class'; +} + +class BarClass +{ + public $value = 'bar_class'; } diff --git a/tests/Rules/HandleTest.php b/tests/Rules/HandleTest.php index 1802979704d..f0da57878d6 100644 --- a/tests/Rules/HandleTest.php +++ b/tests/Rules/HandleTest.php @@ -43,4 +43,10 @@ public function it_outputs_helpful_validation_error() { $this->assertValidationErrorOutput(trans('statamic::validation.handle'), '_bad_input'); } + + #[Test] + public function it_outputs_helpful_validation_error_when_string_starts_with_number() + { + $this->assertValidationErrorOutput(trans('statamic::validation.handle_starts_with_number'), '1bad_input'); + } } diff --git a/tests/Search/AlgoliaIndexTest.php b/tests/Search/AlgoliaIndexTest.php index fd994987af1..3603f3c500c 100644 --- a/tests/Search/AlgoliaIndexTest.php +++ b/tests/Search/AlgoliaIndexTest.php @@ -3,25 +3,22 @@ namespace Tests\Search; use Mockery; -use Statamic\Search\Algolia\Index; -use Statamic\Search\ItemResolver; +use Statamic\Search\Algolia\Index as AlgoliaIndex; use Tests\TestCase; class AlgoliaIndexTest extends TestCase { use IndexTests; - public function getIndex() + public function getIndexClass() { - $resolver = Mockery::mock(ItemResolver::class); - $resolver->shouldReceive('setIndex'); - - $client = Mockery::mock(\AlgoliaSearch\Client::class); - $index = Mockery::mock(\AlgoliaSearch\Index::class); + return AlgoliaIndex::class; + } - $client->shouldReceive('initIndex')->andReturn($index); - $index->shouldReceive('search')->andReturn(['hits' => []]); + public function getIndex($name, $config, $locale) + { + $client = Mockery::mock(\Algolia\AlgoliaSearch\SearchClient::class); - return new Index($resolver, $client); + return new AlgoliaIndex($client, $name, $config, $locale); } } diff --git a/tests/Search/CombIndexTest.php b/tests/Search/CombIndexTest.php index 1cb7ec19471..a41f086ac70 100644 --- a/tests/Search/CombIndexTest.php +++ b/tests/Search/CombIndexTest.php @@ -35,8 +35,8 @@ protected function beforeSearched() ->andReturn('[[]]'); } - public function getIndex() + public function getIndexClass() { - return app(Index::class); + return Index::class; } } diff --git a/tests/Search/CombTest.php b/tests/Search/CombTest.php index 433fc87b75b..897dea3fe21 100644 --- a/tests/Search/CombTest.php +++ b/tests/Search/CombTest.php @@ -239,6 +239,48 @@ public function it_can_search_for_slashes() $this->assertSame(1, $result['info']['total_results']); } + #[Test] + public function it_can_search_for_umlauts() + { + $comb = new Comb([ + ['content' => 'Üppercase umlaut'], + ['content' => 'Lowercase ümlaut'], + ]); + + $result = $comb->lookUp('ü'); + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame(2, $result['info']['total_results']); + } + + #[Test] + public function it_filters_out_results_with_disallowed_words() + { + $comb = new Comb([ + ['title' => 'Pizza', 'ingredients' => 'Tomato, Cheese, Bread'], + ['title' => 'Tomato Soup', 'ingredients' => 'Tomato, Water, Salt'], + ['title' => 'Chicken & Sweetcorn Soup', 'ingredients' => 'Chicken, Sweetcorn, Water'], + ]); + + $results = $comb->lookUp('soup -tomato'); + + $this->assertEquals(['Chicken & Sweetcorn Soup'], collect($results['data'] ?? [])->pluck('data.title')->all()); + } + + #[Test] + public function it_filters_out_results_with_disallowed_words_where_results_are_arrays() + { + $comb = new Comb([ + ['title' => 'Pizza', 'ingredients' => ['Tomato', 'Cheese', 'Bread']], + ['title' => 'Tomato Soup', 'ingredients' => ['Tomato', 'Water', 'Salt']], + ['title' => 'Chicken & Sweetcorn Soup', 'ingredients' => ['Chicken', 'Sweetcorn', 'Water']], + ]); + + $results = $comb->lookUp('soup -tomato'); + + $this->assertEquals(['Chicken & Sweetcorn Soup'], collect($results['data'] ?? [])->pluck('data.title')->all()); + } + public static function searchesProvider() { return [ diff --git a/tests/Search/IndexTests.php b/tests/Search/IndexTests.php index 361df5fde3d..3ac1feedec4 100644 --- a/tests/Search/IndexTests.php +++ b/tests/Search/IndexTests.php @@ -2,29 +2,48 @@ namespace Tests\Search; -use Illuminate\Support\Facades\Event; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Statamic\Events\SearchQueryPerformed; +use Statamic\Search\Index; trait IndexTests { - #[Test] - public function search_event_gets_emitted() + public function tearDown(): void { - $this->markTestSkipped(); + // Reset the static state of the Index class + Index::resolveNameUsing(null); - Event::fake(); + parent::tearDown(); + } + + abstract public function getIndexClass(); + + public function getIndex($name, $config, $locale) + { + $class = $this->getIndexClass(); - $this->beforeSearched(); + return new $class($name, $config, $locale); + } + + #[Test, DataProvider('nameProvider')] + public function it_can_get_the_name($name, $config, $locale, $resolver, $expected) + { + if ($resolver) { + $this->getIndexClass()::resolveNameUsing($resolver); + } - $this->getIndex()->setName('test')->search('foo'); + $index = $this->getIndex($name, $config, $locale); - Event::assertDispatched(SearchQueryPerformed::class, function ($event) { - return $event->query === 'foo'; - }); + $this->assertEquals($expected, $index->name()); } - protected function beforeSearched() + public static function nameProvider() { + return [ + 'basic' => ['test', [], null, null, 'test'], + 'with locale' => ['test', [], 'en', null, 'test_en'], + 'resolver' => ['test', [], null, fn ($name, $locale) => 'prefix_'.$name.'_'.$locale, 'prefix_test_'], + 'resolver with locale' => ['test', [], 'en', fn ($name, $locale) => 'prefix_'.$name.'_'.$locale, 'prefix_test_en'], + ]; } } diff --git a/tests/Sites/SiteTest.php b/tests/Sites/SiteTest.php index a420833d49d..9dcdfe64aba 100644 --- a/tests/Sites/SiteTest.php +++ b/tests/Sites/SiteTest.php @@ -59,6 +59,16 @@ public function gets_lang() $this->assertEquals('en-US', (new Site('en', ['locale' => 'en-US', 'lang' => 'en-US']))->lang()); } + #[Test] + public function gets_is_default() + { + $withoutDefault = new Site('en', ['locale' => 'en_US']); + $withDefault = new Site('en', ['locale' => 'en_US'], true); + + $this->assertFalse($withoutDefault->isDefault()); + $this->assertTrue($withDefault->isDefault()); + } + #[Test] public function gets_url_when_given_a_trailing_slash() { diff --git a/tests/Sites/SitesConfigTest.php b/tests/Sites/SitesConfigTest.php index dfa0d4ac134..c10f2cb73b8 100644 --- a/tests/Sites/SitesConfigTest.php +++ b/tests/Sites/SitesConfigTest.php @@ -54,12 +54,14 @@ public function it_gets_sites_from_yaml() $this->assertSame('/', Site::default()->url()); $this->assertSame('en_US', Site::default()->locale()); $this->assertSame('en', Site::default()->lang()); + $this->assertTrue(Site::default()->isDefault()); $this->assertSame('french', Site::get('french')->handle()); $this->assertSame('French', Site::get('french')->name()); $this->assertSame('/fr', Site::get('french')->url()); $this->assertSame('fr_FR', Site::get('french')->locale()); $this->assertSame('fr', Site::get('french')->lang()); + $this->assertFalse(Site::get('french')->isDefault()); } #[Test] @@ -73,12 +75,12 @@ public function it_gets_default_site_without_yaml() Site::swap(new Sites); $this->assertCount(1, Site::all()); - $this->assertSame('default', Site::default()->handle()); $this->assertSame(config('app.name'), Site::default()->name()); $this->assertSame('/', Site::default()->url()); - $this->assertSame('en_US', Site::default()->locale()); - $this->assertSame('en', Site::default()->lang()); + $this->assertSame(config('app.locale'), Site::default()->locale()); + $this->assertSame(config('app.locale'), Site::default()->lang()); + $this->assertTrue(Site::default()->isDefault()); } #[Test] @@ -131,6 +133,7 @@ public function it_resolves_antlers_when_resolving_sites() ]); Config::set('statamic.some_addon.theme', 'sunset'); + Config::set('statamic.system.view_config_allowlist', ['@default', 'app.faker_locale', 'statamic.some_addon.theme']); Site::setSites([ 'default' => [ diff --git a/tests/Sites/SitesTest.php b/tests/Sites/SitesTest.php index 39897b12396..84a93584790 100644 --- a/tests/Sites/SitesTest.php +++ b/tests/Sites/SitesTest.php @@ -3,6 +3,7 @@ namespace Tests\Sites; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Role; use Statamic\Facades\User; @@ -35,6 +36,13 @@ public function setUp(): void ]); } + public function tearDown(): void + { + File::delete(resource_path('users/roles.yaml')); + + parent::tearDown(); + } + #[Test] public function gets_all_sites() { diff --git a/tests/Stache/Repositories/EntryRepositoryTest.php b/tests/Stache/Repositories/EntryRepositoryTest.php index 965933fc687..e8eaaca9e5a 100644 --- a/tests/Stache/Repositories/EntryRepositoryTest.php +++ b/tests/Stache/Repositories/EntryRepositoryTest.php @@ -190,6 +190,27 @@ public function it_gets_entry_by_structure_uri() $this->assertEquals('Directors', $entry->title()); } + #[Test] + #[DataProvider('entriesByIdsProvider')] + public function it_gets_entries_by_ids($ids, $expected) + { + $actual = $this->repo->whereInId($ids); + + $this->assertInstanceOf(EntryCollection::class, $actual); + $this->assertEquals($expected, $actual->map->get('title')->all()); + } + + public static function entriesByIdsProvider() + { + return [ + 'no ids' => [[], []], + 'single' => [['numeric-one'], ['One']], + 'multiple' => [['numeric-one', 'numeric-two', 'numeric-three'], ['One', 'Two', 'Three']], + 'missing' => [['numeric-one', 'unknown', 'numeric-three'], ['One', 'Three']], + 'ordered' => [['numeric-three', 'numeric-one', 'numeric-two'], ['Three', 'One', 'Two']], + ]; + } + #[Test] public function it_saves_an_entry_to_the_stache_and_to_a_file() { diff --git a/tests/Stache/StacheTest.php b/tests/Stache/StacheTest.php index f8be258d5e9..721b51208be 100644 --- a/tests/Stache/StacheTest.php +++ b/tests/Stache/StacheTest.php @@ -6,10 +6,12 @@ use Illuminate\Support\Collection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Stache\NullLockStore; use Statamic\Stache\Stache; use Statamic\Stache\Stores\ChildStore; use Statamic\Stache\Stores\CollectionsStore; use Statamic\Stache\Stores\EntriesStore; +use Symfony\Component\Lock\LockFactory; use Tests\TestCase; class StacheTest extends TestCase @@ -61,6 +63,29 @@ public function stores_can_be_registered() }); } + #[Test] + public function stores_can_be_excluded_from_warming_and_clearing() + { + $this->stache->sites(['en']); // store expects the stache to have site(s) + $this->assertTrue($this->stache->stores()->isEmpty()); + + $mockStore = $this->mock(CollectionsStore::class, function ($mock) { + $mock->shouldReceive('warm')->never(); + $mock->shouldReceive('clear')->never(); + $mock->shouldReceive('key')->andReturn('collections'); + }); + + $this->stache->registerStore($mockStore); + + $return = $this->stache->exclude('collections'); + + $this->assertEquals($this->stache, $return); + + $this->stache->setLockFactory(new LockFactory(new NullLockStore())); + $this->stache->warm(); + $this->stache->clear(); + } + #[Test] public function multiple_stores_can_be_registered_at_once() { diff --git a/tests/Stache/TraverserTest.php b/tests/Stache/TraverserTest.php index db57da10fb0..26d241fb766 100644 --- a/tests/Stache/TraverserTest.php +++ b/tests/Stache/TraverserTest.php @@ -26,7 +26,7 @@ public function setUp(): void $this->tempDir = __DIR__.'/tmp'; mkdir($this->tempDir); - $this->traverser = new Traverser(new Filesystem); + $this->traverser = new Traverser(); } public function tearDown(): void diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 7a536a84eec..6f51a373fad 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -173,7 +173,11 @@ public function it_can_clear_target_export_path_with_clear_option() base_path('two'), ]); - // Imagine this exists from previous export + // Imagine we already have a target a git repo + $this->files->makeDirectory($this->targetPath('.git'), 0777, true, true); + $this->files->put($this->targetPath('.git/config'), 'Config.'); + + // And imagine this exists from previous export $this->files->makeDirectory($this->exportPath('one'), 0777, true, true); $this->files->put($this->exportPath('one/file.md'), 'One.'); @@ -195,10 +199,13 @@ public function it_can_clear_target_export_path_with_clear_option() $this->exportCoolRunnings(['--clear' => true]); - // But 'one' folder should exist after exporting with `--clear` option + // Our 'one' folder shouldn't exist after exporting with `--clear` option $this->assertFileDoesNotExist($this->exportPath('one')); $this->assertFileExists($this->exportPath('two')); + // But it should not clear `.git` directory + $this->assertFileExists($this->targetPath('.git/config')); + $this->exportCoolRunnings(); $this->cleanPaths($paths); diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 6fc18b8aa11..9f4e8a83d98 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -14,6 +14,7 @@ use Statamic\Facades\Blink; use Statamic\Facades\Config; use Statamic\Facades\Path; +use Statamic\Facades\Search; use Statamic\Facades\YAML; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -495,7 +496,8 @@ public function it_clears_site_when_interactively_confirmed() $this ->installCoolRunningsInteractively(['--without-user' => true]) - ->expectsConfirmation('Clear site first?', 'yes'); + ->expectsConfirmation('Clear site first?', 'yes') + ->expectsConfirmation('Would you like to update your search index(es) as well?', 'no'); $this->assertFileExists(base_path('content/collections/pages/home.md')); $this->assertFileDoesNotExist(base_path('content/collections/pages/contact.md')); @@ -1637,6 +1639,63 @@ public function it_installs_nested_modules_confirmed_interactively_via_prompt() $this->assertComposerJsonHasPackageVersion('require', 'bobsled/speed-calculator', '^1.0.0'); } + #[Test] + public function it_doesnt_update_search_index_by_default_when_installed_non_interactively() + { + Search::shouldReceive('indexes')->never(); + + $this + ->installCoolRunnings() + ->assertSuccessful(); + + $this->assertFileExists(base_path('copied.md')); + } + + #[Test] + public function it_updates_search_index_when_update_search_flag_is_passed() + { + Search::shouldReceive('indexes') + ->once() + ->andReturn([]); + + $this + ->installCoolRunnings(['--update-search' => true]) + ->assertSuccessful(); + + $this->assertFileExists(base_path('copied.md')); + } + + #[Test] + public function it_doesnt_update_search_index_by_default_when_installed_interactively() + { + Search::shouldReceive('indexes')->never(); + + $this + ->installCoolRunningsInteractively() + ->expectsConfirmation('Clear site first?', 'no') + ->expectsConfirmation('Would you like to update your search index(es) as well?', 'no') + ->doesntExpectOutput('statamic:search:update') + ->assertSuccessful(); + + $this->assertFileExists(base_path('copied.md')); + } + + #[Test] + public function it_updates_search_index_when_installed_interactively_confirmed() + { + Search::shouldReceive('indexes') + ->once() + ->andReturn([]); + + $this + ->installCoolRunningsInteractively() + ->expectsConfirmation('Clear site first?', 'no') + ->expectsConfirmation('Would you like to update your search index(es) as well?', 'yes') + ->assertSuccessful(); + + $this->assertFileExists(base_path('copied.md')); + } + private function kitRepoPath($path = null) { return Path::tidy(collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/')); @@ -1692,6 +1751,7 @@ private function installCoolRunningsModules($options = [], $customHttpFake = nul return $this->installCoolRunningsInteractively(array_merge($options, [ '--clear-site' => true, // skip clear site prompt '--without-user' => true, // skip create user prompt + '--update-search' => true, // skip update search index prompt ]), $customHttpFake); } diff --git a/tests/StaticCaching/ApplicationCacherTest.php b/tests/StaticCaching/ApplicationCacherTest.php index 3ff3ae5e983..c8b0bfe9f4b 100644 --- a/tests/StaticCaching/ApplicationCacherTest.php +++ b/tests/StaticCaching/ApplicationCacherTest.php @@ -120,6 +120,60 @@ public function invalidating_a_url_will_invalidate_all_query_string_versions_too $this->assertNotNull($cache->get('static-cache:responses:two')); } + #[Test] + public function invalidating_a_url_with_explicit_domain_updates_correct_url_index() + { + $cache = app(Repository::class); + $cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']); + + // Different domain than base_url + $domainHash = md5('http://differentexample.com'); + $cache->forever("static-cache:{$domainHash}.urls", [ + 'one' => '/one', + 'two' => '/two', + ]); + $cache->forever('static-cache:responses:one', 'html content'); + $cache->forever('static-cache:responses:two', 'two html content'); + + // Invalidate explicit domain + $cacher->invalidateUrl('/one', 'http://differentexample.com'); + + // URL index under http://differentexample.com should be updated and not http://example.com + $this->assertEquals( + ['two' => '/two'], + $cacher->getUrls('http://differentexample.com')->all() + ); + $this->assertNull($cache->get('static-cache:responses:one')); + $this->assertNotNull($cache->get('static-cache:responses:two')); + } + + #[Test] + public function invalidating_a_full_url_without_domain_extracts_domain_correctly() + { + $cache = app(Repository::class); + $cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']); + + // Different domain than base_url + $domainHash = md5('http://differentexample.com'); + $cache->forever("static-cache:{$domainHash}.urls", [ + 'one' => '/one', + 'two' => '/two', + ]); + $cache->forever('static-cache:responses:one', 'html content'); + $cache->forever('static-cache:responses:two', 'two html content'); + + // Invalidate full URL with no explicit domain to simulate CLI + $cacher->invalidateUrl('http://differentexample.com/one'); + + // Should extract domain from URL and update correct URL index + $this->assertEquals( + ['two' => '/two'], + $cacher->getUrls('http://differentexample.com')->all() + ); + $this->assertNull($cache->get('static-cache:responses:one')); + $this->assertNotNull($cache->get('static-cache:responses:two')); + } + #[Test] #[DataProvider('invalidateEventProvider')] public function invalidating_a_url_dispatches_event($domain, $expectedUrl) diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index 79571d41b97..2af12facf78 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -13,16 +13,18 @@ use Statamic\Contracts\Structures\Nav; use Statamic\Contracts\Taxonomies\Taxonomy; use Statamic\Contracts\Taxonomies\Term; +use Statamic\Facades\Site; +use Statamic\Globals\Variables; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\DefaultInvalidator as Invalidator; +use Statamic\Structures\CollectionTree; +use Statamic\Structures\NavTree; +use Statamic\Structures\Structure; +use Statamic\Taxonomies\LocalizedTerm; +use Tests\TestCase; -class DefaultInvalidatorTest extends \PHPUnit\Framework\TestCase +class DefaultInvalidatorTest extends TestCase { - public function tearDown(): void - { - Mockery::close(); - } - #[Test] public function specifying_all_as_invalidation_rule_will_just_flush_the_cache() { @@ -38,7 +40,11 @@ public function specifying_all_as_invalidation_rule_will_just_flush_the_cache() public function assets_can_trigger_url_invalidation() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrls')->once()->with(['/page/one', '/page/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/page/three', + 'http://localhost/page/one', + 'http://localhost/page/two', + ])->once(); }); $container = tap(Mockery::mock(AssetContainer::class), function ($m) { @@ -55,6 +61,48 @@ public function assets_can_trigger_url_invalidation() 'urls' => [ '/page/one', '/page/two', + 'http://localhost/page/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($asset)); + } + + #[Test] + public function assets_can_trigger_url_invalidation_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/page/three', + 'http://test.com/page/one', + 'http://test.com/page/two', + 'http://test.fr/page/one', + 'http://test.fr/page/two', + ])->once(); + }); + + $container = tap(Mockery::mock(AssetContainer::class), function ($m) { + $m->shouldReceive('handle')->andReturn('main'); + }); + + $asset = tap(Mockery::mock(Asset::class), function ($m) use ($container) { + $m->shouldReceive('container')->andReturn($container); + }); + + $invalidator = new Invalidator($cacher, [ + 'assets' => [ + 'main' => [ + 'urls' => [ + '/page/one', + '/page/two', + 'http://test.com/page/three', ], ], ], @@ -67,13 +115,63 @@ public function assets_can_trigger_url_invalidation() public function collection_urls_can_be_invalidated() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrl')->with('/my/test/collection', 'http://test.com')->once(); - $cacher->shouldReceive('invalidateUrls')->once()->with(['/blog/one', '/blog/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/my/test/collection', + 'http://localhost/blog/three', + 'http://localhost/blog/one', + 'http://localhost/blog/two', + ])->once(); + }); + + $collection = tap(Mockery::mock(Collection::class), function ($m) { + $m->shouldReceive('handle')->andReturn('blog'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + $m->shouldReceive('absoluteUrl')->with('en')->andReturn('http://localhost/my/test/collection'); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one', + '/blog/two', + 'http://localhost/blog/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($collection)); + } + + #[Test] + public function collection_urls_can_be_invalidated_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + 'de' => ['url' => 'http://test.de', 'locale' => 'de_DE'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/my/test/collection', + 'http://test.fr/my/test/collection', + 'http://test.com/blog/three', + 'http://test.com/blog/one', + 'http://test.com/blog/two', + 'http://test.fr/blog/one', + 'http://test.fr/blog/two', + ])->once(); }); $collection = tap(Mockery::mock(Collection::class), function ($m) { - $m->shouldReceive('absoluteUrl')->andReturn('http://test.com/my/test/collection'); $m->shouldReceive('handle')->andReturn('blog'); + $m->shouldReceive('sites')->andReturn(collect(['en', 'fr'])); + + $m->shouldReceive('absoluteUrl')->with('en')->andReturn('http://test.com/my/test/collection')->once(); + $m->shouldReceive('absoluteUrl')->with('fr')->andReturn('http://test.fr/my/test/collection')->once(); + $m->shouldReceive('absoluteUrl')->with('de')->never(); }); $invalidator = new Invalidator($cacher, [ @@ -82,6 +180,7 @@ public function collection_urls_can_be_invalidated() 'urls' => [ '/blog/one', '/blog/two', + 'http://test.com/blog/three', ], ], ], @@ -90,12 +189,103 @@ public function collection_urls_can_be_invalidated() $this->assertNull($invalidator->invalidate($collection)); } + #[Test] + public function collection_urls_can_be_invalidated_by_a_tree() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/three', + 'http://localhost/blog/one', + 'http://localhost/blog/two', + ])->once(); + }); + + $collection = tap(Mockery::mock(Collection::class), function ($m) { + $m->shouldReceive('handle')->andReturn('blog'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + }); + + $structure = tap(Mockery::mock(Structure::class), function ($m) use ($collection) { + $m->shouldReceive('collection')->andReturn($collection); + }); + + $tree = tap(Mockery::mock(CollectionTree::class), function ($m) use ($collection, $structure) { + $m->shouldReceive('structure')->andReturn($structure); + $m->shouldReceive('collection')->andReturn($collection); + $m->shouldReceive('site')->andReturn(Site::default()); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one', + '/blog/two', + 'http://localhost/blog/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($tree)); + } + + #[Test] + public function collection_urls_can_be_invalidated_by_a_tree_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/three', + 'http://test.fr/blog/one', + 'http://test.fr/blog/two', + ])->once(); + }); + + $collection = tap(Mockery::mock(Collection::class), function ($m) { + $m->shouldReceive('handle')->andReturn('blog'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + }); + + $structure = tap(Mockery::mock(Structure::class), function ($m) use ($collection) { + $m->shouldReceive('collection')->andReturn($collection); + }); + + $tree = tap(Mockery::mock(CollectionTree::class), function ($m) use ($collection, $structure) { + $m->shouldReceive('structure')->andReturn($structure); + $m->shouldReceive('collection')->andReturn($collection); + $m->shouldReceive('site')->andReturn(Site::get('fr')); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one', + '/blog/two', + 'http://localhost/blog/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($tree)); + } + #[Test] public function collection_urls_can_be_invalidated_by_an_entry() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrl')->with('/my/test/entry', 'http://test.com')->once(); - $cacher->shouldReceive('invalidateUrls')->once()->with(['/blog/one', '/blog/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/my/test/entry', + 'http://localhost/blog/three', + 'http://localhost/blog/one', + 'http://localhost/blog/two', + ])->once(); }); $entry = tap(Mockery::mock(Entry::class), function ($m) { @@ -103,6 +293,47 @@ public function collection_urls_can_be_invalidated_by_an_entry() $m->shouldReceive('absoluteUrl')->andReturn('http://test.com/my/test/entry'); $m->shouldReceive('collectionHandle')->andReturn('blog'); $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one', + '/blog/two', + 'http://localhost/blog/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($entry)); + } + + #[Test] + public function collection_urls_can_be_invalidated_by_an_entry_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.fr/my/test/entry', + 'http://test.com/blog/three', + 'http://test.fr/blog/one', + 'http://test.fr/blog/two', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://test.fr/my/test/entry'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::get('fr')); }); $invalidator = new Invalidator($cacher, [ @@ -111,6 +342,7 @@ public function collection_urls_can_be_invalidated_by_an_entry() 'urls' => [ '/blog/one', '/blog/two', + 'http://test.com/blog/three', ], ], ], @@ -123,8 +355,10 @@ public function collection_urls_can_be_invalidated_by_an_entry() public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrl')->never(); - $cacher->shouldReceive('invalidateUrls')->once()->with(['/blog/one', '/blog/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/one', + 'http://localhost/blog/two', + ])->once(); }); $entry = tap(Mockery::mock(Entry::class), function ($m) { @@ -132,6 +366,7 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() $m->shouldReceive('absoluteUrl')->andReturn('http://test.com/my/test/entry'); $m->shouldReceive('collectionHandle')->andReturn('blog'); $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); }); $invalidator = new Invalidator($cacher, [ @@ -152,9 +387,63 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() public function taxonomy_urls_can_be_invalidated() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrl')->with('/my/test/term', 'http://test.com')->once(); - $cacher->shouldReceive('invalidateUrl')->with('/my/collection/tags/term', 'http://test.com')->once(); - $cacher->shouldReceive('invalidateUrls')->once()->with(['/tags/one', '/tags/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/my/test/term', + 'http://localhost/my/collection/tags/term', + 'http://localhost/tags/three', + 'http://localhost/tags/one', + 'http://localhost/tags/two', + ])->once(); + }); + + $collection = Mockery::mock(Collection::class); + + $taxonomy = tap(Mockery::mock(Taxonomy::class), function ($m) use ($collection) { + $m->shouldReceive('collections')->andReturn(collect([$collection])); + }); + + $term = Mockery::mock(Term::class); + + $localized = tap(Mockery::mock(LocalizedTerm::class), function ($m) use ($term, $taxonomy) { + $m->shouldReceive('term')->andReturn($term); + $m->shouldReceive('taxonomyHandle')->andReturn('tags'); + $m->shouldReceive('taxonomy')->andReturn($taxonomy); + $m->shouldReceive('collection')->andReturn($m); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/my/test/term', 'http://localhost/my/collection/tags/term'); + }); + + $invalidator = new Invalidator($cacher, [ + 'taxonomies' => [ + 'tags' => [ + 'urls' => [ + '/tags/one', + '/tags/two', + 'http://localhost/tags/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($localized)); + } + + #[Test] + public function taxonomy_urls_can_be_invalidated_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.fr/my/test/term', + 'http://test.fr/my/collection/tags/term', + 'http://test.com/tags/three', + 'http://test.fr/tags/one', + 'http://test.fr/tags/two', + ])->once(); }); $collection = Mockery::mock(Collection::class); @@ -163,11 +452,15 @@ public function taxonomy_urls_can_be_invalidated() $m->shouldReceive('collections')->andReturn(collect([$collection])); }); - $term = tap(Mockery::mock(Term::class), function ($m) use ($taxonomy) { - $m->shouldReceive('absoluteUrl')->andReturn('http://test.com/my/test/term', 'http://test.com/my/collection/tags/term'); + $term = Mockery::mock(Term::class); + + $localized = tap(Mockery::mock(LocalizedTerm::class), function ($m) use ($term, $taxonomy) { + $m->shouldReceive('term')->andReturn($term); $m->shouldReceive('taxonomyHandle')->andReturn('tags'); $m->shouldReceive('taxonomy')->andReturn($taxonomy); $m->shouldReceive('collection')->andReturn($m); + $m->shouldReceive('site')->andReturn(Site::get('fr')); + $m->shouldReceive('absoluteUrl')->andReturn('http://test.fr/my/test/term', 'http://test.fr/my/collection/tags/term'); }); $invalidator = new Invalidator($cacher, [ @@ -176,23 +469,68 @@ public function taxonomy_urls_can_be_invalidated() 'urls' => [ '/tags/one', '/tags/two', + 'http://test.com/tags/three', ], ], ], ]); - $this->assertNull($invalidator->invalidate($term)); + $this->assertNull($invalidator->invalidate($localized)); } #[Test] public function navigation_urls_can_be_invalidated() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrls')->once()->with(['/one', '/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/three', + 'http://localhost/one', + 'http://localhost/two', + ])->once(); + }); + + $nav = tap(Mockery::mock(Nav::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => [ + 'links' => [ + 'urls' => [ + '/one', + '/two', + 'http://localhost/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($nav)); + } + + #[Test] + public function navigation_urls_can_be_invalidated_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + 'de' => ['url' => 'http://test.de', 'locale' => 'de_DE'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/three', + 'http://test.com/one', + 'http://test.com/two', + 'http://test.fr/one', + 'http://test.fr/two', + ])->once(); }); $nav = tap(Mockery::mock(Nav::class), function ($m) { $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('sites')->andReturn(collect(['en', 'fr'])); }); $invalidator = new Invalidator($cacher, [ @@ -201,6 +539,7 @@ public function navigation_urls_can_be_invalidated() 'urls' => [ '/one', '/two', + 'http://test.com/three', ], ], ], @@ -209,36 +548,194 @@ public function navigation_urls_can_be_invalidated() $this->assertNull($invalidator->invalidate($nav)); } + #[Test] + public function navigation_urls_can_be_invalidated_by_a_tree() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/three', + 'http://localhost/one', + 'http://localhost/two', + ])->once(); + }); + + $tree = tap(Mockery::mock(NavTree::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('site')->andReturn(Site::default()); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => [ + 'links' => [ + 'urls' => [ + '/one', + '/two', + 'http://localhost/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($tree)); + } + + #[Test] + public function navigation_urls_can_be_invalidated_by_a_tree_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/three', + 'http://test.fr/one', + 'http://test.fr/two', + ])->once(); + }); + + $tree = tap(Mockery::mock(NavTree::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('site')->andReturn(Site::get('fr')); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => [ + 'links' => [ + 'urls' => [ + '/one', + '/two', + 'http://test.com/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($tree)); + } + #[Test] public function globals_urls_can_be_invalidated() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrls')->once()->with(['/one', '/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/three', + 'http://localhost/one', + 'http://localhost/two', + ])->once(); }); $set = tap(Mockery::mock(GlobalSet::class), function ($m) { $m->shouldReceive('handle')->andReturn('social'); }); + $variables = tap(Mockery::mock(Variables::class), function ($m) use ($set) { + $m->shouldReceive('globalSet')->andReturn($set); + $m->shouldReceive('site')->andReturn(Site::default()); + }); + $invalidator = new Invalidator($cacher, [ 'globals' => [ 'social' => [ 'urls' => [ '/one', '/two', + 'http://localhost/three', ], ], ], ]); - $this->assertNull($invalidator->invalidate($set)); + $this->assertNull($invalidator->invalidate($variables)); + } + + #[Test] + public function globals_urls_can_be_invalidated_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/three', + 'http://test.fr/one', + 'http://test.fr/two', + ])->once(); + }); + + $set = tap(Mockery::mock(GlobalSet::class), function ($m) { + $m->shouldReceive('handle')->andReturn('social'); + }); + + $variables = tap(Mockery::mock(Variables::class), function ($m) use ($set) { + $m->shouldReceive('globalSet')->andReturn($set); + $m->shouldReceive('site')->andReturn(Site::get('fr')); + }); + + $invalidator = new Invalidator($cacher, [ + 'globals' => [ + 'social' => [ + 'urls' => [ + '/one', + '/two', + 'http://test.com/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($variables)); } #[Test] public function form_urls_can_be_invalidated() { $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { - $cacher->shouldReceive('invalidateUrls')->once()->with(['/one', '/two']); + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/three', + 'http://localhost/one', + 'http://localhost/two', + ])->once(); + }); + + $form = tap(Mockery::mock(Form::class), function ($m) { + $m->shouldReceive('handle')->andReturn('newsletter'); + }); + + $invalidator = new Invalidator($cacher, [ + 'forms' => [ + 'newsletter' => [ + 'urls' => [ + '/one', + '/two', + 'http://localhost/three', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($form)); + } + + #[Test] + public function form_urls_can_be_invalidated_in_a_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://test.com', 'locale' => 'en_US'], + 'fr' => ['url' => 'http://test.fr', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/three', + 'http://test.com/one', + 'http://test.com/two', + 'http://test.fr/one', + 'http://test.fr/two', + ])->once(); }); $form = tap(Mockery::mock(Form::class), function ($m) { @@ -251,6 +748,7 @@ public function form_urls_can_be_invalidated() 'urls' => [ '/one', '/two', + 'http://test.com/three', ], ], ], diff --git a/tests/StaticCaching/FileCacherTest.php b/tests/StaticCaching/FileCacherTest.php index bc522b5d5ae..fda08112c63 100644 --- a/tests/StaticCaching/FileCacherTest.php +++ b/tests/StaticCaching/FileCacherTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Events\UrlInvalidated; +use Statamic\Facades\File; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\Cachers\Writer; @@ -297,6 +298,32 @@ public function invalidating_a_url_deletes_the_file_and_removes_the_url_when_usi $this->assertEquals(['one' => '/one'], $cacher->getUrls('http://domain.de')->all()); } + #[Test] + public function invalidating_a_url_deletes_the_file_even_if_it_is_not_in_application_cache() + { + $writer = \Mockery::spy(Writer::class); + $cache = app(Repository::class); + $cacher = $this->fileCacher([ + 'path' => public_path('static'), + ], $writer, $cache); + + File::put($cacher->getFilePath('/one'), ''); + File::put($cacher->getFilePath('/one?foo=bar'), ''); + File::put($cacher->getFilePath('/onemore'), ''); + File::put($cacher->getFilePath('/two'), ''); + + $cacher->invalidateUrl('/one', 'http://example.com'); + + File::delete($cacher->getFilePath('/one')); + File::delete($cacher->getFilePath('/one?foo=bar')); + File::delete($cacher->getFilePath('/onemore')); + File::delete($cacher->getFilePath('/two')); + + $writer->shouldHaveReceived('delete')->times(2); + $writer->shouldHaveReceived('delete')->with($cacher->getFilePath('/one'))->once(); + $writer->shouldHaveReceived('delete')->with($cacher->getFilePath('/one?foo=bar'))->once(); + } + #[Test] #[DataProvider('invalidateEventProvider')] public function invalidating_a_url_dispatches_event($domain, $expectedUrl) diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index ed31ae1425d..bf9ea6f432a 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -74,15 +74,19 @@ public function index() $region = app(Session::class)->regions()->first(); - // Initial response should be dynamic and not contain javascript. - $this->assertEquals('1 2', $response->getContent()); + // Initial response should have the placeholder and javascript, NOT the rendered content. + $this->assertEquals(vsprintf('1 %s%s', [ + $region->key(), + 'Loading...', + '', + ]), $response->getContent()); - // The cached response should have the nocache placeholder, and the javascript. + // The cached response should be the same as the initial response. $this->assertTrue(file_exists($this->dir.'/about_.html')); $this->assertEquals(vsprintf('1 %s%s', [ $region->key(), 'Loading...', - '', + '', ]), file_get_contents($this->dir.'/about_.html')); } @@ -148,13 +152,79 @@ public function it_should_add_the_javascript_if_there_is_a_csrf_token() ->get('/about') ->assertOk(); - // Initial response should be dynamic and not contain javascript. - $this->assertEquals(''.csrf_token().'', $response->getContent()); + // Initial response should have the placeholder and the javascript, NOT the real token. + $this->assertEquals('STATAMIC_CSRF_TOKEN', $response->getContent()); - // The cached response should have the token placeholder, and the javascript. + // The cached response should be the same as the initial response. $this->assertTrue(file_exists($this->dir.'/about_.html')); - $this->assertEquals(vsprintf('STATAMIC_CSRF_TOKEN%s', [ - '', - ]), file_get_contents($this->dir.'/about_.html')); + $this->assertEquals('STATAMIC_CSRF_TOKEN', file_get_contents($this->dir.'/about_.html')); + } + + #[Test] + public function excluded_pages_should_have_real_csrf_token() + { + config(['statamic.static_caching.exclude' => [ + 'urls' => ['/about'], + ]]); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ csrf_token }}'); + + $this->createPage('about'); + + $response = $this + ->get('/about') + ->assertOk(); + + // The response should have the real CSRF token, not the placeholder. + $this->assertEquals(''.csrf_token().'', $response->getContent()); + + // The page should not be cached. + $this->assertFalse(file_exists($this->dir.'/about_.html')); + } + + #[Test] + public function excluded_pages_should_have_nocache_regions_replaced() + { + config(['statamic.static_caching.exclude' => [ + 'urls' => ['/about'], + ]]); + + app()->instance('example_count', 0); + + (new class extends \Statamic\Tags\Tags + { + public static $handle = 'example_count'; + + public function index() + { + $count = app('example_count'); + $count++; + app()->instance('example_count', $count); + + return $count; + } + })::register(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ example_count }} {{ nocache }}{{ example_count }}{{ /nocache }}'); + + $this->createPage('about'); + + StaticCache::nocacheJs('js here'); + StaticCache::nocachePlaceholder('Loading...'); + + $response = $this + ->get('/about') + ->assertOk(); + + // The response should have the nocache regions replaced with rendered content, no placeholders or JS. + $this->assertEquals('1 2', $response->getContent()); + $this->assertStringNotContainsString(' -HTML; + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.js" />', $output); - $this->assertEqualsIgnoringLineBreaks($expected, $output); + $this->assertStringContainsString('', $output); } #[Test] public function it_outputs_stylesheet() { - $output = $this->tag('{{ vite src="test.css" }}'); - $expected = <<<'HTML' - - -HTML; + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); - $this->assertEqualsIgnoringLineBreaks($expected, $output); + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); } #[Test] @@ -54,14 +51,17 @@ public function it_outputs_multiple_entry_points() { $output = $this->tag('{{ vite src="test.js|test.css" }}'); - $expected = <<<'HTML' - - - - -HTML; + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); + + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.js" />', $output); - $this->assertEqualsIgnoringLineBreaks($expected, $output); + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); + + $this->assertStringContainsString('', $output); } #[Test] @@ -69,14 +69,17 @@ public function it_includes_attributes() { $output = $this->tag('{{ vite src="test.js|test.css" alfa="bravo" attr:charlie="delta" }}'); - $expected = <<<'HTML' - - - - -HTML; + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); + + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.js" />', $output); - $this->assertEqualsIgnoringLineBreaks($expected, $output); + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" charlie="delta" />', $output); + + $this->assertStringContainsString('', $output); } #[Test] @@ -84,14 +87,17 @@ public function it_includes_tag_specific_attributes() { $output = $this->tag('{{ vite src="test.js|test.css" alfa="bravo" attr:charlie="delta" attr:script:echo="foxtrot" attr:style:golf="hotel" }}'); - $expected = <<<'HTML' - - - - -HTML; + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" />', $output); + + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.js" />', $output); + + $this->assertStringContainsString('assertStringContainsString('href="http://localhost/build/assets/test-123.css" charlie="delta" golf="hotel" />', $output); - $this->assertEqualsIgnoringLineBreaks($expected, $output); + $this->assertStringContainsString('', $output); } // Ignore line breaks just for the sake of readability in the test. diff --git a/tests/TestCase.php b/tests/TestCase.php index b2367916238..c4b133de83f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,9 +6,7 @@ use Illuminate\Testing\TestResponse; use PHPUnit\Framework\Assert; use Statamic\Facades\Config; -use Statamic\Facades\File; use Statamic\Facades\Site; -use Statamic\Facades\YAML; use Statamic\Http\Middleware\CP\AuthenticateSession; abstract class TestCase extends \Orchestra\Testbench\TestCase @@ -40,19 +38,11 @@ protected function setUp(): void if ($this->shouldPreventNavBeingBuilt) { \Statamic\Facades\CP\Nav::shouldReceive('build')->zeroOrMoreTimes()->andReturn(collect()); - $this->addToAssertionCount(-1); // Dont want to assert this + \Statamic\Facades\CP\Nav::shouldReceive('clearCachedUrls')->zeroOrMoreTimes(); + $this->addToAssertionCount(-2); // Dont want to assert this } $this->addGqlMacros(); - - // We changed the default sites setup but the tests assume defaults like the following. - File::put(resource_path('sites.yaml'), YAML::dump([ - 'en' => [ - 'name' => 'English', - 'url' => 'http://localhost/', - 'locale' => 'en_US', - ], - ])); } public function tearDown(): void @@ -133,6 +123,15 @@ protected function getEnvironmentSetUp($app) $viewPaths[] = __DIR__.'/__fixtures__/views/'; $app['config']->set('view.paths', $viewPaths); + + // We changed the default sites setup but the tests assume defaults like the following. + // We write the file early so its ready the first time Site facade is used. + $app['files']->put(resource_path('sites.yaml'), <<<'YAML' +en: + name: English + url: http://localhost/ + locale: en_US +YAML); } protected function setSites($sites) @@ -240,6 +239,29 @@ private function addGqlMacros() return $this; }); + + // Symfony 7.4.0 changed "UTF-8" to "utf-8". + // https://github.com/symfony/symfony/pull/60685 + // While we continue to support lower versions, we'll do a case-insensitive check. + // This macro is essentially assertHeader but with case-insensitive value check. + TestResponse::macro('assertContentType', function (string $value) { + $headerName = 'Content-Type'; + + Assert::assertTrue( + $this->headers->has($headerName), "Header [{$headerName}] not present on response." + ); + + $actual = $this->headers->get($headerName); + + if (! is_null($value)) { + Assert::assertEquals( + strtolower($value), strtolower($this->headers->get($headerName)), + "Header [{$headerName}] was found, but value [{$actual}] does not match [{$value}]." + ); + } + + return $this; + }); } public function __call($name, $arguments) diff --git a/tests/View/Blade/AntlersComponents/NavCompilerTest.php b/tests/View/Blade/AntlersComponents/NavCompilerTest.php index 7ad021026ac..853ca6631eb 100644 --- a/tests/View/Blade/AntlersComponents/NavCompilerTest.php +++ b/tests/View/Blade/AntlersComponents/NavCompilerTest.php @@ -232,4 +232,63 @@ public function it_renders_aliased_recursive_children() Blade::render($template) ); } + + #[Test] + public function it_supports_imported_classes_and_functions() + { + $template = <<<'BLADE' +@use (Statamic\Support\Html) + +
      + +@foreach ($the_items as $item) +
    • {{ Html::entities($item['title']) }}
    • +@endforeach +
      +
    +BLADE; + + $expected = <<<'EXPECTED' +
      +
    • Home
    • +
    • About
    • +
    • Projects
    • +
    • Contact
    • +
    +EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template), + ); + } + + #[Test] + public function it_doesnt_mangle_php_inside_nav_tag() + { + $template = <<<'BLADE' +
      + +@foreach ($the_items as $item) +@php $theValue = $item['title'].'-value'; @endphp +
    • {{ $theValue }}
    • +@endforeach +
      +
    +BLADE; + + $expected = <<<'EXPECTED' +
      +
    • Home-value
    • +
    • About-value
    • +
    • Projects-value
    • +
    • Contact-value
    • +
    +EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template), + ); + } } diff --git a/tests/View/CascadeTest.php b/tests/View/CascadeTest.php index 2b0c3e35b40..e305ab38235 100644 --- a/tests/View/CascadeTest.php +++ b/tests/View/CascadeTest.php @@ -84,13 +84,60 @@ public function it_hydrates_constants() $this->assertEquals('', $cascade['xml_header']); $this->assertEquals(csrf_token(), $cascade['csrf_token']); $this->assertEquals(csrf_field(), $cascade['csrf_field']); - $this->assertEquals(config()->all(), $cascade['config']); + $this->assertEquals(Cascade::config(), $cascade['config']); // Response code is constant. It gets manually overridden on errors. $this->assertEquals(200, $cascade['response_code']); }); } + #[Test] + public function it_only_hydrates_allowlisted_config_values() + { + config([ + 'app.foo' => 'bar', + 'statamic.system.view_config_allowlist' => ['app.name'], + ]); + + tap($this->cascade()->hydrate()->toArray(), function ($cascade) { + $this->assertTrue(Arr::has($cascade['config'], 'app.name')); + $this->assertFalse(Arr::has($cascade['config'], 'app.foo')); + }); + } + + #[Test] + public function overriding_the_allowlist_changes_the_config_subset() + { + config(['statamic.system.view_config_allowlist' => ['app.name']]); + + $nameOnly = Cascade::config(); + + config(['statamic.system.view_config_allowlist' => ['app.env']]); + + $envOnly = Cascade::config(); + + $this->assertTrue(Arr::has($nameOnly, 'app.name')); + $this->assertFalse(Arr::has($nameOnly, 'app.env')); + $this->assertTrue(Arr::has($envOnly, 'app.env')); + $this->assertFalse(Arr::has($envOnly, 'app.name')); + } + + #[Test] + public function default_allowlist_can_be_extended_with_default_spread_syntax() + { + config([ + 'app.foo' => 'bar', + 'statamic.system.license_key' => 'test-license-key', + 'statamic.system.view_config_allowlist' => ['@default', 'app.foo'], + ]); + + $config = Cascade::config(); + + $this->assertTrue(Arr::has($config, 'app.name')); + $this->assertTrue(Arr::has($config, 'app.foo')); + $this->assertFalse(Arr::has($config, 'statamic.system.license_key')); + } + #[Test] public function it_hydrates_auth_when_logged_in() { @@ -410,6 +457,32 @@ public function it_hydrates_page_data() }); } + #[Test] + public function it_hydrates_page_data_by_closure() + { + $vars = ['foo' => 'bar', 'baz' => 'qux']; + $page = EntryFactory::id('test') + ->collection('example') + ->data($vars) + ->make(); + $cascade = $this->cascade()->withContent(fn () => $page); + + $this->assertEquals($page, call_user_func($cascade->content())); + + tap($cascade->hydrate()->toArray(), function ($cascade) use ($page) { + $this->assertArrayHasKey('page', $cascade); + $this->assertEquals($page, $cascade['page']); + + // The 'page' values should also be at the top level. + // They'll be Value classes so Antlers can lazily augment them. + // Blade can prefer {{ $globalhandle->field }} over just {{ $field }} + $this->assertEquals('bar', $cascade['foo']); + $this->assertEquals('qux', $cascade['baz']); + $this->assertInstanceOf(Value::class, $cascade['foo']); + $this->assertInstanceOf(Value::class, $cascade['baz']); + }); + } + #[Test] public function it_hydrates_globals() { diff --git a/translator b/translator index f53294f5bd0..59976b0941f 100644 --- a/translator +++ b/translator @@ -44,9 +44,8 @@ $additionalStrings = [ 'Mobile', 'Hello!', 'Whoops!', - 'Regards', + 'Regards,', "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:", - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:", // laravel <8.48.0 'All rights reserved.', 'The given data was invalid.', 'Protected Page',