@iqual/nuxt-icms 1.6.1 → 1.6.2-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/__tests__/anchors.spec.ts +43 -0
- package/__tests__/contract/blokkliComponents.spec.ts +59 -0
- package/__tests__/contract/componentFragments.spec.ts +24 -0
- package/__tests__/contract/languages.spec.ts +63 -0
- package/__tests__/contract/project.ts +144 -0
- package/__tests__/hreflang.spec.ts +39 -0
- package/{tests → __tests__}/pages.spec.ts +14 -5
- package/app.config.ts +15 -0
- package/app.vue +3 -21
- package/assets/css/backend.css +56 -0
- package/assets/css/base/base.css +2 -2
- package/assets/css/base/typography.css +2 -2
- package/assets/css/ckeditor.css +3 -4
- package/assets/css/components/decorations.css +1 -1
- package/assets/css/components/editor.css +1 -1
- package/assets/css/components/grid-container.css +19 -19
- package/assets/css/fe-fixes.css +6 -12
- package/assets/css/theme/colors/color-primary.css +33 -11
- package/assets/css/theme/colors/color-secondary.css +33 -11
- package/assets/css/utilities/utilities.css +1 -1
- package/assets/symbols/anchor.svg +3 -0
- package/blokkli.editAdapter.ts +56 -0
- package/bundles.ts +6 -6
- package/cache/__tests__/pageCache.spec.ts +540 -0
- package/cache/cacheTagRegistry.ts +124 -0
- package/cache/enabledForRequest.ts +21 -0
- package/cache/multiCache.serverOptions.ts +58 -0
- package/cache/pageCache.ts +360 -0
- package/components/Blokkli/Anchor/icon.svg +3 -0
- package/components/Blokkli/Anchor/index.vue +13 -0
- package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
- package/components/Blokkli/AnchorNavigation/index.vue +9 -0
- package/components/Blokkli/Card/Element/index.vue +0 -1
- package/components/Blokkli/Collapsible/Element/index.vue +5 -0
- package/components/Blokkli/FAQ/Element/index.vue +0 -1
- package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
- package/components/Blokkli/Fragment/Separator/index.vue +87 -0
- package/components/Blokkli/Gallery/index.vue +1 -3
- package/components/Blokkli/IconText/Element/index.vue +0 -1
- package/components/Blokkli/Introduction/index.vue +0 -1
- package/components/Blokkli/Listing/icon.svg +9 -0
- package/components/Blokkli/Listing/index.vue +24 -0
- package/components/Blokkli/Logo/Element/index.vue +0 -1
- package/components/Blokkli/Table/index.vue +0 -1
- package/components/Blokkli/Team/Element/index.vue +0 -1
- package/components/Blokkli/Team/Layout/index.vue +0 -1
- package/components/Blokkli/Testimonial/Element/index.vue +0 -1
- package/components/Blokkli/Text/index.vue +0 -1
- package/components/DesignSystem/Atoms/Button.vue +3 -0
- package/components/DesignSystem/Atoms/GridRow.vue +3 -1
- package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
- package/components/DesignSystem/Molecules/IconText.vue +6 -6
- package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
- package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
- package/components/DesignSystem/Molecules/Partnership.vue +7 -4
- package/components/DesignSystem/Molecules/Table.vue +35 -19
- package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
- package/components/DesignSystem/Molecules/iFrame.vue +2 -2
- package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
- package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
- package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
- package/components/DesignSystem/Organisms/FactCard.vue +6 -7
- package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
- package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
- package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
- package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
- package/components/DesignSystem/Organisms/LinkList.vue +20 -12
- package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
- package/components/DesignSystem/Organisms/Map.vue +1 -1
- package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
- package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
- package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
- package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
- package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
- package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
- package/components/DesignSystem/Organisms/TextCard.vue +10 -10
- package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
- package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
- package/components/DesignSystem/README.md +59 -40
- package/components/Drupal/Breadcrumb/index.vue +25 -12
- package/components/Drupal/Messages/index.vue +9 -4
- package/components/Drupal/Webform/index.vue +271 -51
- package/components/Drupal/Webform/mutation.graphql +17 -1
- package/components/Form/HiddenInput.vue +1 -4
- package/components/Form/SelectInput.vue +1 -1
- package/components/Form/SubmitButton.vue +2 -2
- package/components/Form/TextAreaInput.vue +3 -3
- package/components/Form/TextEmailInput.vue +1 -1
- package/components/Form/TextInput.vue +1 -1
- package/components/Form/TextPhoneInput.vue +1 -1
- package/components/Form/WebformMarkup.vue +1 -1
- package/components/Form/composables/useFormDefaultValue.ts +9 -13
- package/components/Form/composables/useFormElement.ts +5 -3
- package/components/Form/utils/types.ts +3 -3
- package/components/Media/Image/fragment.graphql +4 -0
- package/components/Media/Image/index.vue +27 -17
- package/components/Media/RemoteVideo/index.vue +3 -3
- package/components/Media/Video/index.vue +1 -1
- package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
- package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
- package/components/Node/Icms/Page/Teaser/index.vue +1 -1
- package/components/Node/Icms/Page/index.vue +8 -2
- package/components/Paragraph/Anchor/fragment.graphql +4 -0
- package/components/Paragraph/Anchor/index.vue +48 -0
- package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
- package/components/Paragraph/AnchorNavigation/index.vue +154 -0
- package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
- package/components/Paragraph/Button/Element/fragment.graphql +1 -1
- package/components/Paragraph/Button/Element/index.vue +13 -8
- package/components/Paragraph/Card/Element/index.vue +1 -1
- package/components/Paragraph/Card/Layout/index.vue +9 -9
- package/components/Paragraph/Collapsible/Element/index.vue +2 -2
- package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
- package/components/Paragraph/Contact/index.vue +17 -17
- package/components/Paragraph/Downloads/fragment.graphql +1 -1
- package/components/Paragraph/Downloads/index.vue +13 -13
- package/components/Paragraph/FAQ/Element/index.vue +4 -3
- package/components/Paragraph/FAQ/Layout/index.vue +42 -21
- package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
- package/components/Paragraph/Gallery/index.vue +14 -14
- package/components/Paragraph/HeroImage/index.vue +26 -19
- package/components/Paragraph/IconText/Element/index.vue +4 -4
- package/components/Paragraph/IconText/Layout/index.vue +12 -12
- package/components/Paragraph/Introduction/index.vue +17 -17
- package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
- package/components/Paragraph/LinkLists/Element/index.vue +5 -5
- package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
- package/components/Paragraph/Listing/fragment.graphql +14 -0
- package/components/Paragraph/Listing/index.vue +91 -0
- package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
- package/components/Paragraph/Logo/Element/index.vue +7 -4
- package/components/Paragraph/Media/index.vue +23 -21
- package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
- package/components/Paragraph/Partnerships/Element/index.vue +10 -7
- package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
- package/components/Paragraph/SingleTestimonial/index.vue +13 -13
- package/components/Paragraph/Table/index.vue +12 -12
- package/components/Paragraph/Team/Element/index.vue +16 -14
- package/components/Paragraph/Team/Layout/index.vue +9 -9
- package/components/Paragraph/Teasers/index.vue +14 -17
- package/components/Paragraph/Testimonial/Element/index.vue +19 -18
- package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
- package/components/Paragraph/Text/fragment.graphql +1 -1
- package/components/Paragraph/Text/index.vue +8 -8
- package/components/Paragraph/TextMedia/index.vue +22 -22
- package/components/Paragraph/Timeline/Element/index.vue +19 -19
- package/components/Paragraph/Timeline/Layout/index.vue +51 -20
- package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
- package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
- package/components/Paragraph/iFrame/index.vue +8 -8
- package/components/Search/Facets/FacetDropdownUI.vue +25 -31
- package/components/Search/Facets/FacetedSearchUI.vue +72 -48
- package/components/Search/Facets/fragment.graphql +20 -15
- package/components/Section/Footer/index.vue +55 -32
- package/components/Section/Header/Buttons.vue +4 -5
- package/components/Section/Header/DesktopMainMenu.vue +5 -4
- package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
- package/components/Section/Header/LanguageDropdown.vue +2 -3
- package/components/Section/Header/Logo.vue +3 -3
- package/components/Section/Header/MainButtons.vue +4 -5
- package/components/Section/Header/MetaButtons.vue +5 -6
- package/components/Section/Header/MetaMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenuItem.vue +2 -2
- package/components/Section/Header/index.vue +6 -13
- package/composables/__tests__/useImageSources.spec.ts +91 -0
- package/composables/breadcrumb.ts +12 -7
- package/composables/useAccordionScroll.ts +12 -4
- package/composables/useAllowedFragments.ts +14 -0
- package/composables/useAnchors.ts +48 -0
- package/composables/useChildTitleWeight.ts +1 -1
- package/composables/useCurrentLangcodeEnum.ts +1 -1
- package/composables/useCurrentNodeId.ts +8 -0
- package/composables/useDrupalUrl.ts +15 -0
- package/composables/useEnrichFacetLabels.ts +8 -6
- package/composables/useEntityRegistry.ts +3 -3
- package/composables/useGlobalConfig.ts +1 -2
- package/composables/useGraphqlCacheability.ts +12 -1
- package/composables/useHreflangLinks.ts +54 -0
- package/composables/useImageSources.ts +70 -28
- package/composables/useInitData.ts +1 -2
- package/composables/useIsHomepage.ts +1 -1
- package/composables/useLinkUrl.ts +30 -0
- package/composables/useMediaLightbox.ts +62 -50
- package/composables/useMenu.ts +1 -3
- package/composables/useMessageBus.ts +14 -7
- package/composables/useMobileMenuOverlayBus.ts +1 -1
- package/composables/useNavigationOffset.ts +49 -6
- package/composables/usePageCache.ts +33 -0
- package/composables/useSiteShell.ts +11 -0
- package/composables/useTheme.ts +17 -13
- package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
- package/config/blokkli.ts +7 -13
- package/config/easyTexts.ts +1 -1
- package/config/graphqlMiddleware.ts +11 -4
- package/config/index.ts +5 -2
- package/config/languageNegotiation.ts +1 -1
- package/config/multiCache.ts +6 -1
- package/config/postcss-drupal/postcss.config.cjs +2 -2
- package/config/runtimeConfig.ts +7 -2
- package/config/svgIconSprite.ts +1 -2
- package/config/vuepal.ts +16 -18
- package/error.vue +1 -22
- package/layouts/default.vue +2 -6
- package/layouts/error.vue +7 -15
- package/modules/blokkliFragments.ts +76 -0
- package/modules/dynamicEntityComponentResolver.ts +47 -47
- package/modules/dynamicParagraphResolver.ts +43 -8
- package/modules/pageCache.ts +106 -0
- package/nuxt.config.ts +62 -33
- package/package.json +23 -22
- package/pages/[...slug]/index.vue +25 -4
- package/pages/index.vue +3 -3
- package/pages/node/[nid]/index.vue +2 -3
- package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
- package/plugins/currentNode.ts +17 -0
- package/plugins/graphqlMiddleware.ts +10 -2
- package/plugins/initData.ts +15 -7
- package/plugins/registerNodeIcmsPageComponents.ts +6 -2
- package/plugins/siteShell.client.ts +94 -0
- package/queries/facetTermLabels.graphql +1 -5
- package/queries/fragment.linkUrl.graphql +12 -0
- package/queries/query.contentSearch.graphql +17 -2
- package/queries/query.initData.graphql +1 -1
- package/scripts/create-shade.ts +13 -4
- package/scripts/update-icms.ts +3 -1
- package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
- package/server/__tests__/turnstile.spec.ts +119 -0
- package/server/api/icms/blokkli/last-changed.get.ts +24 -0
- package/server/api/icon/[...params].ts +3 -9
- package/server/graphqlMiddleware.serverOptions.ts +9 -8
- package/server/helpers/index.ts +0 -1
- package/server/middleware/turnstile.ts +51 -0
- package/server/plugins/frameAncestors.ts +20 -0
- package/server/plugins/pageCache.ts +59 -0
- package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
- package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
- package/server/utils/accessDeniedHandover.ts +48 -0
- package/server/utils/backendRequestHeaders.ts +51 -0
- package/server/utils/cacheability.ts +4 -4
- package/shared/__tests__/blokkliState.spec.ts +54 -0
- package/shared/__tests__/csp.spec.ts +53 -0
- package/shared/__tests__/drupalUrl.spec.ts +50 -0
- package/shared/blokkliState.ts +40 -0
- package/shared/csp.ts +47 -0
- package/shared/drupalUrl.ts +30 -0
- package/tailwind.config.ts +0 -5
- package/types/breadcrumb.ts +22 -0
- package/types/global.ts +15 -14
- package/types/langcode.ts +16 -0
- package/types/layerConfig.ts +84 -0
- package/utils/pageCache.ts +95 -0
- package/utils/turnstile.ts +35 -0
- package/composables/useWebformEnrichment.ts +0 -68
- package/server/__tests__/graphqlMiddleware.ts +0 -41
- package/server/multiCache.serverOptions.ts +0 -32
- package/vitest.config.mts +0 -28
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
NodeIcmsPage,
|
|
3
|
+
NodeIcmsPageTeaser,
|
|
4
|
+
NodeIcmsPageSearchResult,
|
|
5
|
+
} from '#components'
|
|
2
6
|
|
|
3
7
|
export default defineNuxtPlugin(() => {
|
|
4
8
|
const { register } = useEntityRegistry()
|
|
@@ -6,4 +10,4 @@ export default defineNuxtPlugin(() => {
|
|
|
6
10
|
register('NodeIcmsPage', NodeIcmsPage)
|
|
7
11
|
register('NodeIcmsPageTeaser', NodeIcmsPageTeaser)
|
|
8
12
|
register('NodeIcmsPageSearchResult', NodeIcmsPageSearchResult)
|
|
9
|
-
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { defineNuxtPlugin } from '#app'
|
|
2
|
+
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
3
|
+
import useDrupalUser from '../composables/useDrupalUser'
|
|
4
|
+
import useSiteShell from '../composables/useSiteShell'
|
|
5
|
+
import { drupalOrigin } from '../shared/drupalUrl'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bridge to the Drupal backend's site shell.
|
|
9
|
+
*
|
|
10
|
+
* Framed by the backend: tell it about route changes (it mirrors the path in
|
|
11
|
+
* its address bar and refreshes the top bar). The blökkli editor and preview
|
|
12
|
+
* need the whole window, so they leave the shell (the top window navigates to
|
|
13
|
+
* the frontend). Not framed: editors are taken into the shell whenever they
|
|
14
|
+
* are not editing, so browsing always happens in one UI. `?noshell` keeps the
|
|
15
|
+
* bare frontend for the browser session.
|
|
16
|
+
*/
|
|
17
|
+
export default defineNuxtPlugin({
|
|
18
|
+
name: 'icms-site-shell',
|
|
19
|
+
dependsOn: ['nuxt:router', 'drupal-user'],
|
|
20
|
+
setup(nuxtApp) {
|
|
21
|
+
// The backend of the domain the frontend runs on (docs/routing.md).
|
|
22
|
+
const backendOrigin = drupalOrigin(window.location)
|
|
23
|
+
const shell = useSiteShell()
|
|
24
|
+
const router = useRouter()
|
|
25
|
+
|
|
26
|
+
const framedByBackend = (): boolean => {
|
|
27
|
+
if (window.parent === window) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
const ancestors = window.location.ancestorOrigins
|
|
31
|
+
if (ancestors && ancestors.length) {
|
|
32
|
+
return ancestors[0] === backendOrigin
|
|
33
|
+
}
|
|
34
|
+
return document.referrer.startsWith(backendOrigin)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isEditing = (route: RouteLocationNormalizedLoaded) =>
|
|
38
|
+
!!(route.query.blokkliEditing || route.query.blokkliPreview)
|
|
39
|
+
|
|
40
|
+
if (framedByBackend()) {
|
|
41
|
+
shell.value.framed = true
|
|
42
|
+
const post = (type: string, data: Record<string, unknown>) =>
|
|
43
|
+
window.parent.postMessage({ type, ...data }, backendOrigin)
|
|
44
|
+
const announce = () =>
|
|
45
|
+
post('icms:navigate', {
|
|
46
|
+
path: router.currentRoute.value.fullPath,
|
|
47
|
+
title: document.title,
|
|
48
|
+
})
|
|
49
|
+
const leaveShell = () => {
|
|
50
|
+
window.top?.location.replace(window.location.href)
|
|
51
|
+
}
|
|
52
|
+
// The title is updated by useHead once the page has finished loading;
|
|
53
|
+
// query-only changes don't finish a page.
|
|
54
|
+
nuxtApp.hook('page:finish', () => {
|
|
55
|
+
setTimeout(announce, 100)
|
|
56
|
+
})
|
|
57
|
+
router.afterEach((to) => {
|
|
58
|
+
if (isEditing(to)) {
|
|
59
|
+
leaveShell()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
setTimeout(announce, 100)
|
|
63
|
+
})
|
|
64
|
+
if (isEditing(router.currentRoute.value)) {
|
|
65
|
+
leaveShell()
|
|
66
|
+
}
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NO_SHELL_KEY = 'icms:noshell'
|
|
71
|
+
const noShell = (): boolean => {
|
|
72
|
+
try {
|
|
73
|
+
if ('noshell' in router.currentRoute.value.query) {
|
|
74
|
+
sessionStorage.setItem(NO_SHELL_KEY, '1')
|
|
75
|
+
}
|
|
76
|
+
return sessionStorage.getItem(NO_SHELL_KEY) === '1'
|
|
77
|
+
} catch {
|
|
78
|
+
return 'noshell' in router.currentRoute.value.query
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const enterShell = (route: RouteLocationNormalizedLoaded) => {
|
|
82
|
+
if (
|
|
83
|
+
useDrupalUser().value.accessToolbar &&
|
|
84
|
+
!isEditing(route) &&
|
|
85
|
+
!noShell()
|
|
86
|
+
) {
|
|
87
|
+
window.location.replace(backendOrigin + route.fullPath)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
enterShell(router.currentRoute.value)
|
|
91
|
+
// Closing the editor navigates back to the page: return to the shell.
|
|
92
|
+
router.afterEach((to) => enterShell(to))
|
|
93
|
+
},
|
|
94
|
+
})
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
query facetTermLabels($ids: [String!]!) {
|
|
2
2
|
terms: entityQuery(
|
|
3
3
|
entityType: TAXONOMY_TERM
|
|
4
|
-
filter: {
|
|
5
|
-
conditions: [
|
|
6
|
-
{ field: "tid", value: $ids, operator: IN }
|
|
7
|
-
]
|
|
8
|
-
}
|
|
4
|
+
filter: { conditions: [{ field: "tid", value: $ids, operator: IN }] }
|
|
9
5
|
) {
|
|
10
6
|
items {
|
|
11
7
|
... on TaxonomyTerm {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fragment linkUrl on Url {
|
|
2
|
+
path
|
|
3
|
+
... on EntityCanonicalUrl {
|
|
4
|
+
entity {
|
|
5
|
+
# Only media select an id here: it marks a link whose target (the file
|
|
6
|
+
# behind /media/{id}) is served by the backend, not by the Nuxt app.
|
|
7
|
+
... on Media {
|
|
8
|
+
id
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
query contentSearch(
|
|
2
|
-
|
|
1
|
+
query contentSearch(
|
|
2
|
+
$keys: String!
|
|
3
|
+
$language: String
|
|
4
|
+
$facets: [String!]
|
|
5
|
+
$filter: SearchApiQueryFilterInput
|
|
6
|
+
$limit: Int
|
|
7
|
+
$offset: Int
|
|
8
|
+
) {
|
|
9
|
+
searchApiQuery(
|
|
10
|
+
indexId: "content_search"
|
|
11
|
+
keys: $keys
|
|
12
|
+
language: $language
|
|
13
|
+
facets: $facets
|
|
14
|
+
filter: $filter
|
|
15
|
+
limit: $limit
|
|
16
|
+
offset: $offset
|
|
17
|
+
) {
|
|
3
18
|
count
|
|
4
19
|
facets {
|
|
5
20
|
field
|
package/scripts/create-shade.ts
CHANGED
|
@@ -98,7 +98,12 @@ async function main() {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const oklch = toOklch(parsed)
|
|
101
|
-
if (
|
|
101
|
+
if (
|
|
102
|
+
!oklch ||
|
|
103
|
+
oklch.l === undefined ||
|
|
104
|
+
oklch.c === undefined ||
|
|
105
|
+
oklch.h === undefined
|
|
106
|
+
) {
|
|
102
107
|
fail(`Could not convert color to OKLCH: ${rawColor}`)
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -142,7 +147,9 @@ async function main() {
|
|
|
142
147
|
].join('\n')
|
|
143
148
|
}).join('\n')
|
|
144
149
|
|
|
145
|
-
const themeShadeLines = SHADES.map(
|
|
150
|
+
const themeShadeLines = SHADES.map(
|
|
151
|
+
(shade) => ` --color-${name}-${shade}: var(--${name}-${shade});`,
|
|
152
|
+
).join('\n')
|
|
146
153
|
|
|
147
154
|
const fileContent = `:root {
|
|
148
155
|
/* ${displayName}: ${hex} */
|
|
@@ -162,7 +169,9 @@ ${themeShadeLines}
|
|
|
162
169
|
await writeFile(filePath, fileContent)
|
|
163
170
|
await ensureColorsImport(colorsPath, fileName)
|
|
164
171
|
|
|
165
|
-
console.log(
|
|
172
|
+
console.log(
|
|
173
|
+
`Created ${fileName} from ${hex} with anchor shade ${anchorShade}`,
|
|
174
|
+
)
|
|
166
175
|
}
|
|
167
176
|
|
|
168
|
-
await main()
|
|
177
|
+
await main()
|
package/scripts/update-icms.ts
CHANGED
|
@@ -50,7 +50,9 @@ const pick = (dependencies?: Record<string, string>) =>
|
|
|
50
50
|
const add = (packages: string[], flags: string[] = []) => {
|
|
51
51
|
if (packages.length === 0) return
|
|
52
52
|
console.log(`Updating ${packages.join(', ')}`)
|
|
53
|
-
const result = spawnSync('bun', ['add', ...flags, ...packages], {
|
|
53
|
+
const result = spawnSync('bun', ['add', ...flags, ...packages], {
|
|
54
|
+
stdio: 'inherit',
|
|
55
|
+
})
|
|
54
56
|
if (result.status !== 0) {
|
|
55
57
|
fail(`Failed to update ${packages.join(', ')}`)
|
|
56
58
|
}
|
|
@@ -15,8 +15,8 @@ vi.mock('#nuxt-language-negotiation/config', () => ({
|
|
|
15
15
|
langcodes: ['de', 'fr', 'it', 'en'],
|
|
16
16
|
}))
|
|
17
17
|
|
|
18
|
-
vi.mock('
|
|
19
|
-
|
|
18
|
+
vi.mock('../utils/backendRequestHeaders', () => ({
|
|
19
|
+
buildBackendRequestHeaders: () => ({}),
|
|
20
20
|
}))
|
|
21
21
|
|
|
22
22
|
// Nitro auto-imports in the module under test, provided as globals: the h3
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { createEvent, createError, defineEventHandler, getHeader } from 'h3'
|
|
3
|
+
import type { H3Event } from 'h3'
|
|
4
|
+
import { IncomingMessage, ServerResponse } from 'node-mock-http'
|
|
5
|
+
import {
|
|
6
|
+
TURNSTILE_ERROR_CODE,
|
|
7
|
+
TURNSTILE_TOKEN_HEADER,
|
|
8
|
+
isWebformSubmissionRequest,
|
|
9
|
+
} from '../../utils/turnstile'
|
|
10
|
+
|
|
11
|
+
// Nitro auto-imports in the module under test, provided as globals. The h3
|
|
12
|
+
// helpers are the real ones; the runtime config and the Cloudflare call are
|
|
13
|
+
// the two things a unit test has to control.
|
|
14
|
+
const runtimeConfig = { turnstile: { secretKey: '' } }
|
|
15
|
+
const verifyTurnstileToken = vi.fn()
|
|
16
|
+
|
|
17
|
+
vi.stubGlobal('defineEventHandler', defineEventHandler)
|
|
18
|
+
vi.stubGlobal('getHeader', getHeader)
|
|
19
|
+
vi.stubGlobal('createError', createError)
|
|
20
|
+
vi.stubGlobal('useRuntimeConfig', () => runtimeConfig)
|
|
21
|
+
vi.stubGlobal('verifyTurnstileToken', verifyTurnstileToken)
|
|
22
|
+
|
|
23
|
+
const middleware = (await import('../middleware/turnstile')).default
|
|
24
|
+
|
|
25
|
+
function submissionEvent(headers: Record<string, string> = {}): H3Event {
|
|
26
|
+
const request = new IncomingMessage()
|
|
27
|
+
request.url = '/api/graphql/mutation/submitWebform'
|
|
28
|
+
request.method = 'POST'
|
|
29
|
+
Object.assign(request.headers, headers)
|
|
30
|
+
|
|
31
|
+
return createEvent(request, new ServerResponse(request))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function run(event: H3Event) {
|
|
35
|
+
return (middleware as unknown as (event: H3Event) => Promise<void>)(event)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('isWebformSubmissionRequest', () => {
|
|
39
|
+
it.each([
|
|
40
|
+
'/api/graphql/mutation/submitWebform',
|
|
41
|
+
'/api/graphql/mutation/submitWebform?__client=1',
|
|
42
|
+
// A project may change graphqlMiddleware.serverApiPrefix.
|
|
43
|
+
'/custom-prefix/mutation/submitWebform',
|
|
44
|
+
])('matches %s', (path) => {
|
|
45
|
+
expect(isWebformSubmissionRequest(path)).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
'/api/graphql/query/route',
|
|
50
|
+
'/api/graphql/mutation/submitWebformSomethingElse',
|
|
51
|
+
'/de/veranstaltungen',
|
|
52
|
+
'',
|
|
53
|
+
undefined,
|
|
54
|
+
])('does not match %s', (path) => {
|
|
55
|
+
expect(isWebformSubmissionRequest(path)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('turnstile middleware', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
runtimeConfig.turnstile.secretKey = ''
|
|
62
|
+
verifyTurnstileToken.mockReset()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('lets every request through while no secret key is configured', async () => {
|
|
66
|
+
await expect(run(submissionEvent())).resolves.toBeUndefined()
|
|
67
|
+
expect(verifyTurnstileToken).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('ignores requests that are not a webform submission', async () => {
|
|
71
|
+
runtimeConfig.turnstile.secretKey = 'secret'
|
|
72
|
+
|
|
73
|
+
const request = new IncomingMessage()
|
|
74
|
+
request.url = '/api/graphql/query/route'
|
|
75
|
+
const event = createEvent(request, new ServerResponse(request))
|
|
76
|
+
|
|
77
|
+
await expect(run(event)).resolves.toBeUndefined()
|
|
78
|
+
expect(verifyTurnstileToken).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('rejects a submission without a token', async () => {
|
|
82
|
+
runtimeConfig.turnstile.secretKey = 'secret'
|
|
83
|
+
|
|
84
|
+
await expect(run(submissionEvent())).rejects.toMatchObject({
|
|
85
|
+
statusCode: 403,
|
|
86
|
+
data: { code: TURNSTILE_ERROR_CODE },
|
|
87
|
+
})
|
|
88
|
+
expect(verifyTurnstileToken).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects a submission Cloudflare does not verify', async () => {
|
|
92
|
+
runtimeConfig.turnstile.secretKey = 'secret'
|
|
93
|
+
verifyTurnstileToken.mockResolvedValue({
|
|
94
|
+
success: false,
|
|
95
|
+
'error-codes': ['invalid-input-response'],
|
|
96
|
+
})
|
|
97
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
run(submissionEvent({ [TURNSTILE_TOKEN_HEADER]: 'a-token' })),
|
|
101
|
+
).rejects.toMatchObject({
|
|
102
|
+
statusCode: 403,
|
|
103
|
+
data: { code: TURNSTILE_ERROR_CODE },
|
|
104
|
+
})
|
|
105
|
+
expect(verifyTurnstileToken).toHaveBeenCalledWith(
|
|
106
|
+
'a-token',
|
|
107
|
+
expect.anything(),
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('passes a verified submission through', async () => {
|
|
112
|
+
runtimeConfig.turnstile.secretKey = 'secret'
|
|
113
|
+
verifyTurnstileToken.mockResolvedValue({ success: true })
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
run(submissionEvent({ [TURNSTILE_TOKEN_HEADER]: 'a-token' })),
|
|
117
|
+
).resolves.toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { buildBackendRequestHeaders } from '../../../utils/backendRequestHeaders'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proxies paragraphs_blokkli's "last changed" poll to the Drupal backend.
|
|
5
|
+
*
|
|
6
|
+
* The blökkli preview polls this endpoint; the Drupal route is not reachable
|
|
7
|
+
* on the frontend domain when the backend runs on its own domain, and calling
|
|
8
|
+
* it there directly would be a cross-origin request.
|
|
9
|
+
*/
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
const query = getQuery(event)
|
|
12
|
+
const entityType = String(query.entityType || '')
|
|
13
|
+
const entityUuid = String(query.entityUuid || '')
|
|
14
|
+
if (!/^[a-z_]+$/.test(entityType) || !/^[0-9a-f-]{36}$/i.test(entityUuid)) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid entity.' })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
setResponseHeader(event, 'Cache-Control', 'private, no-store')
|
|
19
|
+
const config = useRuntimeConfig()
|
|
20
|
+
return $fetch<{ changed: number }>(
|
|
21
|
+
`${config.backendUrl}/paragraphs_blokkli/${entityType}/${entityUuid}/last_changed`,
|
|
22
|
+
{ headers: buildBackendRequestHeaders(event) },
|
|
23
|
+
)
|
|
24
|
+
})
|
|
@@ -2,6 +2,7 @@ import { getRouterParams, defaultContentType, defineEventHandler } from 'h3'
|
|
|
2
2
|
import type { H3Event } from 'h3'
|
|
3
3
|
import { optimize } from 'svgo'
|
|
4
4
|
import { MAX_AGE } from '../../helpers'
|
|
5
|
+
import { buildBackendRequestHeaders } from '../../utils/backendRequestHeaders'
|
|
5
6
|
|
|
6
7
|
const config = useRuntimeConfig()
|
|
7
8
|
|
|
@@ -117,17 +118,10 @@ async function getIcon(
|
|
|
117
118
|
return value
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
const requestHeaders = getHeaders(event)
|
|
121
|
-
const host = requestHeaders.host || ''
|
|
122
|
-
|
|
123
121
|
// Fetch the SVG markup.
|
|
124
122
|
const url = `${config.backendUrl}/de/media/${id}/icon`
|
|
125
123
|
const response = await $fetch.raw<Blob>(url, {
|
|
126
|
-
headers:
|
|
127
|
-
host,
|
|
128
|
-
cookie: requestHeaders.cookie || '',
|
|
129
|
-
referer: requestHeaders.referer || '',
|
|
130
|
-
},
|
|
124
|
+
headers: buildBackendRequestHeaders(event),
|
|
131
125
|
})
|
|
132
126
|
|
|
133
127
|
const cacheability = extractCacheability(response, event)
|
|
@@ -197,4 +191,4 @@ export default defineEventHandler(async (event) => {
|
|
|
197
191
|
// We don't want to return a message here, only an empty response.
|
|
198
192
|
return EMPTY_RESPONSE
|
|
199
193
|
}
|
|
200
|
-
})
|
|
194
|
+
})
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/server-options'
|
|
2
|
+
import { getRequestURL } from 'h3'
|
|
2
3
|
import { extractCacheability } from '../server/utils/cacheability'
|
|
3
4
|
import { defaultLangcode, langcodes } from '#nuxt-language-negotiation/config'
|
|
4
5
|
import { MAX_AGE, type GraphqlCacheability } from '../server/helpers'
|
|
5
|
-
import {
|
|
6
|
+
import { buildBackendRequestHeaders } from '../server/utils/backendRequestHeaders'
|
|
7
|
+
import { handoverAccessDeniedRoute } from '../server/utils/accessDeniedHandover'
|
|
8
|
+
import { drupalOrigin } from '../shared/drupalUrl'
|
|
6
9
|
|
|
7
10
|
export default defineGraphqlServerOptions<{
|
|
8
11
|
extensions: {
|
|
@@ -22,13 +25,8 @@ export default defineGraphqlServerOptions<{
|
|
|
22
25
|
return `${config.backendUrl}/${defaultLangcode}/graphql`
|
|
23
26
|
},
|
|
24
27
|
serverFetchOptions(event) {
|
|
25
|
-
const config = useRuntimeConfig()
|
|
26
|
-
|
|
27
28
|
return {
|
|
28
|
-
headers:
|
|
29
|
-
...buildProxyRequestHeaders(event),
|
|
30
|
-
'x-drupal-graphql-token': config.drupalGraphqlToken,
|
|
31
|
-
},
|
|
29
|
+
headers: buildBackendRequestHeaders(event),
|
|
32
30
|
}
|
|
33
31
|
},
|
|
34
32
|
onServerResponse(event, graphqlResponse, operation, operationName, context) {
|
|
@@ -83,7 +81,10 @@ export default defineGraphqlServerOptions<{
|
|
|
83
81
|
|
|
84
82
|
// Return the GraphQL response.
|
|
85
83
|
return {
|
|
86
|
-
data:
|
|
84
|
+
data: handoverAccessDeniedRoute(
|
|
85
|
+
graphqlResponse._data!.data,
|
|
86
|
+
drupalOrigin(getRequestURL(event)),
|
|
87
|
+
),
|
|
87
88
|
errors: graphqlResponse._data!.errors,
|
|
88
89
|
extensions: {
|
|
89
90
|
cacheability: addCacheability ? cacheability : null,
|
package/server/helpers/index.ts
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TURNSTILE_ERROR_CODE,
|
|
3
|
+
TURNSTILE_TOKEN_HEADER,
|
|
4
|
+
isWebformSubmissionRequest,
|
|
5
|
+
} from '../../utils/turnstile'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Verifies the Turnstile token before a webform submission reaches Drupal.
|
|
9
|
+
*
|
|
10
|
+
* `graphql_webform` submits through Webform's programmatic API, which bypasses
|
|
11
|
+
* the Form API — Drupal-side spam modules (honeypot, captcha, antibot) hook
|
|
12
|
+
* into `hook_form_alter()` and therefore never run on a decoupled submission.
|
|
13
|
+
* The check has to happen where the submission crosses the boundary, and it has
|
|
14
|
+
* to happen on the server: verifying in the component only would be skipped by
|
|
15
|
+
* anyone calling the mutation endpoint directly.
|
|
16
|
+
*
|
|
17
|
+
* Disabled, and completely transparent, while no secret key is configured.
|
|
18
|
+
*/
|
|
19
|
+
export default defineEventHandler(async (event) => {
|
|
20
|
+
if (!isWebformSubmissionRequest(event.path)) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const secretKey = useRuntimeConfig(event).turnstile?.secretKey
|
|
25
|
+
if (!secretKey) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const token = getHeader(event, TURNSTILE_TOKEN_HEADER)
|
|
30
|
+
if (!token) {
|
|
31
|
+
throw createError({
|
|
32
|
+
statusCode: 403,
|
|
33
|
+
statusMessage: 'Missing spam protection token.',
|
|
34
|
+
data: { code: TURNSTILE_ERROR_CODE },
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = await verifyTurnstileToken(token, event)
|
|
39
|
+
if (!result?.success) {
|
|
40
|
+
console.warn(
|
|
41
|
+
'[turnstile] Rejected a webform submission:',
|
|
42
|
+
result?.['error-codes'] ?? 'no error codes returned',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
throw createError({
|
|
46
|
+
statusCode: 403,
|
|
47
|
+
statusMessage: 'Spam protection check failed.',
|
|
48
|
+
data: { code: TURNSTILE_ERROR_CODE },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { drupalOrigin } from '../../shared/drupalUrl'
|
|
2
|
+
import { mergeFrameAncestors } from '../../shared/csp'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lets the Drupal backend frame the frontend (site shell).
|
|
6
|
+
*
|
|
7
|
+
* The backend is the "backend." subdomain of the domain the page was
|
|
8
|
+
* requested on (see docs/routing.md). The directive is merged into whatever
|
|
9
|
+
* Content-Security-Policy the rendered response already carries — a project's
|
|
10
|
+
* own policy keeps its other directives and sources.
|
|
11
|
+
*/
|
|
12
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
13
|
+
nitroApp.hooks.hook('render:response', (response, { event }) => {
|
|
14
|
+
const origin = drupalOrigin(getRequestURL(event))
|
|
15
|
+
response.headers['content-security-policy'] = mergeFrameAncestors(
|
|
16
|
+
response.headers['content-security-policy'],
|
|
17
|
+
["'self'", origin],
|
|
18
|
+
).join(', ')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { removeResponseHeader } from 'h3'
|
|
2
|
+
import { serverOptions } from '#nuxt-multi-cache/server-options'
|
|
3
|
+
import {
|
|
4
|
+
applyCacheControlHeaders,
|
|
5
|
+
finalizeRouteCache,
|
|
6
|
+
resolvePageCacheConfig,
|
|
7
|
+
setRouteCacheStatusHeader,
|
|
8
|
+
} from '../../cache/pageCache'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Applies the page cache mode to every response (ICMS-268), see
|
|
12
|
+
* cache/pageCache.ts.
|
|
13
|
+
*/
|
|
14
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
15
|
+
const config = resolvePageCacheConfig(useRuntimeConfig().pageCache)
|
|
16
|
+
|
|
17
|
+
// Without a bounded route storage nuxt-multi-cache falls back to an
|
|
18
|
+
// unbounded in-memory one, so pages are never stored then. This happens
|
|
19
|
+
// when a project's own server/multiCache.serverOptions.ts replaces the
|
|
20
|
+
// ICMS server options (modules/pageCache.ts) without one.
|
|
21
|
+
const routeDriver = serverOptions.route?.storage?.driver
|
|
22
|
+
const hasBoundedRouteStorage = !!routeDriver
|
|
23
|
+
if (hasBoundedRouteStorage && config.mode !== 'varnish') {
|
|
24
|
+
const maxSize = Number(routeDriver.options?.maxSize) || 0
|
|
25
|
+
console.info(
|
|
26
|
+
config.routeMaxSize === 0
|
|
27
|
+
? '[icms] Page cache: off (routeMaxSize 0, the default in development)'
|
|
28
|
+
: `[icms] Page cache: ${(maxSize / 1024 / 1024).toFixed(1)} MiB` +
|
|
29
|
+
(config.routeMaxSize === 'auto' ? ' (auto)' : '') +
|
|
30
|
+
(config.routeCompression ? ', gzip' : ''),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
if (!hasBoundedRouteStorage) {
|
|
34
|
+
console.error(
|
|
35
|
+
'[icms] Page cache disabled: the multi-cache server options define ' +
|
|
36
|
+
'no route storage. Extend the ICMS server options in ' +
|
|
37
|
+
'server/multiCache.serverOptions.ts (see the @iqual/nuxt-icms README).',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
nitroApp.hooks.hook('beforeResponse', (event) => {
|
|
42
|
+
if (!hasBoundedRouteStorage) {
|
|
43
|
+
event.context.multiCache?.route?.setUncacheable()
|
|
44
|
+
}
|
|
45
|
+
finalizeRouteCache(event, config)
|
|
46
|
+
applyCacheControlHeaders(event, config)
|
|
47
|
+
setRouteCacheStatusHeader(event)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Error responses (e.g. a 404 thrown during the render) are sent by the
|
|
51
|
+
// error handler without the beforeResponse hook. The headers of the error
|
|
52
|
+
// page render are applied on top, but a Cache-Tag the failed render already
|
|
53
|
+
// set would stay.
|
|
54
|
+
nitroApp.hooks.hook('error', (_error, { event }) => {
|
|
55
|
+
if (event && config.mode !== 'varnish') {
|
|
56
|
+
removeResponseHeader(event, 'cache-tag')
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { handoverAccessDeniedRoute } from '../accessDeniedHandover'
|
|
3
|
+
|
|
4
|
+
const origin = 'https://backend.www.example.com'
|
|
5
|
+
|
|
6
|
+
describe('handoverAccessDeniedRoute', () => {
|
|
7
|
+
it('sends an access-denied route to the backend page directly', () => {
|
|
8
|
+
const data = {
|
|
9
|
+
route: {
|
|
10
|
+
__typename: 'RedirectUrl',
|
|
11
|
+
path: '/user/login?destination=/de/node/1/latest',
|
|
12
|
+
redirect: { statusCode: 403 },
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
expect(handoverAccessDeniedRoute(data, origin)).toEqual({
|
|
16
|
+
route: {
|
|
17
|
+
__typename: 'RedirectUrl',
|
|
18
|
+
path: 'https://backend.www.example.com/de/node/1/latest',
|
|
19
|
+
redirect: { statusCode: 302 },
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
// The response is not mutated.
|
|
23
|
+
expect(data.route.redirect.statusCode).toBe(403)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to the login form without a usable destination', () => {
|
|
27
|
+
for (const path of ['/de/user/login', '/user/login?destination=//evil']) {
|
|
28
|
+
expect(
|
|
29
|
+
handoverAccessDeniedRoute(
|
|
30
|
+
{ route: { path, redirect: { statusCode: 403 } } },
|
|
31
|
+
origin,
|
|
32
|
+
).route?.path,
|
|
33
|
+
).toBe(origin + path)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('leaves other responses alone', () => {
|
|
38
|
+
const cases = [
|
|
39
|
+
{ route: { path: '/de/neu', redirect: { statusCode: 301 } } },
|
|
40
|
+
{ route: { path: '/user/login?destination=/x', redirect: null } },
|
|
41
|
+
{ route: { __typename: 'EntityCanonicalUrl', path: '/de/x' } },
|
|
42
|
+
{ route: null },
|
|
43
|
+
{},
|
|
44
|
+
]
|
|
45
|
+
for (const data of cases) {
|
|
46
|
+
expect(handoverAccessDeniedRoute(data, origin)).toBe(data)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
})
|