diff --git a/src/app/outils/telechargements/page.tsx b/src/app/outils/telechargements/page.tsx index 1cae715ad..7da78eb43 100644 --- a/src/app/outils/telechargements/page.tsx +++ b/src/app/outils/telechargements/page.tsx @@ -44,6 +44,16 @@ export default async function PageDownloadBan() { title="Télécharger" id="download" theme="primary" + footer={( +

+ + Naviguez à travers toutes les données téléchargeables mises à disposition par la BAN + +

+ )} >
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 &&
Précédent ({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'