diff --git a/src/pages/data/[[...path]].tsx b/src/pages/data/[[...path]].tsx
new file mode 100644
index 000000000..3d6df8223
--- /dev/null
+++ b/src/pages/data/[[...path]].tsx
@@ -0,0 +1,221 @@
+import type { GetServerSidePropsContext } from 'next'
+
+import { S3 } from '@aws-sdk/client-s3'
+import PropTypes from 'prop-types'
+import Section from '@/components/Section'
+import Breadcrumb from '@/layouts/Breadcrumb'
+import NotFoundPage from '@/app/not-found'
+// import sendToTracker, { getDownloadToEventTracker } from '@/lib/util/analytics-tracker'
+
+import { dataConfig, pageConfig } from './config'
+import Data from './components/Data'
+import {
+ getAlias,
+ getFormatedDate,
+ asyncSendS3,
+ listObjectsRecursively,
+ autorizedPathS3,
+} from './utils'
+
+interface Context extends GetServerSidePropsContext {
+ params: {
+ path: string[]
+ }
+}
+
+interface DataPageProps {
+ title: string
+ path: string[]
+ data: object[]
+ config: object
+ errorCode: number
+ errorMessage: string
+}
+
+const {
+ S3_CONFIG_ACCESS_KEY_ID,
+ S3_CONFIG_SECRET_ACCESS_KEY,
+ S3_CONFIG_REGION,
+ S3_CONFIG_ENDPOINT,
+} = process.env
+
+const { rootLink } = pageConfig
+
+const bucketName = 'prd-ign-mut-ban'
+const rootDir = ['adresse-data']
+const clientS3 = new S3({
+ credentials: {
+ accessKeyId: S3_CONFIG_ACCESS_KEY_ID || '',
+ secretAccessKey: S3_CONFIG_SECRET_ACCESS_KEY || '',
+ },
+ region: S3_CONFIG_REGION || '',
+ endpoint: S3_CONFIG_ENDPOINT || '',
+})
+
+export async function getServerSideProps(context: Context) {
+ const { params, res, req } = context
+ const { path: paramPathRaw = [] } = params
+ const config = dataConfig?.directory.find(({ path }) => path === paramPathRaw.join('/')) || {}
+ const alias = await getAlias(clientS3, bucketName)(rootDir, dataConfig?.alias, paramPathRaw.join('/') || '')
+
+ const paramPath = alias && (new RegExp(`^${alias.parent}/${alias.name}`)).test(paramPathRaw.join('/'))
+ ? paramPathRaw.join('/').replace(new RegExp(`^${alias.parent}/${alias.name}`), `${alias.parent}/${alias.target}`).split('/')
+ : paramPathRaw
+ const dirPath = `${paramPath.join('/')}`
+ const formattedDate = getFormatedDate()
+ const s3ObjectPath = [...rootDir, ...paramPath].join('/')
+
+ try {
+ const s3Head = await clientS3.headObject({
+ Bucket: bucketName,
+ Key: s3ObjectPath,
+ })
+
+ // NO ERROR > PATH IS A FILE
+ try {
+ // sendToTracker(getDownloadToEventTracker({
+ // downloadDataType: `${paramPath[0]}${req?.headers?.range ? ' (Partial)' : ''}`,
+ // downloadFileName: dirPath,
+ // nbDownload: 1
+ // }))
+ await asyncSendS3(clientS3)((req as unknown as Request), res, {
+ params: {
+ ...(req?.headers?.range ? { Range: req.headers.range } : {}),
+ Bucket: bucketName,
+ Key: s3ObjectPath,
+ },
+ fileName: paramPath[paramPath.length - 1],
+ metadata: s3Head,
+ })
+ }
+ catch (err) {
+ console.warn(`[${formattedDate} - ERROR]`, 'File access error:', err)
+ return {
+ props: {
+ errorCode: 502,
+ errorMessage: 'Une erreur est survenue lors de la génération du téléchargement.',
+ },
+ }
+ }
+ }
+ catch {
+ // ERROR > PATH SHOULD BE DIRECTORY
+ const s3DirPath = `${s3ObjectPath}/`
+ const s3Objects = await listObjectsRecursively(clientS3, bucketName)(s3DirPath)
+ if (s3Objects) {
+ const s3data = [
+ ...(s3Objects || [])
+ .filter(({ name }) => autorizedPathS3(name).auth)
+ .map(({ name, path, isDirectory, fileInfo, ...rest }) => ({
+ name,
+ path: alias && alias.parent !== dirPath
+ ? path.replace(
+ (new RegExp(`^${rootDir.join('/')}/${alias.parent}/${alias.target}`)),
+ `${rootDir.join('/')}/${alias.parent}/${alias.name}`
+ )
+ : path,
+ isDirectory,
+ ...(fileInfo ? { fileInfo } : {}),
+ ...rest,
+ })),
+ ]
+ const s3contentDir = [
+ ...s3data,
+ ...(alias && alias.parent === dirPath
+ ? [{
+ ...alias,
+ ...(s3data.find(({ name }) => name === alias.target) || {}),
+ name: alias.name,
+ path: `${s3ObjectPath}/${alias.name}`,
+ }]
+ : []),
+ ].sort(
+ (a, b) => (
+ a.name.localeCompare(b.name)
+ || Number(a.isDirectory) - Number(b.isDirectory)
+ )
+ )
+
+ return {
+ props: {
+ title: ['data', ...paramPath].join('/') || '',
+ path: paramPathRaw || [],
+ data: s3contentDir || [],
+ config,
+ },
+ }
+ }
+
+ // OBJECT DO NOT EXIST > IS UNKNOWN PATH
+ console.warn(`[${formattedDate} - ERROR]`, 'S3 Object - Try access to unknown object:', s3DirPath)
+ res.statusCode = 404
+ return {
+ props: { errorCode: 404 },
+ }
+ }
+
+ return { props: {} }
+}
+
+export default function DataPage({ title, path, data, config, errorCode, errorMessage }: DataPageProps) {
+ const currentDir = ['data', ...(path || [])].slice(-1)
+
+ return errorCode && errorCode !== 200
+ ? (
+ <>
+ {path && (
+
({
+ label: dir,
+ linkProps: { href: `/data/${path.slice(0, index).join('/')}` },
+ })),
+ ]}
+ />
+ )}
+
+ >
+ )
+ : (path
+ ? (
+ <>
+ ({
+ label: dir,
+ linkProps: { href: `/data/${path.slice(0, index).join('/')}` },
+ })),
+ ]}
+ />
+
+
+ >
+ )
+ : null)
+}
+
+DataPage.propTypes = {
+ title: PropTypes.string,
+ path: PropTypes.arrayOf(PropTypes.string),
+ data: PropTypes.arrayOf(PropTypes.object),
+ config: PropTypes.shape({
+ hero: PropTypes.shape({
+ type: PropTypes.string,
+ value: PropTypes.string,
+ }),
+ }),
+ errorCode: PropTypes.number,
+ errorMessage: PropTypes.string,
+}
diff --git a/src/pages/data/components/Data.styled.tsx b/src/pages/data/components/Data.styled.tsx
new file mode 100644
index 000000000..1fb0fe7ca
--- /dev/null
+++ b/src/pages/data/components/Data.styled.tsx
@@ -0,0 +1,16 @@
+'use client'
+
+import styled from 'styled-components'
+
+export const ItemList = styled.ul`
+ padding-top: 0.5rem;
+`
+
+export const Item = styled.li`
+ display: block;
+`
+
+export const ParentDirName = styled.span.attrs({ className: 'fr-link--icon-left fr-icon-folder-2-line' })`
+ display: inline-block;
+ padding: 0 0.5em;
+`
diff --git a/src/pages/data/components/Data.tsx b/src/pages/data/components/Data.tsx
new file mode 100644
index 000000000..1852fdb30
--- /dev/null
+++ b/src/pages/data/components/Data.tsx
@@ -0,0 +1,164 @@
+import { useMemo } from 'react'
+import Link from 'next/link'
+import PropTypes from 'prop-types'
+
+import { dateFormatOptions } from '../config'
+import DataEntry from './DataEntry'
+import {
+ ItemList,
+ Item,
+ ParentDirName,
+} from './Data.styled'
+
+interface DataProps {
+ root: {
+ href: string
+ label: string
+ }
+ path: string[]
+ data: any[]
+ config: {
+ groups?: {
+ name: string
+ rule: string
+ description: string
+ }[]
+ }
+}
+
+interface DataEntry {
+ name: string
+ isDirectory: boolean
+ fileInfo: {
+ date: string
+ size: number
+ }
+}
+
+const sortDirectory = (a: DataEntry, b: DataEntry) => {
+ if (a.isDirectory === b.isDirectory) {
+ return a.name.localeCompare(b.name)
+ }
+ return a.isDirectory ? -1 : 1
+}
+
+function Data({ root, path = [], data: dataRaw = [], config = {} }: DataProps) {
+// function Data({ root, path: _path = [], data: dataRaw = [], config = {} }: DataProps) {
+ // const path = ['data', ..._path]
+ // const currentDir = [...path].slice(-1)
+ // const parentsDir = [...path].slice(0, -1)
+ const currentDir = ['data', ...path].slice(-1)
+ const parentsDir = ['data', ...path].slice(0, -1)
+ const parentDir = parentsDir.slice(-1)[0]
+ const sectionsConfig = useMemo(() => Object.fromEntries((config.groups || []).map(group => [group.name, group])), [config.groups])
+ const { __: dataDefault, ...dateSections } = useMemo>(() => {
+ const sections = Object.fromEntries((config.groups || []).map(group => [group.name, []]))
+
+ const dataSections = dataRaw.reduce((acc, entry) => {
+ const fileDate = entry.fileInfo?.date && new Date(entry.fileInfo.date)
+ const { year, month, day } = /^(?\d{4})-(?\d{2})-(?\d{2})$/.exec(entry.name)?.groups || {}
+ const humanDateDirName = year && month && day && (
+ new Date(+year, (+month - 1), +day)
+ ).toLocaleString('fr-FR', dateFormatOptions.dateFormatOptionsLongDate)
+
+ const finalEntry = {
+ ...entry,
+ humanDirName: humanDateDirName,
+ fileDate,
+ }
+
+ const section = config.groups?.find(({ rule }: { rule: string }) => rule && (new RegExp(rule)).test(entry.name))
+ acc[section ? section.name : '__'].push(finalEntry)
+
+ return acc
+ }, (sections.__ ? sections : { __: [], ...sections }))
+ return dataSections
+ }, [dataRaw, config.groups])
+
+ console.log('path >>>', path)
+ console.log('parentDir >>>', parentDir)
+
+ return (
+
+ {/*
({
+ label: dir,
+ linkProps: { href: `/data/${path.slice(0, index).join('/')}` },
+ })),
+ ]}
+ /> */}
+
+ {/* Breadcrumb : */}
+ {/*
+ {root
+ ? {root.label}{' '}
+ : null}
+ {path.length > 0 && (
+ parentsDir.map((dir, index) => (
+ > {dir}{' '}
+ ))
+ )}
+ > {currentDir}{' '}
+
*/}
+
+ {parentDir && }
+
+ {(!dataDefault || dataDefault.length === 0) && (!dateSections || Object.keys(dateSections).length === 0) && (
+ Ce répertoire est vide
+ )}
+
+
+ {dataDefault?.length > 0 && (
+
+ {
+ dataDefault.sort(sortDirectory).map(entry => (
+
+ ))
+ }
+
+ )}
+
+ {dateSections && Object.entries(dateSections).map(([sectionName, sectionEntries]) => {
+ const { description } = sectionsConfig[sectionName] || {}
+ return (
+
+
+
{sectionName}
+ {description &&
{description}
}
+
+ {
+ sectionEntries.sort(sortDirectory).map(entry => (
+
+ ))
+ }
+
+
+ )
+ }
+ )}
+
+
+ )
+}
+
+Data.propTypes = {
+ root: PropTypes.shape({
+ href: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ }),
+ path: PropTypes.array.isRequired,
+ data: PropTypes.array.isRequired,
+ config: PropTypes.object,
+}
+
+export default Data
diff --git a/src/pages/data/components/DataEntry.styled.tsx b/src/pages/data/components/DataEntry.styled.tsx
new file mode 100644
index 000000000..6cbe93be0
--- /dev/null
+++ b/src/pages/data/components/DataEntry.styled.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import styled from 'styled-components'
+import Link from 'next/link'
+
+const fileTypeIcons: { [key: string]: string } = {
+ dir: 'fr-icon-folder-2-fill',
+ pdf: 'fr-icon-file-pdf-line',
+ json: 'ri-file-code-line',
+ file: 'fr-icon-file-line',
+ download: 'fr-icon-file-download-line',
+ txt: 'fr-icon-file-text-line',
+ md: 'fr-icon-file-text-line',
+ gz: 'ri-file-zip-line',
+ zip: 'ri-file-zip-line',
+ tar: 'ri-file-zip-line',
+ rar: 'ri-file-zip-line',
+}
+
+const getIcon = (fileType?: string) => fileType && fileTypeIcons[fileType] ? fileTypeIcons[fileType] : fileTypeIcons.download
+
+export const ExplorerLink = styled(Link)`
+ display: flex;
+ text-decoration: none;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-top: 0.3rem;
+ border-radius: 0.25rem;
+ padding: 0.1rem 0.25rem;
+
+ &[href] {
+ background: none;
+ background-color: #ffffffff;
+ transition: background-color 0.25s ease-in-out;
+ }
+ &[href]:hover,
+ &[href]:focus {
+ background-color: #eeeeeeff;
+ }
+
+ @media screen and (min-width: ${({ theme }) => theme.breakpoints.md}) {
+ max-width: 35em;
+ flex-direction: row;
+ }
+`
+
+export const ExplorerLinkLabel = styled.span.attrs<{ $fileType?: string }>(({ $fileType }) => ({
+ className: `fr-link--icon-left ${getIcon($fileType)}`,
+}))`
+ display: inline;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ padding: 0 0;
+ color: var(--text-action-high-blue-france);
+
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ flex: 1;
+
+ &.unknown-type {
+ padding-left: 1.5em;
+ }
+`
+export const ExplorerLinkLabelWrapper = styled.span`
+ display: flex;
+ flex-direction: column;
+
+ @media screen and (min-width: ${({ theme }) => theme.breakpoints.md}) {
+ flex-direction: row;
+ }
+`
+export const ExplorerLinkLabelHuman = styled.span`
+ padding-right: .5em;
+ color: #777;
+
+ &:first-letter {
+ text-transform: uppercase;
+ }
+
+ @media screen and (min-width: ${({ theme }) => theme.breakpoints.md}) {
+ padding-left: .5em;
+ }
+`
+export const ExplorerLinkLabelText = styled.span<{ $withHumanDateDirName: boolean }>`
+ padding-right: .5em;
+ text-decoration: underline;
+
+ ${({ $withHumanDateDirName }) => $withHumanDateDirName && `
+ display: inline-block;
+ width: 7em;
+ `}
+`
+export const ExplorerLinkLabelSize = styled.span`
+ display: flex;
+ align-self: flex-end;
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ align-items: end;
+ line-height: 1em;
+ padding-bottom: 0.2em;
+ font-size: 0.8em;
+ font-style: italic;
+ color: #777;
+`
+export const ExplorerLinkDatetime = styled.span`
+ display: flex;
+ min-width: 5em;
+ align-items: end;
+ line-height: 1em;
+ padding-bottom: 0.2em;
+ font-size: 0.8em;
+ color: #777;
+ white-space: nowrap;
+ gap: 0.5em;
+`
+export const ExplorerLinkDate = styled.span`
+`
+export const ExplorerLinkTime = styled.span`
+ min-width: 4.2em;
+`
diff --git a/src/pages/data/components/DataEntry.tsx b/src/pages/data/components/DataEntry.tsx
new file mode 100644
index 000000000..ad8da4c3c
--- /dev/null
+++ b/src/pages/data/components/DataEntry.tsx
@@ -0,0 +1,126 @@
+import { Fragment, ReactNode } from 'react'
+import Link from 'next/link'
+import PropTypes from 'prop-types'
+import { filesize } from 'filesize'
+
+// import theme from '@/styles/theme'
+
+import dateFormatOptions from '../config/date-format-options'
+
+import {
+ ExplorerLink,
+ ExplorerLinkLabel,
+ ExplorerLinkLabelWrapper,
+ ExplorerLinkLabelHuman,
+ ExplorerLinkLabelText,
+ ExplorerLinkLabelSize,
+ ExplorerLinkDatetime,
+ ExplorerLinkDate,
+ ExplorerLinkTime,
+} from './DataEntry.styled'
+
+const theme = {
+ breakPoints: {
+ desktop: '1024px',
+ },
+}
+
+const translatedName: { [key: string]: string } = {
+ 'latest': 'Dernière(s) version(s) en date',
+ 'weekly': 'Version(s) hebdomadaire(s)',
+ 'ban/adresses-odbl': 'Repertoire déprecié',
+ 'ban/adresses-odbl/latest': 'Dernière(s) version(s) en date',
+}
+
+const translatedQuarter: { [key: string]: string } = {
+ T1: '1er trimestre',
+ T2: '2ème trimestre',
+ T3: '3ème trimestre',
+ T4: '4ème trimestre',
+}
+
+interface DataEntryProps {
+ entry: {
+ name: keyof typeof translatedName
+ isDirectory: boolean
+ fileInfo: {
+ date: string
+ size: number
+ }
+ }
+ path: string[]
+}
+
+const getFileType = (isDir: boolean, name: string) => {
+ if (isDir) return 'dir'
+ return name.match(/\..*$/)?.[0]?.split('.')?.pop()
+}
+
+function DataEntry({ entry: { name, isDirectory, fileInfo }, path }: DataEntryProps) {
+ const fileDate = fileInfo?.date && new Date(fileInfo.date)
+ const dateSearchRegExp = /^(?\d{4})-(((?\d{2})-(?\d{2}))|(?T[1-4]))$/
+ const { year, quarter, month, day } = (dateSearchRegExp.exec(`${name}`)?.groups || {}) as {
+ year?: string
+ quarter?: keyof typeof translatedQuarter
+ month?: string
+ day?: string
+ }
+ const humanDateDirName = year && month && day
+ // ? (new Date(+year, (+month - 1), +day)).toLocaleString('fr-FR', {
+ // timeZone: 'Europe/Paris',
+ // weekday: 'long',
+ // year: 'numeric',
+ // month: 'long',
+ // day: 'numeric',
+ // })
+ ? (new Date(+year, (+month - 1), +day)).toLocaleString('fr-FR', dateFormatOptions.dateFormatOptionsLongDate)
+ : year && quarter && `${translatedQuarter[quarter]} ${year}`
+ const humanDirName = humanDateDirName ? `export du ${humanDateDirName}` : translatedName[([...path, name].join('/'))] || translatedName[name]
+
+ return (
+
+
+
+
+ {name}
+ {humanDirName && {humanDirName}}
+
+
+ {(fileInfo?.size && filesize(fileInfo.size, { base: 10 }) as ReactNode)}
+
+ {fileDate && (
+
+ {fileDate?.toLocaleString('fr-FR', dateFormatOptions.dateFormatOptionsDate)}
+ {fileDate?.toLocaleString('fr-FR', dateFormatOptions.dateFormatOptionsTime)}
+
+ )}
+
+
+ )
+}
+
+DataEntry.propTypes = {
+ entry: PropTypes.shape(
+ {
+ name: PropTypes.string.isRequired,
+ isDirectory: PropTypes.bool.isRequired,
+ fileInfo: PropTypes.shape(
+ {
+ translatedName: PropTypes.string,
+ date: PropTypes.string,
+ hash: PropTypes.string,
+ size: PropTypes.number,
+ }
+ ),
+ }
+ ).isRequired,
+ path: PropTypes.array.isRequired,
+}
+
+export default DataEntry
diff --git a/src/pages/data/components/index.ts b/src/pages/data/components/index.ts
new file mode 100644
index 000000000..fabeb12eb
--- /dev/null
+++ b/src/pages/data/components/index.ts
@@ -0,0 +1,10 @@
+export { default } from './Data'
+// export {
+// getAlias,
+// getFormatedDate,
+// asyncSendS3,
+// listObjectsRecursively,
+// autorizedPathS3,
+// } from '../utils/helper'
+
+// export { default as config } from './data-config.json'
diff --git a/src/pages/data/config/data-config.json b/src/pages/data/config/data-config.json
new file mode 100644
index 000000000..e3bea4ecc
--- /dev/null
+++ b/src/pages/data/config/data-config.json
@@ -0,0 +1,129 @@
+{
+ "directory": [
+ {
+ "path": "adresses-cadastre",
+ "hero": {
+ "value": "Ces fichiers sont produit depuis les données fournis par la DGFiP"
+ },
+ "groups": [
+ {
+ "name": "Données archivées",
+ "direction": "desc",
+ "rule": "^\\d{4}$"
+ },
+ {
+ "name": "__"
+ }
+ ]
+ },
+ {
+ "path": "ban/adresses",
+ "hero": {
+ "value": "Retrouvez ici toute les adresses de la Base Adresse Nationale (BAN)"
+ },
+ "groups": [
+ {
+ "name": "Données archivées",
+ "description": "Données archivées de la BAN",
+ "direction": "desc",
+ "rule": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ {
+ "name": "__"
+ }
+ ]
+ },
+ {
+ "path": "ban/adresses-odbl",
+ "hero": {
+ "type": "warning",
+ "value": "Ce répertoire est obsolète et redirige vers le répertoire adresses"
+ },
+ "groups": [
+ {
+ "name": "Données archivées",
+ "description": "Données archivées de la BAN",
+ "direction": "desc",
+ "rule": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ {
+ "name": "__"
+ }
+ ]
+ },
+ {
+ "path": "ban/export-api-gestion",
+ "hero": {
+ "type": "warning",
+ "value": "Ce répertoire est obsolète et redirige vers le répertoire adresses"
+ },
+ "groups": [
+ {
+ "name": "Données archivées",
+ "description": "Données archivées de la BAN",
+ "direction": "desc",
+ "rule": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ {
+ "name": "__"
+ }
+ ]
+ }
+ ],
+ "alias": [
+ {
+ "parent": "adresses-cadastre",
+ "name": "latest",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^\\d{4}$"]
+ }
+ },
+ {
+ "parent": "adresses-ftth",
+ "name": "latest",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^\\d{4}-T\\d$"]
+ }
+ },
+ {
+ "parent": "ban",
+ "name": "adresses-odbl",
+ "target": "adresses",
+ "comment": "Ce repertoire est obsolète et redirige vers les données du répertoire `adresses`"
+ },
+ {
+ "parent": "ban/adresses",
+ "name": "weekly",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^\\d{4}-\\d{2}-\\d{2}$"]
+ }
+ },
+ {
+ "parent": "ban/export-api-gestion",
+ "name": "latest",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^\\d{4}-\\d{2}-\\d{2}$"]
+ }
+ },
+ {
+ "parent": "contours-administratifs",
+ "name": "latest",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^\\d{4}$"]
+ }
+ },
+ {
+ "parent": "fantoir",
+ "name": "latest",
+ "target": {
+ "action": "getLatestFromRegExp",
+ "params": ["^fantoir-\\d{4}-\\d{2}\\.gz$"]
+ }
+ }
+ ]
+}
diff --git a/src/pages/data/config/date-format-options.ts b/src/pages/data/config/date-format-options.ts
new file mode 100644
index 000000000..65a5b277f
--- /dev/null
+++ b/src/pages/data/config/date-format-options.ts
@@ -0,0 +1,31 @@
+const TIME_ZONE = 'Europe/Paris'
+
+interface DateFormatOptions {
+ dateFormatOptionsLongDate: Intl.DateTimeFormatOptions
+ dateFormatOptionsDate: Intl.DateTimeFormatOptions
+ dateFormatOptionsTime: Intl.DateTimeFormatOptions
+}
+
+const dateFormatOptions: DateFormatOptions = {
+ dateFormatOptionsLongDate: {
+ timeZone: TIME_ZONE,
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ },
+ dateFormatOptionsDate: {
+ timeZone: TIME_ZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ },
+ dateFormatOptionsTime: {
+ timeZone: TIME_ZONE,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ },
+}
+
+export default dateFormatOptions
diff --git a/src/pages/data/config/index.ts b/src/pages/data/config/index.ts
new file mode 100644
index 000000000..63bc5f708
--- /dev/null
+++ b/src/pages/data/config/index.ts
@@ -0,0 +1,3 @@
+export { default as dateFormatOptions } from './date-format-options'
+export { default as dataConfig } from './data-config.json'
+export { default as pageConfig } from './page-config.json'
diff --git a/src/pages/data/config/page-config.json b/src/pages/data/config/page-config.json
new file mode 100644
index 000000000..2806f6166
--- /dev/null
+++ b/src/pages/data/config/page-config.json
@@ -0,0 +1,6 @@
+{
+ "rootLink": {
+ "href": "/outils/telechargements",
+ "label": "Télécharger les données de la BAN"
+ }
+}
diff --git a/src/pages/data/utils/helper.ts b/src/pages/data/utils/helper.ts
new file mode 100644
index 000000000..e53240bea
--- /dev/null
+++ b/src/pages/data/utils/helper.ts
@@ -0,0 +1,195 @@
+import type AWS from '@aws-sdk/client-s3'
+
+interface S3ObjectItem {
+ Key: string
+ LastModified: Date
+ ETag: string
+ Size: number
+}
+
+interface S3ObjectResponse {
+ Contents: S3ObjectItem[]
+ CommonPrefixes: { Prefix: string }[]
+ IsTruncated: boolean
+ NextContinuationToken: string
+ KeyCount: number
+}
+
+interface DirItem {
+ name: string
+ path: string
+ isDirectory: boolean
+}
+interface ObjectItem extends DirItem {
+ fileInfo?: {
+ date: string
+ hash: string
+ size: number
+ }
+}
+
+interface AsyncSendS3Params {
+ Bucket: string
+ Key: string
+}
+
+interface AsyncSendS3Options {
+ params: AsyncSendS3Params
+ fileName: string
+ metadata?: AWS.HeadObjectCommandOutput
+}
+
+interface AliasAction {
+ [key: string]: (s3Objects: ObjectItem[], ...regExpStringAndRest: string[]) => ObjectItem | undefined
+}
+interface Aliase {
+ parent: string
+ name: string
+ target?: string | {
+ action: string
+ params: string[]
+ }
+}
+
+export const getFormatedDate = () => {
+ const date = new Date()
+ const dateFormatOptions = {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }
+ return new Intl
+ .DateTimeFormat('fr', (dateFormatOptions as Intl.DateTimeFormatOptions))
+ .format(date)
+ .replace(/,/g, '\'')
+ .replace(/ /g, ' ')
+}
+
+export const autorizedPathS3 = (path: string) => {
+ const isPathIsVisibleFile = !path.startsWith('.')
+
+ return {
+ path,
+ auth: isPathIsVisibleFile,
+ }
+}
+
+export const asyncSendS3 = (clientS3: AWS.S3) =>
+ (req: Request, res: any, options: AsyncSendS3Options) =>
+ new Promise(
+ (resolve, reject) => {
+ console.log('res >>>', res)
+ // res.attachment(params.key)
+ res.setHeader('Content-Length', options.metadata?.ContentLength)
+ res.setHeader('Content-Disposition', `attachment; filename="${options.fileName}"`)
+
+ clientS3.getObject(options.params)
+ .then(({ Body, AcceptRanges, ContentRange, ContentLength }) => {
+ if (AcceptRanges && ContentRange) {
+ res.setHeader('Accept-Ranges', AcceptRanges)
+ res.setHeader('Content-Range', ContentRange)
+ res.setHeader('Content-Length', ContentLength)
+ res.status(206)
+ }
+ (Body as NodeJS.ReadableStream)
+ ?.on('error', (err: Error) => {
+ const formattedDate = getFormatedDate()
+ console.warn(`[${formattedDate} - ERROR]`, 'File access error:', err)
+ reject(err)
+ })
+ ?.on('end', () => {
+ resolve()
+ })
+ .pipe(res)
+ })
+ .catch((err: Error) => console.error(err))
+ }
+ )
+
+// Reccursivly GET S3 Objects
+export const listObjectsRecursively = (
+ clientS3: AWS.S3,
+ bucketName: string
+) =>
+ async function listObjects(prefix: string, continuationToken?: string): Promise<(ObjectItem)[] | undefined> {
+ const params = {
+ Bucket: bucketName,
+ Prefix: prefix || undefined,
+ Delimiter: '/',
+ ContinuationToken: continuationToken,
+ }
+
+ try {
+ const data = (await clientS3.listObjectsV2(params)) as S3ObjectResponse
+
+ if (data.KeyCount <= 0) {
+ return undefined
+ }
+
+ const filesList: ObjectItem[] = (data.Contents || []).map(({ Key, LastModified, ETag, Size }) => ({
+ name: Key.replace(new RegExp(`^${prefix}`), ''),
+ path: Key,
+ fileInfo: {
+ date: LastModified.toString(),
+ hash: ETag,
+ size: Size,
+ },
+ isDirectory: false,
+ }))
+ const dirsList = (data.CommonPrefixes || []).map(({ Prefix }) => ({
+ name: Prefix.replace(new RegExp(`^${prefix}`), '').replace(/\/$/, ''),
+ path: Prefix,
+ isDirectory: true,
+ }))
+ const nextList: ObjectItem[] = ((data.IsTruncated) && await listObjects(prefix, data.NextContinuationToken)) || []
+ return [...filesList, ...dirsList, ...nextList]
+ }
+ catch (err) {
+ console.error('Erreur lors de la récupération de la liste des objets :', err)
+ }
+ }
+
+const aliasAction: AliasAction = {
+ getLatestFromRegExp: (s3Objects, regExpString) => s3Objects
+ .filter(({ name }) => ((new RegExp(regExpString)).test(name)))
+ .sort((a, b) => new Date(b.name).getTime() - new Date(a.name).getTime())[0],
+}
+
+export const getAlias = (clientS3: AWS.S3, bucketName: string) => async (rootDir: string[], aliasesRaw: Aliase[], currentPath: string) => {
+ const aliases = aliasesRaw && (
+ [...aliasesRaw]
+ .sort((a, b) => `${b.parent}${b.name}`.localeCompare(`${a.parent}${a.name}`))
+ .reverse()
+ )
+ const alias = aliases?.find(({ parent }) => (new RegExp(`^${parent}(/|$)`)).test(currentPath)) || null
+
+ if (!alias) {
+ return null
+ }
+
+ if (typeof alias.target === 'string') {
+ return alias
+ }
+
+ if (typeof alias.target === 'object' && alias.target.action) {
+ const s3ObjectPath = [...rootDir, ...alias.parent.replace(/\/$/, '').split('/')].join('/')
+ const s3DirPath = `${s3ObjectPath}/`
+ const s3Objects = await listObjectsRecursively(clientS3, bucketName)(s3DirPath)
+ const s3data = s3Objects
+ ? [...(s3Objects || []).filter(({ name }) => autorizedPathS3(name).auth)]
+ : []
+
+ const paraXXX = alias.target?.params
+ const targetAlias = aliasAction[alias.target.action](s3data, ...((alias.target?.params) || []))
+
+ return ({
+ ...alias,
+ target: targetAlias?.name,
+ } as Aliase)
+ }
+
+ throw new Error('Alias target should be a string, or an object with an action key')
+}
diff --git a/src/pages/data/utils/index.ts b/src/pages/data/utils/index.ts
new file mode 100644
index 000000000..bcfc81d77
--- /dev/null
+++ b/src/pages/data/utils/index.ts
@@ -0,0 +1,7 @@
+export {
+ getAlias,
+ getFormatedDate,
+ asyncSendS3,
+ listObjectsRecursively,
+ autorizedPathS3,
+} from './helper'