Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -37,9 +37,10 @@
import('./assets/css/fonts-without-marianne.css')
}

app.vueApp.use(datagouv, {

Check failure on line 40 in app.vue

View workflow job for this annotation

GitHub Actions / quality

No overload matches this call.
name: runtimeConfig.public.title,
baseUrl: runtimeConfig.public.baseUrl,
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 @@ -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.")
}}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'cors'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ 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).") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'network'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ 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.") }}</span>
<span>{{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error"
Expand All @@ -54,8 +62,8 @@ import { RiErrorWarningLine } from '@remixicon/vue'
import { useComponentsConfig } from '../../config'
import SimpleBanner from '../SimpleBanner.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 @@ -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
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 @@ -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.")
}}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'cors'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ 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).") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'network'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="flex-none size-6" />
<span>{{ 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.") }}</span>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error"
Expand All @@ -69,8 +77,8 @@ import { RiErrorWarningLine } from '@remixicon/vue'
import SimpleBanner from '../SimpleBanner.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'

const PDF = defineAsyncComponent(() =>
import('pdf-vue3').then((module) => {
Expand All @@ -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' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'cors'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ 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).") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error === 'network'"
type="warning"
class="flex items-center space-x-2"
>
<RiErrorWarningLine class="shrink-0 size-6" />
<span>{{ 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.") }}</span>
<span>{{ t("Impossible de charger l'aperçu. Vérifiez votre connexion ou l'accessibilité du fichier.") }}</span>
</SimpleBanner>
<SimpleBanner
v-else-if="error"
Expand All @@ -46,9 +54,9 @@ import { RiErrorWarningLine } from '@remixicon/vue'
import { useComponentsConfig } from '../../config'
import SimpleBanner from '../SimpleBanner.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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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
}
50 changes: 49 additions & 1 deletion datagouv-components/src/functions/resources.ts
Original file line number Diff line number Diff line change
@@ -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':
Expand Down Expand Up @@ -129,3 +133,47 @@
}
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 trustedDomains = config.trustedDomains

Check failure on line 164 in datagouv-components/src/functions/resources.ts

View workflow job for this annotation

GitHub Actions / quality

Property 'trustedDomains' does not exist on type 'PluginConfig'.
const hasPublicCors = allowOrigin === '*'
const hasSpecificCors = allowOrigin
? trustedDomains.some(domain => allowOrigin.includes(domain))

Check failure on line 167 in datagouv-components/src/functions/resources.ts

View workflow job for this annotation

GitHub Actions / quality

Parameter 'domain' implicitly has an 'any' type.
: 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 @@ -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',
Expand Down
Loading