diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c8947720..f7075b7bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ You can also check the - Features - add link to https://status.lindas.admin.ch/ in footer +- Fixes + - Statistics page no longer fails when the database contains malformed chart + configs; unsafe cube IRIs are filtered out and SPARQL metadata lookups + tolerate endpoint failures ## 6.5.5 - 2026-10-26 diff --git a/app/statistics/iris.ts b/app/statistics/iris.ts new file mode 100644 index 0000000000..21808abace --- /dev/null +++ b/app/statistics/iris.ts @@ -0,0 +1,13 @@ +const SAFE_IRI = /^https?:\/\/[^ <>"{}|\\^`]+$/; + +export const isSafeIri = (iri: string): boolean => { + return SAFE_IRI.test(iri); +}; + +export const chunk = (items: T[], size: number): T[][] => { + return Array.from({ length: Math.ceil(items.length / size) }, (_, i) => + items.slice(i * size, i * size + size) + ); +}; + +export const IRIS_CHUNK_SIZE = 100; diff --git a/app/statistics/prisma.ts b/app/statistics/prisma.ts index 9f51009131..3e5f567abc 100644 --- a/app/statistics/prisma.ts +++ b/app/statistics/prisma.ts @@ -192,29 +192,37 @@ export const fetchChartsMetadata = async () => { jsonb_agg( COALESCE( data ->> 'dataSet', - chart_config_obj ->> 'dataSet', + data -> 'chartConfig' ->> 'dataSet', cubes_obj ->> 'iri' ) ) AS iris, COALESCE( jsonb_agg(chart_config_array ->> 'chartType') FILTER (WHERE chart_config_array ->> 'chartType' IS NOT NULL), - jsonb_build_array(chart_config_obj ->> 'chartType') + jsonb_build_array(data -> 'chartConfig' ->> 'chartType') ) AS chart_types, - layout -> 'type' AS layout_type, - layout -> 'layout' AS layout_subtype + data -> 'layout' -> 'type' AS layout_type, + data -> 'layout' -> 'layout' AS layout_subtype FROM config - LEFT JOIN LATERAL jsonb_array_elements(data -> 'chartConfigs') AS chart_config_array ON true - LEFT JOIN LATERAL jsonb_array_elements(chart_config_array -> 'cubes') AS cubes_obj ON true - LEFT JOIN LATERAL (SELECT data -> 'chartConfig' AS chart_config_obj) AS single_config ON true - LEFT JOIN LATERAL (SELECT data -> 'layout' AS layout) AS layout ON true - GROUP BY day, data, chart_config_obj, layout, layout_subtype + LEFT JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(data -> 'chartConfigs') = 'array' THEN data -> 'chartConfigs' + ELSE '[]'::jsonb + END + ) AS chart_config_array ON true + LEFT JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(chart_config_array -> 'cubes') = 'array' THEN chart_config_array -> 'cubes' + ELSE '[]'::jsonb + END + ) AS cubes_obj ON true + GROUP BY config.id, day ) SELECT day, iris, chart_types, layout_type, layout_subtype FROM filtered_data WHERE NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(iris) AS iri - WHERE NOT (iri LIKE 'http://%' OR iri LIKE 'https://%') + WHERE iri IS NULL OR NOT (iri ~ '^https?://[^ <>"{}|\\^]+$') ); `.then((rows) => { return rows.map((row) => ({ diff --git a/app/statistics/sparql.ts b/app/statistics/sparql.ts index 144c90eefc..2d0fe0e4a6 100644 --- a/app/statistics/sparql.ts +++ b/app/statistics/sparql.ts @@ -2,6 +2,11 @@ import ParsingClient from "sparql-http-client/ParsingClient"; import { DEFAULT_DATA_SOURCE } from "@/domain/data-source"; import { buildLocalizedSubQuery, iriToNode } from "@/rdf/query-utils"; +import { + chunk, + IRIS_CHUNK_SIZE, + isSafeIri, +} from "@/statistics/iris"; const sparqlClient = new ParsingClient({ endpointUrl: DEFAULT_DATA_SOURCE.url, @@ -46,21 +51,51 @@ export const queryCubesMetadata = async ({ cubeIris: string[]; locale: string; }): Promise< - { iri: string; unversionedIri: string; title: string; creator: string }[] + { iri: string; unversionedIri: string; title: string; creator?: string }[] > => { - const query = getCubeMetadataQuery({ cubeIris, locale }); - const results = await sparqlClient.query.select(query, { - operation: "postUrlencoded", + const safeCubeIris = cubeIris.filter(isSafeIri); + const normalize = ( + iri: string, + unversionedIri?: string, + title?: string, + creator?: string + ): { iri: string; unversionedIri: string; title: string; creator?: string } => ({ + iri, + unversionedIri: unversionedIri ?? iri, + title: title ?? iri, + creator, }); - return results.map((result) => { - const iri = result.iri.value; + const queryChunk = async ( + iris: string[] + ): Promise< + { iri: string; unversionedIri: string; title: string; creator?: string }[] + > => { + const query = getCubeMetadataQuery({ cubeIris: iris, locale }); - return { - iri, - unversionedIri: result.unversionedIri.value ?? iri, - title: result.title?.value ?? iri, - creator: result.creator?.value, - }; - }); + try { + const rows = await sparqlClient.query.select(query, { + operation: "postUrlencoded", + }); + + return rows.flatMap((result) => { + const iri = result.iri?.value; + + return typeof iri === "string" + ? [ + normalize( + iri, + result.unversionedIri?.value, + result.title?.value, + result.creator?.value + ), + ] + : []; + }); + } catch { + return iris.map((iri) => normalize(iri)); + } + }; + + return (await Promise.all(chunk(safeCubeIris, IRIS_CHUNK_SIZE).map(queryChunk))).flat(); };