diff --git a/app.vue b/app.vue index 81d6056d7..3c02bdebc 100644 --- a/app.vue +++ b/app.vue @@ -40,6 +40,7 @@ else { app.vueApp.use(datagouv, { name: runtimeConfig.public.title, baseUrl: runtimeConfig.public.baseUrl, + trustedDomains: runtimeConfig.public.trustedDomains, apiBase: runtimeConfig.public.apiBase, devApiKey: runtimeConfig.public.devApiKey, datasetQualityGuideUrl: runtimeConfig.public.datasetQualityGuideUrl, diff --git a/datagouv-components/src/components/ResourceAccordion/JsonPreview.client.vue b/datagouv-components/src/components/ResourceAccordion/JsonPreview.client.vue index 58308120a..a2b22a4d4 100644 --- a/datagouv-components/src/components/ResourceAccordion/JsonPreview.client.vue +++ b/datagouv-components/src/components/ResourceAccordion/JsonPreview.client.vue @@ -28,13 +28,21 @@ : t("L'aperçu n'est pas disponible car la taille du fichier est inconnue. Pour consulter le fichier complet, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + + + {{ 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("Ce fichier JSON ne peut pas être prévisualisé, peut-être parce qu'il est hébergé sur un autre site qui ne l'autorise pas. Pour le consulter, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + {{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }} import('vue3-json-viewer').then((module) => { @@ -79,36 +87,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 diff --git a/datagouv-components/src/components/ResourceAccordion/Metadata.vue b/datagouv-components/src/components/ResourceAccordion/Metadata.vue index d3667561e..2ed2e0417 100644 --- a/datagouv-components/src/components/ResourceAccordion/Metadata.vue +++ b/datagouv-components/src/components/ResourceAccordion/Metadata.vue @@ -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 diff --git a/datagouv-components/src/components/ResourceAccordion/PdfPreview.client.vue b/datagouv-components/src/components/ResourceAccordion/PdfPreview.client.vue index 0b9cab3aa..adcd7106d 100644 --- a/datagouv-components/src/components/ResourceAccordion/PdfPreview.client.vue +++ b/datagouv-components/src/components/ResourceAccordion/PdfPreview.client.vue @@ -44,13 +44,21 @@ : t("L'aperçu n'est pas disponible car la taille du fichier est inconnue. Pour consulter le fichier complet, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + + + {{ 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("Ce fichier PDF ne peut pas être prévisualisé, peut-être parce qu'il est hébergé sur un autre site qui ne l'autorise pas. Pour le consulter, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + + {{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }} import('pdf-vue3').then((module) => { @@ -92,28 +100,36 @@ const fileTooLarge = ref(false) const fileSizeBytes = computed(() => getResourceFilesize(props.resource)) -const shouldLoadPdf = computed(() => { - const size = fileSizeBytes.value - if (!size) { - // If we don't know the size, don't risk loading a potentially huge file - return false - } +const corsStatus = computed(() => getResourceCorsStatus(props.resource)) +const isSizeAllowed = computed(() => { + const size = fileSizeBytes.value // Use maxPdfPreviewByteSize from config, fallback to 10 MB if not set const maxByteSize = config.maxPdfPreviewByteSize ?? 10_000_000 + + // If we don't know the size, don't risk loading a potentially huge file + if (!size) return false + return size <= maxByteSize }) const loadPdf = async () => { - // Check if file is too large or size is unknown before loading - if (!shouldLoadPdf.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 { // Test if the PDF URL is accessible const response = await fetch(props.resource.url, { method: 'HEAD' }) diff --git a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue index 20f11b71b..043df4496 100644 --- a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue +++ b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue @@ -386,9 +386,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' diff --git a/datagouv-components/src/components/ResourceAccordion/XmlPreview.client.vue b/datagouv-components/src/components/ResourceAccordion/XmlPreview.client.vue index 5c011e2bb..de6b8f2ba 100644 --- a/datagouv-components/src/components/ResourceAccordion/XmlPreview.client.vue +++ b/datagouv-components/src/components/ResourceAccordion/XmlPreview.client.vue @@ -20,13 +20,21 @@ : t("L'aperçu n'est pas disponible car la taille du fichier est inconnue. Pour consulter le fichier complet, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + + + {{ 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("Ce fichier XML ne peut pas être prévisualisé, peut-être parce qu'il est hébergé sur un autre site qui ne l'autorise pas. Pour le consulter, téléchargez-le en cliquant sur le bouton bleu ou depuis l'onglet Téléchargements.") }} + {{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }} import('vue3-xml-viewer').then((module) => { @@ -70,36 +78,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 diff --git a/datagouv-components/src/components/ResourceExplorer/ResourceExplorerViewer.vue b/datagouv-components/src/components/ResourceExplorer/ResourceExplorerViewer.vue index 4b5f6ac69..9de8eaad0 100644 --- a/datagouv-components/src/components/ResourceExplorer/ResourceExplorerViewer.vue +++ b/datagouv-components/src/components/ResourceExplorer/ResourceExplorerViewer.vue @@ -267,8 +267,7 @@ import Preview from '../ResourceAccordion/Preview.vue' import DataStructure from '../ResourceAccordion/DataStructure.vue' import Metadata from '../ResourceAccordion/Metadata.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' diff --git a/datagouv-components/src/functions/datasets.ts b/datagouv-components/src/functions/datasets.ts index ebba78794..2592406ad 100644 --- a/datagouv-components/src/functions/datasets.ts +++ b/datagouv-components/src/functions/datasets.ts @@ -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) @@ -14,18 +12,3 @@ export function getDatasetOEmbedHtml(type: string, id: string): string { const staticUrl = constructUrl(config.baseUrl, 'oembed.js') return `
` } - -export function isCommunityResource(resource: Resource | CommunityResource): boolean { - return 'organization' in resource || 'owner' in resource -} - -export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit, 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 -} diff --git a/datagouv-components/src/functions/resources.ts b/datagouv-components/src/functions/resources.ts index 27b11ac87..e028c96ca 100644 --- a/datagouv-components/src/functions/resources.ts +++ b/datagouv-components/src/functions/resources.ts @@ -1,15 +1,19 @@ 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' +const config = useComponentsConfig() + export function getResourceFormatIcon(format: string): Component | null { switch (format?.trim()?.toLowerCase()) { case 'txt': @@ -129,3 +133,47 @@ 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, 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 trustedDomains = config.trustedDomains + const hasPublicCors = allowOrigin === '*' + const hasSpecificCors = allowOrigin + ? trustedDomains.some(domain => allowOrigin.includes(domain)) + : 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' +} diff --git a/nuxt.config.ts b/nuxt.config.ts index eda88a9c4..8c8d72cac 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -52,6 +52,7 @@ export default defineNuxtConfig({ albertApiKey: '', public: { baseUrl: 'https://www.data.gouv.fr/', + trustedDomains: ['data.gouv.fr', 'www.data.gouv.fr'], banner: undefined, title: 'data.gouv.fr',