Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8ef1b56
feat: use resource extra info about its CORS headers before trying to…
bolinocroustibat Feb 23, 2026
b956ca8
refactor: move getResourceFilesize and isResourceCorsEnabled to funct…
bolinocroustibat Feb 23, 2026
366a716
fix: remove double comment and add a TODO
bolinocroustibat Feb 23, 2026
47ce985
refactor: move resources functions to resources.ts instead of dataset.ts
bolinocroustibat Feb 24, 2026
7b19300
docs: fix comment
bolinocroustibat Feb 24, 2026
2116936
fix: when testing preview, first check the file size than the CORS, s…
bolinocroustibat Feb 24, 2026
bf017a7
Merge branch 'main' into feat/preview-depending-on-cors
bolinocroustibat Feb 24, 2026
19ae796
Merge branch 'main' into feat/preview-depending-on-cors
bolinocroustibat Feb 25, 2026
d60e24b
fix: fix css class typo
bolinocroustibat Feb 25, 2026
0f8d9d7
fix: remove isHealthy check in isResourceCorsEnabled
bolinocroustibat Feb 25, 2026
9f84710
Merge branch 'main' into feat/preview-depending-on-cors
bolinocroustibat Feb 25, 2026
d3ed929
feat: use trustedDomains from config
bolinocroustibat Feb 25, 2026
efeef3b
fix: manage different cases for getResourceCorsStatus
bolinocroustibat Feb 25, 2026
ef820f2
Merge branch 'main' into feat/preview-depending-on-cors
bolinocroustibat Mar 25, 2026
3d6c2b0
style: better UI messages for preview CORS error
bolinocroustibat Mar 25, 2026
950d25d
fix: fix wrong useComponentsConfig call
bolinocroustibat Mar 26, 2026
cb13f9a
Merge branch 'main' into feat/preview-depending-on-cors
bolinocroustibat Mar 27, 2026
a5b1c9c
fix: stricter trustedDomains CORS check
bolinocroustibat Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const siteConfig = useSiteConfig()
app.vueApp.use(datagouv, {
name: runtimeConfig.public.title,
baseUrl: siteConfig.url,
trustedDomains: runtimeConfig.public.trustedDomains,
apiBase: runtimeConfig.public.apiBase,
devApiKey: runtimeConfig.public.devApiKey,
datasetQualityGuideUrl: runtimeConfig.public.datasetQualityGuideUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
: t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.")
}}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'cors'">
{{ t("Ce fichier JSON ne peut pas être prévisualisé car il est hébergé sur un site distant qui restreint l'accès (CORS). Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'network'">
{{ t("Ce fichier est hébergé sur un site externe qui ne permet pas la prévisualisation. Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
Expand All @@ -37,8 +40,8 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useComponentsConfig } from '../../config'
import PreviewUnavailable from './PreviewUnavailable.vue'
import type { Resource } from '../../types/resources'
import { getResourceFilesize, getResourceCorsStatus } from '../../functions/resources'
import { useTranslation } from '../../composables/useTranslation'
import { getResourceFilesize } from '../../functions/datasets'

const JsonViewer = defineAsyncComponent(() =>
import('vue3-json-viewer').then((module) => {
Expand All @@ -62,36 +65,37 @@ const fileTooLarge = ref(false)

const fileSizeBytes = computed(() => getResourceFilesize(props.resource))

const shouldLoadJson = computed(() => {
const size = fileSizeBytes.value
if (!size) {
// If we don't know the size, don't risk loading a potentially huge file
return false
}

// Check if maxJsonPreviewCharSize is configured
if (!config.maxJsonPreviewCharSize) {
// If no limit is set, don't load unknown files
return false
}
const corsStatus = computed(() => getResourceCorsStatus(props.resource))

const isSizeAllowed = computed(() => {
const size = fileSizeBytes.value
// Convert maxJsonPreviewCharSize from characters to bytes (rough estimate)
// Assuming average 1 byte per character for JSON
const maxByteSize = config.maxJsonPreviewCharSize

// If we don't know the size or the max size, don't risk loading a potentially huge file
if (!size || !maxByteSize) return false

return size <= maxByteSize
})

const fetchJsonData = async () => {
// Check if file is too large or size is unknown before making the request
if (!shouldLoadJson.value) {
error.value = null
fileTooLarge.value = false

// Check if file is too large or size is unknown
if (!isSizeAllowed.value) {
fileTooLarge.value = true
return
}

loading.value = true
error.value = null
// Check if CORS is allowed
if (corsStatus.value === 'blocked') {
error.value = 'cors'
return
}

loading.value = true
try {
const response = await fetch(props.resource.url)
// const response = await fetch('/test-data.json') // For testing locally without CORS issues
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import DescriptionTerm from '../DescriptionTerm.vue'
import { useFormatDate } from '../../functions/dates'
import { filesize } from '../../functions/helpers'
import ExtraAccordion from '../ExtraAccordion.vue'
import { getResourceTitleId, getResourceLabel } from '../../functions/resources'
import { getResourceTitleId, getResourceLabel, getResourceFilesize } from '../../functions/resources'
import { useTranslation } from '../../composables/useTranslation'
import { getResourceFilesize } from '../../functions/datasets'

const props = defineProps<{
resource: Resource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
: t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.")
}}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'cors'">
{{ t("Ce fichier PDF ne peut pas être prévisualisé car il est hébergé sur un site distant qui restreint l'accès (CORS). Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'network'">
{{ t("Ce fichier est hébergé sur un site externe qui ne permet pas la prévisualisation. Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
Expand All @@ -41,8 +44,8 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'
import PreviewUnavailable from './PreviewUnavailable.vue'
import { useComponentsConfig } from '../../config'
import type { Resource } from '../../types/resources'
import { getResourceFilesize, getResourceCorsStatus } from '../../functions/resources'
import { useTranslation } from '../../composables/useTranslation'
import { getResourceFilesize } from '../../functions/datasets'

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker

Expand Down Expand Up @@ -97,6 +100,8 @@ async function renderPage(pageNum: number) {

const fileSizeBytes = computed(() => getResourceFilesize(props.resource))

const corsStatus = computed(() => getResourceCorsStatus(props.resource))

const shouldLoadPdf = computed(() => {
const size = fileSizeBytes.value
if (!size) {
Expand All @@ -110,14 +115,21 @@ const shouldLoadPdf = computed(() => {
})

const loadPdf = async () => {
error.value = null
fileTooLarge.value = false

if (!shouldLoadPdf.value) {
fileTooLarge.value = true
return
}

loading.value = true
error.value = null
// Check if CORS is allowed
if (corsStatus.value === 'blocked') {
error.value = 'cors'
return
}

loading.value = true
try {
const loadingTask = pdfjsLib.getDocument({
url: props.resource.url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,8 @@ import { trackEvent } from '../../functions/matomo'
import CopyButton from '../CopyButton.vue'
import { useComponentsConfig } from '../../config'
import { getOwnerName } from '../../functions/owned'
import { getResourceFormatIcon, getResourceTitleId, detectOgcService } from '../../functions/resources'
import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
import BrandedButton from '../BrandedButton.vue'
import { getResourceExternalUrl, getResourceFilesize } from '../../functions/datasets'
import { useTranslation } from '../../composables/useTranslation'
import { useHasTabularData } from '../../composables/useHasTabularData'
import Metadata from './Metadata.vue'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
: t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.")
}}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'cors'">
{{ t("Ce fichier XML ne peut pas être prévisualisé car il est hébergé sur un site distant qui restreint l'accès (CORS). Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
<PreviewUnavailable v-else-if="error === 'network'">
{{ t("Ce fichier est hébergé sur un site externe qui ne permet pas la prévisualisation. Téléchargez-le depuis l'onglet Téléchargements.") }}
</PreviewUnavailable>
Expand All @@ -29,9 +32,9 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useComponentsConfig } from '../../config'
import PreviewUnavailable from './PreviewUnavailable.vue'
import type { Resource } from '../../types/resources'
import { getResourceFilesize, getResourceCorsStatus } from '../../functions/resources'
import { useTranslation } from '../../composables/useTranslation'
import '../../types/vue3-xml-viewer.d'
import { getResourceFilesize } from '../../main'

const XmlViewer = defineAsyncComponent(() =>
import('vue3-xml-viewer').then((module) => {
Expand All @@ -53,36 +56,37 @@ const fileTooLarge = ref(false)

const fileSizeBytes = computed(() => getResourceFilesize(props.resource))

const shouldLoadXml = computed(() => {
const size = fileSizeBytes.value
if (!size) {
// If we don't know the size, don't risk loading a potentially huge file
return false
}

// Check if maxXmlPreviewCharSize is configured
if (!config.maxXmlPreviewCharSize) {
// If no limit is set, don't load unknown files
return false
}
const corsStatus = computed(() => getResourceCorsStatus(props.resource))

const isSizeAllowed = computed(() => {
const size = fileSizeBytes.value
// Convert maxXmlPreviewCharSize from characters to bytes (rough estimate)
// Assuming average 1 byte per character for XML
const maxByteSize = config.maxXmlPreviewCharSize

// If we don't know the size or the max size, don't risk loading a potentially huge file
if (!size || !maxByteSize) return false

return size <= maxByteSize
})

const fetchXmlData = async () => {
// Check if file is too large or size is unknown before making the request
if (!shouldLoadXml.value) {
error.value = null
fileTooLarge.value = false

// Check if file is too large or size is unknown
if (!isSizeAllowed.value) {
fileTooLarge.value = true
return
}

loading.value = true
error.value = null
// Check if CORS is allowed
if (corsStatus.value === 'blocked') {
error.value = 'cors'
return
}

loading.value = true
try {
const response = await fetch(props.resource.url)
// const response = await fetch('/test-data.xml') // For testing locally without CORS issues
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,7 @@ import DataStructure from '../ResourceAccordion/DataStructure.vue'
import Metadata from '../ResourceAccordion/Metadata.vue'
import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
import { filesize, summarize } from '../../functions/helpers'
import { getResourceFormatIcon } from '../../functions/resources'
import { getResourceExternalUrl, getResourceFilesize } from '../../functions/datasets'
import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
import { trackEvent } from '../../functions/matomo'
import { useComponentsConfig } from '../../config'
import { useFormatDate } from '../../functions/dates'
Expand Down
2 changes: 2 additions & 0 deletions datagouv-components/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { FetchOptions } from 'ofetch'
export type PluginConfig = {
name: string // Name of the application (ex: data.gouv.fr)
baseUrl: string
/** Hostnames allowed in Access-Control-Allow-Origin for resource preview CORS checks (e.g. data.gouv.fr). */
trustedDomains?: string[]
apiBase: string
devApiKey?: string | null
datasetQualityGuideUrl?: string
Expand Down
17 changes: 0 additions & 17 deletions datagouv-components/src/functions/datasets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { useComponentsConfig } from '../config'
import type { Dataset, DatasetV2 } from '../types/datasets'
import type { CommunityResource, Resource } from '../types/resources'

function constructUrl(baseUrl: string, path: string): string {
const url = new URL(baseUrl)
Expand All @@ -14,18 +12,3 @@ export function getDatasetOEmbedHtml(type: string, id: string): string {
const staticUrl = constructUrl(config.baseUrl, 'oembed.js')
return `<div data-udata-${type}="${id}"></div><script data-udata="${config.baseUrl}" src="${staticUrl}" async defer></script>`
}

export function isCommunityResource(resource: Resource | CommunityResource): boolean {
return 'organization' in resource || 'owner' in resource
}

export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
}

export function getResourceFilesize(resource: Resource): null | number {
if (resource.filesize) return resource.filesize
if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number

return null
}
57 changes: 56 additions & 1 deletion datagouv-components/src/functions/resources.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { readonly, type Component } from 'vue'

import { RiEarthLine, RiMap2Line } from '@remixicon/vue'
import { useComponentsConfig } from '../config'
import Archive from '../components/Icons/Archive.vue'
import Code from '../components/Icons/Code.vue'
import type { Dataset, DatasetV2 } from '../types/datasets'
import Documentation from '../components/Icons/Documentation.vue'
import Image from '../components/Icons/Image.vue'
import Link from '../components/Icons/Link.vue'
import Table from '../components/Icons/Table.vue'
import type { Resource } from '../types/resources'
import type { CommunityResource, Resource } from '../types/resources'
import { useTranslation } from '../composables/useTranslation'

export function getResourceFormatIcon(format: string): Component | null {
Expand Down Expand Up @@ -129,3 +131,56 @@ export const detectOgcService = (resource: Resource) => {
}
return false
}

export function isCommunityResource(resource: Resource | CommunityResource): boolean {
return 'organization' in resource || 'owner' in resource
}

export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
}

export function getResourceFilesize(resource: Resource): null | number {
if (resource.filesize) return resource.filesize
if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number

return null
}

type CorsStatus = 'allowed' | 'blocked' | 'unknown'

export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
const extras = resource.extras
if (!extras || !('check:cors:allow-origin' in extras)) {
return 'unknown'
}

const allowOrigin = extras['check:cors:allow-origin'] as string | undefined
const rawMethods = extras['check:cors:allow-methods'] as string | undefined

// Check if allow-origin is '*' or contains one of our trusted domains
const config = useComponentsConfig()
const trustedDomains = config.trustedDomains ?? []
const hasPublicCors = allowOrigin === '*'
const hasSpecificCors = allowOrigin
? trustedDomains.some((domain) => {
try {
const hostname = new URL(allowOrigin).hostname
return hostname === domain || hostname.endsWith(`.${domain}`)
}
catch {
return false
}
})
: false

const isOriginAllowed = hasPublicCors || hasSpecificCors

// Ensure GET method is allowed
const allowedMethods = rawMethods
? rawMethods.split(',').map(m => m.trim().toUpperCase())
: []
const supportsGet = allowedMethods.length === 0 || allowedMethods.includes('GET')

return isOriginAllowed && supportsGet ? 'allowed' : 'blocked'
}
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default defineNuxtConfig({
albertApiKey: '',
public: {
isProduction: true,
trustedDomains: ['data.gouv.fr'], // Hostnames allowed in Access-Control-Allow-Origin for resource preview CORS checks (includes subdomains)
banner: undefined,

title: 'data.gouv.fr',
Expand Down
Loading