import type { HttpContext } from '@adonisjs/core/http'; // import { RequestContract } from '@ioc:Adonis/Core/Request'; import { Request } from '@adonisjs/core/http'; import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js'; import { create } from 'xmlbuilder2'; import dayjs, { Dayjs } from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import timezone from 'dayjs/plugin/timezone.js'; import { readFileSync } from 'fs'; import { StatusCodes } from 'http-status-codes'; import SaxonJS from 'saxon-js'; // import { Xslt, xmlParse } from 'xslt-processor' import { OaiErrorCodes, OaiModelError } from '#app/exceptions/OaiErrorCodes'; import { OaiModelException, BadOaiModelException } from '#app/exceptions/OaiModelException'; import Dataset from '#models/dataset'; import Collection from '#models/collection'; import { getDomain, preg_match } from '#app/utils/utility-functions'; import XmlModel from '#app/Library/XmlModel'; import logger from '@adonisjs/core/services/logger'; import ResumptionToken from '#app/Library/Oai/ResumptionToken'; // import Config from '@ioc:Adonis/Core/Config'; import config from '@adonisjs/core/services/config'; // import { inject } from '@adonisjs/fold'; import { inject } from '@adonisjs/core'; // import { TokenWorkerContract } from "MyApp/Models/TokenWorker"; import TokenWorkerContract from '#library/Oai/TokenWorkerContract'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; interface XslTParameter { [key: string]: any; } interface Dictionary { [index: string]: string; } interface PagingParameter { cursor: number; totalLength: number; start: number; nextDocIds: number[]; activeWorkIds: number[]; metadataPrefix: string; queryParams: Object; } @inject() export default class OaiController { private deliveringDocumentStates = ['published', 'deleted']; private sampleRegEx = /^[A-Za-zäüÄÜß0-9\-_.!~]+$/; private xsltParameter: XslTParameter; private firstPublishedDataset: Dataset | null; /** * Holds xml representation of document information to be processed. * * @var xmlbuilder.XMLDocument | null Defaults to null. */ private xml: XMLBuilder; private proc; constructor(public tokenWorker: TokenWorkerContract) { // Load the XSLT file this.proc = readFileSync('public/assets2/datasetxml2oai.sef.json'); dayjs.extend(utc); dayjs.extend(timezone); } public async index({ response, request }: HttpContext): Promise { this.xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, ''); // this.proc = new XSLTProcessor(); // const stylesheet = readFileSync(__dirname + "/datasetxml2oai.sef.json"); const xsltParameter: XslTParameter = (this.xsltParameter = {}); let oaiRequest: Dictionary = {}; if (request.method() === 'POST') { oaiRequest = request.body(); } else if (request.method() === 'GET') { oaiRequest = request.qs(); } else { xsltParameter['oai_error_code'] = 'unknown'; xsltParameter['oai_error_message'] = 'Only POST and GET methods are allowed for OAI-PMH.'; } let earliestDateFromDb; // const oaiRequest: OaiParameter = request.body; try { this.firstPublishedDataset = await Dataset.earliestPublicationDate(); this.firstPublishedDataset != null && (earliestDateFromDb = this.firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")); this.xsltParameter['earliestDatestamp'] = earliestDateFromDb; // start the request await this.handleRequest(oaiRequest, request); } catch (error) { if (error instanceof OaiModelException) { const code = error.oaiCode; let oaiErrorCode: string | undefined = 'Unknown oai error code ' + code; if (OaiModelError.has(error.oaiCode) && OaiModelError.get(code) !== undefined) { oaiErrorCode = OaiModelError.get(error.oaiCode); } this.xsltParameter['oai_error_code'] = oaiErrorCode; this.xsltParameter['oai_error_message'] = error.message; } else { // // return next(error); // passing to default express middleware error handler this.xsltParameter['oai_error_code'] = 'unknown'; this.xsltParameter['oai_error_message'] = 'An internal error occured.'; } } const xmlString = this.xml.end({ prettyPrint: true }); let xmlOutput; // = xmlString; try { const result = await SaxonJS.transform({ // stylesheetFileName: `${config.TMP_BASE_DIR}/data-quality/rules/iati.sef.json`, stylesheetText: this.proc, destination: 'serialized', // sourceFileName: sourceFile, sourceText: xmlString, stylesheetParams: xsltParameter, // logLevel: 10, }); xmlOutput = result.principalResult; } catch (error) { return response.status(500).json({ message: 'An error occurred while creating the user', error: error.message, }); } response .header('Content-Type', 'application/xml; charset=utf-8') .header('Access-Control-Allow-Origin', '*') .header('Access-Control-Allow-Methods', 'GET,POST'); response.status(StatusCodes.OK).send(xmlOutput); } protected async handleRequest(oaiRequest: Dictionary, request: Request) { // Setup stylesheet // $this->loadStyleSheet('datasetxml2oai-pmh.xslt'); // Set response time const now: Dayjs = dayjs(); this.xsltParameter['responseDate'] = now.format('YYYY-MM-DDTHH:mm:ss[Z]'); this.xsltParameter['unixTimestamp'] = now.unix(); // set OAI base url const baseDomain = process.env.OAI_BASE_DOMAIN || 'localhost'; this.xsltParameter['baseURL'] = baseDomain + '/oai'; this.xsltParameter['repURL'] = request.protocol() + '://' + request.hostname(); this.xsltParameter['downloadLink'] = request.protocol() + '://' + request.hostname() + '/file/download/'; this.xsltParameter['doiLink'] = 'https://doi.org/'; this.xsltParameter['doiPrefix'] = 'info:eu-repo/semantics/altIdentifier/doi/'; if (oaiRequest['verb']) { const verb = oaiRequest['verb']; this.xsltParameter['oai_verb'] = verb; if (verb === 'Identify') { this.handleIdentify(); } else if (verb === 'ListMetadataFormats') { this.handleListMetadataFormats(); } else if (verb == 'GetRecord') { await this.handleGetRecord(oaiRequest); } else if (verb == 'ListRecords') { // Get browser fingerprint from the request: const browserFingerprint = this.getBrowserFingerprint(request); await this.handleListRecords(oaiRequest, browserFingerprint); } else if (verb == 'ListIdentifiers') { // Get browser fingerprint from the request: const browserFingerprint = this.getBrowserFingerprint(request); await this.handleListIdentifiers(oaiRequest, browserFingerprint); } else if (verb == 'ListSets') { await this.handleListSets(); } else { this.handleIllegalVerb(); } } else { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The verb provided in the request is illegal.', OaiErrorCodes.BADVERB, ); } } protected handleIdentify() { // Get configuration values from environment or a dedicated configuration service const email = process.env.OAI_EMAIL ?? 'repository@geosphere.at'; const repositoryName = process.env.OAI_REPOSITORY_NAME ?? 'Tethys RDR'; const repIdentifier = process.env.OAI_REP_IDENTIFIER ?? 'tethys.at'; const sampleIdentifier = `oai:${repIdentifier}:1`; // Dataset::earliestPublicationDate()->server_date_published->format('Y-m-d\TH:i:s\Z') : null; // earliestDateFromDb!= null && (this.xsltParameter['earliestDatestamp'] = earliestDateFromDb?.server_date_published); // set parameters for oai-pmh.xslt this.xsltParameter['email'] = email; this.xsltParameter['repositoryName'] = repositoryName; this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['sampleIdentifier'] = sampleIdentifier; // $this->proc->setParameter('', 'earliestDatestamp', $earliestDateFromDb); this.xml.root().ele('Datasets'); } protected handleListMetadataFormats() { this.xml.root().ele('Datasets'); } protected async handleListSets() { const repIdentifier = 'tethys.at'; this.xsltParameter['repIdentifier'] = repIdentifier; const datasetElement = this.xml.root().ele('Datasets'); const sets: { [key: string]: string } = { 'open_access': 'Set for open access licenses', 'openaire_data': 'OpenAIRE', 'doc-type:ResearchData': 'Set for document type ResearchData', ...(await this.getSetsForDatasetTypes()), ...(await this.getSetsForCollections()), // ... await this.getSetsForProjects(), } as Dictionary; for (const [key, value] of Object.entries(sets)) { const setElement = datasetElement.ele('Rdr_Sets'); setElement.att('Type', key); setElement.att('TypeName', value); } } protected async handleGetRecord(oaiRequest: Dictionary) { const repIdentifier = 'tethys.at'; this.xsltParameter['repIdentifier'] = repIdentifier; // Validate that required parameter exists early if (!('identifier' in oaiRequest)) { throw new BadOaiModelException('The prefix of the identifier argument is unknown.'); } // Validate and extract the dataset identifier from the request const dataId = this.validateAndGetIdentifier(oaiRequest); // Retrieve dataset with associated XML cache and collection roles const dataset = await Dataset.query() .where('publish_id', dataId) .preload('xmlCache') .preload('collections', (builder) => { builder.preload('collectionRole'); }) .first(); if (!dataset || !dataset.publish_id) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The value of the identifier argument is unknown or illegal in this repository.', OaiErrorCodes.IDDOESNOTEXIST, ); } // Validate and set the metadata prefix parameter const metadataPrefix = this.validateAndGetMetadataPrefix(oaiRequest); this.xsltParameter['oai_metadataPrefix'] = metadataPrefix; // Ensure that the dataset is in an exportable state this.validateDatasetState(dataset); // Build the XML for the dataset record and add it to the root node const datasetNode = this.xml.root().ele('Datasets'); await this.createXmlRecord(dataset, datasetNode); } protected async handleListIdentifiers(oaiRequest: Dictionary, browserFingerprint: string) { if (!this.tokenWorker.isConnected) { await this.tokenWorker.connect(); } const maxIdentifier: number = config.get('oai.max.listidentifiers', 100); await this.handleLists(oaiRequest, maxIdentifier, browserFingerprint); } protected async handleListRecords(oaiRequest: Dictionary, browserFingerprint: string) { if (!this.tokenWorker.isConnected) { await this.tokenWorker.connect(); } const maxRecords: number = config.get('oai.max.listrecords', 100); await this.handleLists(oaiRequest, maxRecords, browserFingerprint); } private async handleLists(oaiRequest: Dictionary, maxRecords: number, browserFingerprint: string) { const repIdentifier = 'tethys.at'; this.xsltParameter['repIdentifier'] = repIdentifier; const datasetNode = this.xml.root().ele('Datasets'); const paginationParams: PagingParameter ={ cursor: 0, totalLength: 0, start: maxRecords + 1, nextDocIds: [], activeWorkIds: [], metadataPrefix: '', queryParams: {}, }; if ('resumptionToken' in oaiRequest) { await this.handleResumptionToken(oaiRequest, maxRecords, paginationParams); } else { await this.handleNoResumptionToken(oaiRequest, paginationParams, maxRecords); } const nextIds: number[] = paginationParams.nextDocIds; const workIds: number[] = paginationParams.activeWorkIds; if (workIds.length === 0) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The combination of the given values results in an empty list.', OaiErrorCodes.NORECORDSMATCH, ); } const datasets = await Dataset.query() .whereIn('publish_id', workIds) .preload('xmlCache') .preload('collections', (builder) => { builder.preload('collectionRole'); }) .orderBy('publish_id'); for (const dataset of datasets) { await this.createXmlRecord(dataset, datasetNode); } await this.setResumptionToken(nextIds, paginationParams, browserFingerprint); } private async handleNoResumptionToken(oaiRequest: Dictionary, paginationParams: PagingParameter, maxRecords: number) { this.validateMetadataPrefix(oaiRequest, paginationParams); const finder: ModelQueryBuilderContract = Dataset.query().whereIn( 'server_state', this.deliveringDocumentStates, ); this.applySetFilter(finder, oaiRequest); this.applyDateFilters(finder, oaiRequest); await this.fetchAndSetResults(finder, paginationParams, oaiRequest, maxRecords); } private async fetchAndSetResults( finder: ModelQueryBuilderContract, paginationParams: PagingParameter, oaiRequest: Dictionary, maxRecords: number ) { const totalResult = await finder .clone() .count('* as total') .first() .then((res) => res?.$extras.total); paginationParams.totalLength = Number(totalResult); const combinedRecords: Dataset[] = await finder.select('publish_id').orderBy('publish_id').offset(0).limit(maxRecords*2); paginationParams.activeWorkIds = combinedRecords.slice(0, 100).map((dat) => Number(dat.publish_id)); paginationParams.nextDocIds = combinedRecords.slice(100).map((dat) => Number(dat.publish_id)); // No resumption token was used – set queryParams from the current oaiRequest paginationParams.queryParams = { ...oaiRequest, deliveringStates: this.deliveringDocumentStates, }; // paginationParams.totalLength = 230; } private async handleResumptionToken(oaiRequest: Dictionary, maxRecords: number, paginationParams: PagingParameter) { const resParam = oaiRequest['resumptionToken']; const token = await this.tokenWorker.get(resParam); if (!token) { throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'cache is outdated.', OaiErrorCodes.BADRESUMPTIONTOKEN); } // this.setResumptionParameters(token, maxRecords, paginationParams); paginationParams.cursor = token.startPosition - 1; paginationParams.start = token.startPosition + maxRecords; paginationParams.totalLength = token.totalIds; paginationParams.activeWorkIds = token.documentIds; paginationParams.metadataPrefix = token.metadataPrefix; paginationParams.queryParams = token.queryParams; this.xsltParameter['oai_metadataPrefix'] = token.metadataPrefix; const finder = this.buildDatasetQueryViaToken(token); const nextRecords: Dataset[] = await this.fetchNextRecords(finder, token, maxRecords); paginationParams.nextDocIds = nextRecords.map((dat) => Number(dat.publish_id)); } private async setResumptionToken(nextIds: number[], paginationParams: PagingParameter, browserFingerprint: string) { const countRestIds = nextIds.length; if (countRestIds > 0) { // const token = this.createResumptionToken(paginationParams, nextIds); const token = new ResumptionToken(); token.startPosition = paginationParams.start; token.totalIds = paginationParams.totalLength; token.documentIds = nextIds; token.metadataPrefix = paginationParams.metadataPrefix; token.queryParams = paginationParams.queryParams; const res: string = await this.tokenWorker.set(token, browserFingerprint); this.setParamResumption(res, paginationParams.cursor, paginationParams.totalLength); } } private buildDatasetQueryViaToken(token: ResumptionToken) { const finder = Dataset.query(); const originalQuery = token.queryParams || {}; const deliveringStates = originalQuery.deliveringStates || this.deliveringDocumentStates; finder.whereIn('server_state', deliveringStates); this.applySetFilter(finder, originalQuery); this.applyDateFilters(finder, originalQuery); return finder; } private async fetchNextRecords(finder: ModelQueryBuilderContract, token: ResumptionToken, maxRecords: number) { return finder .select('publish_id') .orderBy('publish_id') .offset(token.startPosition - 1 + maxRecords) .limit(100); } private validateMetadataPrefix(oaiRequest: Dictionary, paginationParams: PagingParameter) { if (!('metadataPrefix' in oaiRequest)) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The prefix of the metadata argument is unknown.', OaiErrorCodes.BADARGUMENT, ); } paginationParams.metadataPrefix = oaiRequest['metadataPrefix']; this.xsltParameter['oai_metadataPrefix'] = paginationParams.metadataPrefix; } private applySetFilter(finder: ModelQueryBuilderContract, queryParams: any) { if ('set' in queryParams) { const [setType, setValue] = queryParams['set'].split(':'); switch (setType) { case 'data-type': setValue && finder.where('type', setValue); break; case 'open_access': finder.andWhereHas('licenses', (query) => { query.whereIn('name', ['CC-BY-4.0', 'CC-BY-SA-4.0']); }); break; case 'ddc': setValue && finder.andWhereHas('collections', (query) => { query.where('number', setValue); }); break; } } } private applyDateFilters(finder: ModelQueryBuilderContract, queryParams: any) { const { from, until } = queryParams; if (from && until) { this.handleFromUntilFilter(finder, from, until); } else if (from) { this.handleFromFilter(finder, from); } else if (until) { this.handleUntilFilter(finder, until); } } private handleFromUntilFilter(finder: ModelQueryBuilderContract, from: string, until: string) { const fromDate = this.parseDateWithValidation(from, 'From'); const untilDate = this.parseDateWithValidation(until, 'Until'); if (from.length !== until.length) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The request has different granularities for the from and until parameters.', OaiErrorCodes.BADARGUMENT, ); } finder.whereBetween('server_date_published', [fromDate.format('YYYY-MM-DD HH:mm:ss'), untilDate.format('YYYY-MM-DD HH:mm:ss')]); } private handleFromFilter(finder: ModelQueryBuilderContract, from: string) { const fromDate = this.parseDateWithValidation(from, 'From'); const now = dayjs(); if (fromDate.isAfter(now)) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'Given from date is greater than now. The given values results in an empty list.', OaiErrorCodes.NORECORDSMATCH, ); } finder.andWhere('server_date_published', '>=', fromDate.format('YYYY-MM-DD HH:mm:ss')); } private handleUntilFilter(finder: ModelQueryBuilderContract, until: string) { const untilDate = this.parseDateWithValidation(until, 'Until'); const earliestPublicationDate = dayjs(this.firstPublishedDataset?.server_date_published.toISO()); if (earliestPublicationDate.isAfter(untilDate)) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'earliestDatestamp is greater than given until date. The given values results in an empty list.', OaiErrorCodes.NORECORDSMATCH, ); } finder.andWhere('server_date_published', '<=', untilDate.format('YYYY-MM-DD HH:mm:ss')); } private parseDateWithValidation(dateStr: string, label: string) { let date = dayjs(dateStr); if (!date.isValid()) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, `${label} date parameter is not valid.`, OaiErrorCodes.BADARGUMENT, ); } date = dayjs.tz(dateStr, 'Europe/Vienna'); return date.hour() === 0 ? (label === 'From' ? date.startOf('day') : date.endOf('day')) : date; } private setParamResumption(res: string, cursor: number, totalIds: number) { const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DDThh:mm:ss[Z]'); this.xsltParameter['dateDelete'] = tomorrow; this.xsltParameter['res'] = res; this.xsltParameter['cursor'] = cursor; this.xsltParameter['totalIds'] = totalIds; } private validateAndGetIdentifier(oaiRequest: Dictionary): number { // Identifier references metadata Urn, not plain Id! // Currently implemented as 'oai:foo.bar.de:{docId}' or 'urn:nbn...-123' if (!('identifier' in oaiRequest)) { throw new BadOaiModelException('The prefix of the identifier argument is unknown.'); } const dataId = Number(this.getDocumentIdByIdentifier(oaiRequest.identifier)); if (isNaN(dataId)) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The value of the identifier argument is illegal in this repository.', OaiErrorCodes.BADARGUMENT, ); } return dataId; } private validateAndGetMetadataPrefix(oaiRequest: Dictionary): string { let metadataPrefix = ''; if ('metadataPrefix' in oaiRequest) { metadataPrefix = oaiRequest['metadataPrefix']; } else { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'The prefix of the metadata argument is unknown.', OaiErrorCodes.BADARGUMENT, ); } return metadataPrefix; } private validateDatasetState(dataset: Dataset): void { if (dataset.server_state == null || !this.deliveringDocumentStates.includes(dataset.server_state)) { throw new OaiModelException( StatusCodes.INTERNAL_SERVER_ERROR, 'Document is not available for OAI export!', OaiErrorCodes.NORECORDSMATCH, ); } } private async createXmlRecord(dataset: Dataset, datasetNode: XMLBuilder) { const domNode = await this.getDatasetXmlDomNode(dataset); if (domNode) { // add frontdoor url and data-type dataset.publish_id && this.addLandingPageAttribute(domNode, dataset.publish_id.toString()); this.addSpecInformation(domNode, 'data-type:' + dataset.type); if (dataset.collections) { for (const coll of dataset.collections) { const collRole = coll.collectionRole; this.addSpecInformation(domNode, collRole.oai_name + ':' + coll.number); } } datasetNode.import(domNode); } } private async getDatasetXmlDomNode(dataset: Dataset) { const xmlModel = new XmlModel(dataset); // xmlModel.setModel(dataset); xmlModel.excludeEmptyFields(); xmlModel.caching = true; // const cache = dataset.xmlCache ? dataset.xmlCache : null; // dataset.load('xmlCache'); if (dataset.xmlCache) { xmlModel.xmlCache = dataset.xmlCache; } // return cache.getDomDocument(); const domDocument: XMLBuilder | null = await xmlModel.getDomDocument(); return domDocument; } private addSpecInformation(domNode: XMLBuilder, information: string) { domNode.ele('SetSpec').att('Value', information); } private addLandingPageAttribute(domNode: XMLBuilder, dataid: string) { const baseDomain = process.env.OAI_BASE_DOMAIN || 'localhost'; const url = 'https://' + getDomain(baseDomain) + '/dataset/' + dataid; // add attribute du dataset xml element domNode.att('landingpage', url); } private getDocumentIdByIdentifier(oaiIdentifier: string): string { const identifierParts: string[] = oaiIdentifier.split(':'); // explode(":", $oaiIdentifier); const dataId: string = identifierParts[2]; // switch (identifierParts[0]) { // case 'oai': // if (isset($identifierParts[2])) { // $dataId = $identifierParts[2]; // } // break; // default: // throw new OaiModelException( // 'The prefix of the identifier argument is unknown.', // OaiModelError::BADARGUMENT // ); // break; // } // if (empty($dataId) or !preg_match('/^\d+$/', $dataId)) { // throw new OaiModelException( // 'The value of the identifier argument is unknown or illegal in this repository.', // OaiModelError::IDDOESNOTEXIST // ); return dataId; } private async getSetsForCollections(): Promise { const sets: { [key: string]: string } = {} as Dictionary; const collections = await Collection.query() .select('name', 'number', 'role_id') .whereHas('collectionRole', (query) => { query.where('visible_oai', true); }) .preload('collectionRole'); collections.forEach((collection) => { // if collection has a collection role (classification like ddc): if (collection.number) { // collection.load('collectionRole'); const setSpec = collection.collectionRole?.oai_name + ':' + collection.number; sets[setSpec] = `Set ${collection.number} '${collection.name}'`; } }); return sets; } private async getSetsForDatasetTypes(): Promise { const sets: { [key: string]: string } = {} as Dictionary; const datasets: Array = await Dataset.query().select('type').where('server_state', 'published'); datasets.forEach((dataset) => { if (dataset.type && false == preg_match(this.sampleRegEx, dataset.type)) { const msg = `Invalid SetSpec (data-type='${dataset.type}'). Allowed characters are [${this.sampleRegEx}].`; // Log::error("OAI-PMH: $msg"); logger.error(`OAI-PMH: ${msg}`); return; } const setSpec = 'data-type:' + dataset.type; sets[setSpec] = `Set for document type '${dataset.type}'`; }); return sets; } private handleIllegalVerb() { this.xsltParameter['oai_error_code'] = 'badVerb'; this.xsltParameter['oai_error_message'] = 'The verb provided in the request is illegal.'; } /** * Helper method to build a browser fingerprint by combining: * - User-Agent header, * - the IP address, * - Accept-Language header, * - current timestamp rounded to the hour. * * Every new hour, this will return a different fingerprint. */ private getBrowserFingerprint(request: Request): string { const userAgent = request.header('user-agent') || 'unknown'; // Check for X-Forwarded-For header to use the client IP from the proxy if available. const xForwardedFor = request.header('x-forwarded-for'); let ip = request.ip(); // console.log(ip); if (xForwardedFor) { // X-Forwarded-For may contain a comma-separated list of IPs; the first one is the client IP. ip = xForwardedFor.split(',')[0].trim(); // console.log('xforwardedfor ip' + ip); } const locale = request.header('accept-language') || 'default'; // Round the current time to the start of the hour. const timestampHour = dayjs().startOf('hour').format('YYYY-MM-DDTHH'); return `${userAgent}-${ip}-${locale}-${timestampHour}`; } }