From 324ccdeab4228448b1926b131c0d3663ad0b738c Mon Sep 17 00:00:00 2001 From: tesaide Date: Wed, 24 Dec 2025 13:32:38 +0200 Subject: [PATCH 1/4] feat: Add displaying the country depending on the IP --- index.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 7f81005..3c6318c 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,87 @@ export default class AuditLogPlugin extends AdminForthPlugin { static defaultError = 'Sorry, you do not have access to this resource.' + async getClientIpCountry(headers: Record, clientIp: string | null): Promise { + + const headersLower = Object.keys(headers).reduce((acc: Record, key: string) => { + acc[key.toLowerCase()] = headers[key]; + return acc; + }, {}); + + // Cloudflare Check (Fastest) + const cfCountry = headersLower['cf-ipcountry']; + if (cfCountry && cfCountry !== 'XX') { + return cfCountry.toUpperCase(); + } + + if (!clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.includes('localhost')) { + return null; + } + + // DB CHECK + try { + const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); + + const ipCol = this.options.resourceColumns.resourceIpColumnName || 'ip_address'; + const countryCol = this.options.resourceColumns.resourceCountryColumnName || 'country'; + const createdCol = this.options.resourceColumns.resourceCreatedColumnName || 'created_at'; + + if (auditLogResource && ipCol && countryCol) { + const connector = this.adminforth.connectors[auditLogResource.dataSource]; + + const response: any = await connector.getData({ + resource: auditLogResource, + filters: { + operator: 'and', + subFilters: [ + { field: ipCol, operator: 'eq', value: clientIp }, + + { insecureRawSQL: `"${countryCol}" IS NOT NULL` } + ] + }, + + sort: [{ field: createdCol, direction: 'desc' }], + limit: 1, + offset: 0 + }); + + let rows: any[] = []; + if (Array.isArray(response)) { + rows = response; + } else if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) { + rows = response.data; + } + + if (rows.length > 0) { + return rows[0][countryCol]; + } + } + } catch (e) { + } + + // API Request + try { + const apiUrl = `https://geoip.vuiz.net/geoip?ip=${clientIp}`; + + const response = await fetch(apiUrl); + + if (response.status !== 200) { + return null; + } + + const data: any = await response.json(); + + const country = data.country_code || data.countryCode || data.country_code3 || data.country; + + if (country && typeof country === 'string' && country.length === 2) { + return country.toUpperCase(); + } + + } catch (e) { + } + + return null; + } createLogRecord = async (resource: AdminForthResource, action: AllowedActionsEnum | string, data: Object, user: AdminUser, oldRecord?: Object, extra?: HttpExtra) => { const recordIdFieldName = resource.columns.find((c) => c.primaryKey === true)?.name; const recordId = data?.[recordIdFieldName] || oldRecord?.[recordIdFieldName]; @@ -84,16 +165,23 @@ export default class AuditLogPlugin extends AdminForthPlugin { delete newRecord[c.name]; } }); + const clientIp = (this.options.resourceColumns.resourceIpColumnName && extra?.headers) ? this.adminforth.auth.getClientIp(extra.headers) : null; + + const country = extra?.headers ? await this.getClientIpCountry(extra.headers, clientIp) : null; const record = { [this.options.resourceColumns.resourceIdColumnName]: resource.resourceId, [this.options.resourceColumns.resourceActionColumnName]: action, - [this.options.resourceColumns.resourceDataColumnName]: { 'oldRecord': oldRecord || {}, 'newRecord': newRecord }, + [this.options.resourceColumns.resourceDataColumnName]: { + 'oldRecord': oldRecord || {}, + 'newRecord': { ...newRecord, ip: clientIp, country: country } + }, [this.options.resourceColumns.resourceUserIdColumnName]: user.pk, [this.options.resourceColumns.resourceRecordIdColumnName]: recordId, // utc iso string [this.options.resourceColumns.resourceCreatedColumnName]: dayjs.utc().format(), - ...(this.options.resourceColumns.resourceIpColumnName && extra?.headers ? {[this.options.resourceColumns.resourceIpColumnName]: this.adminforth.auth.getClientIp(extra.headers)} : {}), + ...(clientIp ? {[this.options.resourceColumns.resourceIpColumnName]: clientIp} : {}), + ...(country && this.options.resourceColumns.resourceCountryColumnName ? {[this.options.resourceColumns.resourceCountryColumnName]: country} : {}), } const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); await this.adminforth.createResourceRecord({ resource: auditLogResource, record, adminUser: user}); @@ -131,15 +219,22 @@ export default class AuditLogPlugin extends AdminForthPlugin { throw new Error(`Resource ${resourceId} not found. Did you mean ${similarResource.resourceId}?`) } } + const clientIp = (this.options.resourceColumns.resourceIpColumnName && headers) ? this.adminforth.auth.getClientIp(headers) : null; + + const country = headers ? await this.getClientIpCountry(headers, clientIp) : null; const record = { [this.options.resourceColumns.resourceIdColumnName]: resourceId, [this.options.resourceColumns.resourceActionColumnName]: actionId, - [this.options.resourceColumns.resourceDataColumnName]: { 'oldRecord': oldData || {}, 'newRecord': data }, + [this.options.resourceColumns.resourceDataColumnName]: { + 'oldRecord': oldData || {}, + 'newRecord': { ...data, ip: clientIp, country: country } + }, [this.options.resourceColumns.resourceUserIdColumnName]: user.pk, [this.options.resourceColumns.resourceRecordIdColumnName]: recordId, [this.options.resourceColumns.resourceCreatedColumnName]: dayjs.utc().format(), - ...(this.options.resourceColumns.resourceIpColumnName && headers ? {[this.options.resourceColumns.resourceIpColumnName]: this.adminforth.auth.getClientIp(headers)} : {}), + ...(clientIp ? {[this.options.resourceColumns.resourceIpColumnName]: clientIp} : {}), + ...(country && this.options.resourceColumns.resourceCountryColumnName ? {[this.options.resourceColumns.resourceCountryColumnName]: country} : {}), } const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); await this.adminforth.createResourceRecord({ resource: auditLogResource, record, adminUser: user}); From cb6382f22d6050231782ad8133c1c5142a4146c3 Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Wed, 7 Jan 2026 10:18:20 +0200 Subject: [PATCH 2/4] fix: refactor getting clientIP and country logic --- index.ts | 93 +++++++++++++++++++------------------------------------- types.ts | 4 ++- 2 files changed, 35 insertions(+), 62 deletions(-) diff --git a/index.ts b/index.ts index 3c6318c..7b53956 100644 --- a/index.ts +++ b/index.ts @@ -7,7 +7,7 @@ import type { import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc.js'; -import { AdminForthPlugin, AllowedActionsEnum, AdminForthSortDirections, AdminForthDataTypes, HttpExtra, ActionCheckSource, } from "adminforth"; +import { AdminForthPlugin, AllowedActionsEnum, AdminForthSortDirections, AdminForthDataTypes, HttpExtra, ActionCheckSource, Filters, } from "adminforth"; import { PluginOptions } from "./types.js"; dayjs.extend(utc); @@ -29,8 +29,19 @@ export default class AuditLogPlugin extends AdminForthPlugin { static defaultError = 'Sorry, you do not have access to this resource.' - async getClientIpCountry(headers: Record, clientIp: string | null): Promise { + async getIpAndCountry(headers: Record): Promise<{ country: string | null, clientIp: string | null }> { + let clientIp: string | null = null; + if (this.options.resourceColumns.resourceIpColumnName) { + clientIp = this.adminforth.auth.getClientIp(headers); + } + let country: string | null = null; + if (this.options.resourceColumns.resourceCountryColumnName && clientIp) { + country = await this.getClientIpCountry(headers, clientIp); + } + return { country, clientIp }; + } + async getClientIpCountry(headers: Record, clientIp: string | null): Promise { const headersLower = Object.keys(headers).reduce((acc: Record, key: string) => { acc[key.toLowerCase()] = headers[key]; return acc; @@ -43,69 +54,36 @@ export default class AuditLogPlugin extends AdminForthPlugin { } if (!clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.includes('localhost')) { - return null; + //return null; } // DB CHECK - try { - const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); - - const ipCol = this.options.resourceColumns.resourceIpColumnName || 'ip_address'; - const countryCol = this.options.resourceColumns.resourceCountryColumnName || 'country'; - const createdCol = this.options.resourceColumns.resourceCreatedColumnName || 'created_at'; - - if (auditLogResource && ipCol && countryCol) { - const connector = this.adminforth.connectors[auditLogResource.dataSource]; - - const response: any = await connector.getData({ - resource: auditLogResource, - filters: { - operator: 'and', - subFilters: [ - { field: ipCol, operator: 'eq', value: clientIp }, - - { insecureRawSQL: `"${countryCol}" IS NOT NULL` } - ] - }, - - sort: [{ field: createdCol, direction: 'desc' }], - limit: 1, - offset: 0 - }); - - let rows: any[] = []; - if (Array.isArray(response)) { - rows = response; - } else if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) { - rows = response.data; - } + const ipCol = this.options.resourceColumns.resourceIpColumnName; + const countryCol = this.options.resourceColumns.resourceCountryColumnName; - if (rows.length > 0) { - return rows[0][countryCol]; - } - } - } catch (e) { + const existingLog = await this.adminforth.resource(this.auditLogResource).get(Filters.AND(Filters.EQ(ipCol, clientIp), Filters.IS_NOT_EMPTY(countryCol))); + if (existingLog) { + return existingLog[countryCol]; } - + // API Request try { - const apiUrl = `https://geoip.vuiz.net/geoip?ip=${clientIp}`; + const apiUrl = `https://ipinfo.io/${clientIp}/json`; const response = await fetch(apiUrl); - if (response.status !== 200) { - return null; - } + return null; + } const data: any = await response.json(); - - const country = data.country_code || data.countryCode || data.country_code3 || data.country; + const country = data.country; if (country && typeof country === 'string' && country.length === 2) { return country.toUpperCase(); } } catch (e) { + console.error('Error fetching IP country', e); } return null; @@ -165,23 +143,19 @@ export default class AuditLogPlugin extends AdminForthPlugin { delete newRecord[c.name]; } }); - const clientIp = (this.options.resourceColumns.resourceIpColumnName && extra?.headers) ? this.adminforth.auth.getClientIp(extra.headers) : null; - const country = extra?.headers ? await this.getClientIpCountry(extra.headers, clientIp) : null; + const { country, clientIp } = await this.getIpAndCountry(extra?.headers || {}); const record = { [this.options.resourceColumns.resourceIdColumnName]: resource.resourceId, [this.options.resourceColumns.resourceActionColumnName]: action, - [this.options.resourceColumns.resourceDataColumnName]: { - 'oldRecord': oldRecord || {}, - 'newRecord': { ...newRecord, ip: clientIp, country: country } - }, + [this.options.resourceColumns.resourceDataColumnName]: { 'oldRecord': oldRecord || {}, 'newRecord': newRecord }, [this.options.resourceColumns.resourceUserIdColumnName]: user.pk, [this.options.resourceColumns.resourceRecordIdColumnName]: recordId, // utc iso string [this.options.resourceColumns.resourceCreatedColumnName]: dayjs.utc().format(), ...(clientIp ? {[this.options.resourceColumns.resourceIpColumnName]: clientIp} : {}), - ...(country && this.options.resourceColumns.resourceCountryColumnName ? {[this.options.resourceColumns.resourceCountryColumnName]: country} : {}), + ...(country ? {[this.options.resourceColumns.resourceCountryColumnName]: country } : {}), } const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); await this.adminforth.createResourceRecord({ resource: auditLogResource, record, adminUser: user}); @@ -219,23 +193,20 @@ export default class AuditLogPlugin extends AdminForthPlugin { throw new Error(`Resource ${resourceId} not found. Did you mean ${similarResource.resourceId}?`) } } - const clientIp = (this.options.resourceColumns.resourceIpColumnName && headers) ? this.adminforth.auth.getClientIp(headers) : null; - const country = headers ? await this.getClientIpCountry(headers, clientIp) : null; + const { country, clientIp } = await this.getIpAndCountry(headers || {}); const record = { [this.options.resourceColumns.resourceIdColumnName]: resourceId, [this.options.resourceColumns.resourceActionColumnName]: actionId, - [this.options.resourceColumns.resourceDataColumnName]: { - 'oldRecord': oldData || {}, - 'newRecord': { ...data, ip: clientIp, country: country } - }, + [this.options.resourceColumns.resourceDataColumnName]: { 'oldRecord': oldData || {}, 'newRecord': data }, [this.options.resourceColumns.resourceUserIdColumnName]: user.pk, [this.options.resourceColumns.resourceRecordIdColumnName]: recordId, [this.options.resourceColumns.resourceCreatedColumnName]: dayjs.utc().format(), ...(clientIp ? {[this.options.resourceColumns.resourceIpColumnName]: clientIp} : {}), - ...(country && this.options.resourceColumns.resourceCountryColumnName ? {[this.options.resourceColumns.resourceCountryColumnName]: country} : {}), + ...(country ? {[this.options.resourceColumns.resourceCountryColumnName]: country } : {}), } + const auditLogResource = this.adminforth.config.resources.find((r) => r.resourceId === this.auditLogResource); await this.adminforth.createResourceRecord({ resource: auditLogResource, record, adminUser: user}); } diff --git a/types.ts b/types.ts index 3b69d24..dd08542 100644 --- a/types.ts +++ b/types.ts @@ -35,8 +35,10 @@ export type PluginOptions = { resourceRecordIdColumnName: string resourceCreatedColumnName: string + + resourceCountryColumnName?: string resourceIpColumnName?: string } - + } \ No newline at end of file From ad5b0025106e38bf52ff52d8ec479f2062e74cb1 Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Wed, 7 Jan 2026 10:19:44 +0200 Subject: [PATCH 3/4] fix: return null for invalid client IP addresses in getClientIpCountry method --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 7b53956..46c445c 100644 --- a/index.ts +++ b/index.ts @@ -54,7 +54,7 @@ export default class AuditLogPlugin extends AdminForthPlugin { } if (!clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.includes('localhost')) { - //return null; + return null; } // DB CHECK From 936e79ddf5b606c73e76999e9c813c2e7455cc00 Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Fri, 9 Jan 2026 11:31:17 +0200 Subject: [PATCH 4/4] feat: add support for custom ISO country code request header in getClientIpCountry method --- index.ts | 15 +++++++-------- types.ts | 6 ++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/index.ts b/index.ts index 46c445c..937ed98 100644 --- a/index.ts +++ b/index.ts @@ -47,20 +47,19 @@ export default class AuditLogPlugin extends AdminForthPlugin { return acc; }, {}); - // Cloudflare Check (Fastest) - const cfCountry = headersLower['cf-ipcountry']; - if (cfCountry && cfCountry !== 'XX') { - return cfCountry.toUpperCase(); - } - - if (!clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.includes('localhost')) { - return null; + if (this.options.isoCountryCodeRequestHeader) { + const cfCountry = headersLower[this.options.isoCountryCodeRequestHeader.toLowerCase()]; + if (cfCountry && cfCountry !== 'XX') { + return cfCountry.toUpperCase(); + } } // DB CHECK const ipCol = this.options.resourceColumns.resourceIpColumnName; const countryCol = this.options.resourceColumns.resourceCountryColumnName; + //TODO fix ts-ignore after release new adminforth version with proper types + //@ts-ignore const existingLog = await this.adminforth.resource(this.auditLogResource).get(Filters.AND(Filters.EQ(ipCol, clientIp), Filters.IS_NOT_EMPTY(countryCol))); if (existingLog) { return existingLog[countryCol]; diff --git a/types.ts b/types.ts index dd08542..2acc4b1 100644 --- a/types.ts +++ b/types.ts @@ -41,4 +41,10 @@ export type PluginOptions = { resourceIpColumnName?: string } + + /* + * should be in format ISO 3166-1 alpha-2 + * e.g. for ckloudflare it should be 'CF-IPCountry' + */ + isoCountryCodeRequestHeader?: string; } \ No newline at end of file