From ac159072b15d02f3863ee53e97429dde97f82991 Mon Sep 17 00:00:00 2001 From: Ian Morland Date: Wed, 20 Sep 2023 03:01:57 +0100 Subject: [PATCH 1/4] feat: IP info modal and map for moderators, and integration with session info --- README.md | 12 +- composer.json | 2 +- extend.php | 3 + js/src/forum/components/LabelValue.tsx | 29 +++++ js/src/forum/components/MapModal.tsx | 87 +++++++++++++++ js/src/forum/components/ZipCodeMap.js | 12 +- .../extenders/extendAccessTokensList.tsx | 28 +++++ js/src/forum/extenders/extendPostMeta.js | 65 +++++++++-- js/src/forum/helpers/IPDataHelper.tsx | 7 +- js/src/forum/index.ts | 4 + resources/less/forum.less | 104 ++++++++++++++---- resources/locale/en.yml | 11 ++ src/Api/Controller/ShowIpInfoController.php | 83 ++++++++++++++ 13 files changed, 403 insertions(+), 44 deletions(-) create mode 100644 js/src/forum/components/LabelValue.tsx create mode 100644 js/src/forum/components/MapModal.tsx create mode 100644 js/src/forum/extenders/extendAccessTokensList.tsx create mode 100644 src/Api/Controller/ShowIpInfoController.php diff --git a/README.md b/README.md index c6aaad5..3fcbb43 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ ![License](https://img.shields.io/badge/license-MIT-blue.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/fof/geoip.svg)](https://packagist.org/packages/fof/geoip) [![OpenCollective](https://img.shields.io/badge/opencollective-fof-blue.svg)](https://opencollective.com/fof/donate) [![Donate](https://img.shields.io/badge/donate-datitisev-important.svg)](https://datitisev.me/donate) -A [Flarum](http://flarum.org) extension. Geolocation for your Flarum forum. +A [Flarum](http://flarum.org) extension. + +## Empower Your Flarum Moderators with GeoIP + +Moderators play a crucial role in maintaining the health and quality of forums. With GeoIP, give them the geolocation tools they need to better understand users, make informed decisions, and maintain a safe environment. Only moderators have access to IP-based geolocation, ensuring user privacy and data security. + +### 🌎 Key Features +- **Location Insights**: Enable moderators to identify the country and region of users. +- **Interactive Mapping**: Let moderators visualize user locations with an integrated map view. +- **Threat Detection**: Equip moderators with the ability to highlight potentially malicious IP addresses through threat level indicators. (Via supported IP location data providers) ### Installation @@ -16,6 +25,7 @@ composer require fof/geoip:"*" ```sh composer update fof/geoip +php flarum cache:clear ``` ### Links diff --git a/composer.json b/composer.json index 2ef3402..c97257d 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "require": { "php": "^8.0", - "flarum/core": "^1.2.0", + "flarum/core": "^1.8.0", "guzzlehttp/guzzle": "^7.3" }, "authors": [ diff --git a/extend.php b/extend.php index 632f3c4..9c1f279 100644 --- a/extend.php +++ b/extend.php @@ -67,4 +67,7 @@ (new Extend\Settings()) ->default('fof-geoip.service', 'ipapi'), + + (new Extend\Routes('api')) + ->get('/ip_info/{ip}', 'fof-geoip.api.ip_info', Api\Controller\ShowIpInfoController::class), ]; diff --git a/js/src/forum/components/LabelValue.tsx b/js/src/forum/components/LabelValue.tsx new file mode 100644 index 0000000..6bace3c --- /dev/null +++ b/js/src/forum/components/LabelValue.tsx @@ -0,0 +1,29 @@ +import Component, { ComponentAttrs } from 'flarum/common/Component'; +import type Mithril from 'mithril'; +import app from 'flarum/forum/app'; + +export interface ILabelValueAttrs extends ComponentAttrs { + label: Mithril.Children; + value: Mithril.Children; +} + +/** + * A generic component for displaying a label and value inline. + * Created to avoid reinventing the wheel. Copied here as it's not exported by core at present. Awaiting https://github.com/flarum/framework/pull/3888 + * + * `label: value` + */ +export default class LabelValue extends Component { + view(vnode: Mithril.Vnode): Mithril.Children { + return ( +
+
+ {app.translator.trans('core.lib.data_segment.label', { + label: this.attrs.label, + })} +
+
{this.attrs.value}
+
+ ); + } +} diff --git a/js/src/forum/components/MapModal.tsx b/js/src/forum/components/MapModal.tsx new file mode 100644 index 0000000..d003c4f --- /dev/null +++ b/js/src/forum/components/MapModal.tsx @@ -0,0 +1,87 @@ +import app from 'flarum/forum/app'; +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import ZipCodeMap from './ZipCodeMap'; +import IPInfo from '../models/IPInfo'; +import { handleCopyIP } from '../helpers/ClipboardHelper'; +import LabelValue from './LabelValue'; +import type Mithril from 'mithril'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; + +interface MapModalAttrs extends IInternalModalAttrs { + ipInfo?: IPInfo; + ipAddr: string; +} + +export default class MapModal extends Modal { + ipInfo: IPInfo | undefined; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.ipInfo = this.attrs.ipInfo; + if (this.ipInfo === undefined) { + this.loadIpInfo(); + } + } + + className() { + return 'MapModal Modal--medium'; + } + + loadIpInfo() { + app.store + .find('ip_info', encodeURIComponent(this.attrs.ipAddr)) + .then((ipInfo) => { + this.ipInfo = ipInfo; + m.redraw(); + }) + .catch((error) => { + console.error('Failed to load IP information from the store', error); + }); + } + + title() { + return app.translator.trans('fof-geoip.forum.map_modal.title'); + } + + content() { + const ipInfo = this.ipInfo; + + if (!ipInfo) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + {this.attrs.ipAddr} + + } + /> + {ipInfo.countryCode() && } + {ipInfo.zipCode() && } + {ipInfo.isp() && } + {ipInfo.organization() && ( + + )} + {ipInfo.threatLevel() && } + {ipInfo.threatTypes().length > 0 && ( + + )} + {ipInfo.error() && } +
+
+
+ +
+
+ ); + } +} diff --git a/js/src/forum/components/ZipCodeMap.js b/js/src/forum/components/ZipCodeMap.js index c19d970..95eb69c 100644 --- a/js/src/forum/components/ZipCodeMap.js +++ b/js/src/forum/components/ZipCodeMap.js @@ -7,8 +7,8 @@ let addedResources = false; const addResources = async () => { if (addedResources) return; - await load.css('https://unpkg.com/leaflet@1.5.1/dist/leaflet.css'); - await load.js('https://unpkg.com/leaflet@1.5.1/dist/leaflet.js'); + await load.css('https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'); + await load.js('https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'); addedResources = true; }; @@ -65,12 +65,8 @@ export default class ZipCodeMap extends Component { this.map = L.map(vnode.dom).setView([51.505, -0.09], 5); - L.control.scale().addTo(this.map); - - L.tileLayer(`https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}{r}.png?lang=${app.data.locale}`, { - attribution: 'Wikimedia', - minZoom: 1, - maxZoom: 19, + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', }).addTo(this.map); L.marker([bounding[0], bounding[2]]).addTo(this.map).bindPopup(displayName).openPopup(); diff --git a/js/src/forum/extenders/extendAccessTokensList.tsx b/js/src/forum/extenders/extendAccessTokensList.tsx new file mode 100644 index 0000000..d191270 --- /dev/null +++ b/js/src/forum/extenders/extendAccessTokensList.tsx @@ -0,0 +1,28 @@ +import app from 'flarum/forum/app'; +import { extend } from 'flarum/common/extend'; +import AccessTokensList from 'flarum/forum/components/AccessTokensList'; +import ItemList from 'flarum/common/utils/ItemList'; +import MapModal from '../components/MapModal'; +import Button from 'flarum/common/components/Button'; +import type Mithril from 'mithril'; +import AccessToken from 'flarum/common/models/AccessToken'; + +export default function extendAccessTokensList() { + extend(AccessTokensList.prototype, 'tokenActionItems', function (items: ItemList, token: AccessToken) { + const ipAddr = token.lastIpAddress(); + + if (ipAddr) { + items.add( + 'geoip-info', + , + 10 + ); + } + }); +} diff --git a/js/src/forum/extenders/extendPostMeta.js b/js/src/forum/extenders/extendPostMeta.js index 8c83139..af3e8e6 100644 --- a/js/src/forum/extenders/extendPostMeta.js +++ b/js/src/forum/extenders/extendPostMeta.js @@ -4,36 +4,77 @@ import PostMeta from 'flarum/common/components/PostMeta'; import Tooltip from 'flarum/common/components/Tooltip'; import { getIPData } from '../helpers/IPDataHelper'; import { handleCopyIP } from '../helpers/ClipboardHelper'; +import ItemList from 'flarum/common/utils/ItemList'; +import Button from 'flarum/common/components/Button'; +import MapModal from '../components/MapModal'; export default function extendPostMeta() { extend(PostMeta.prototype, 'view', function (vdom) { const post = this.attrs.post; + + // Exit early if there's no post if (!post) return; const ipInformation = post.ip_info(); - const ipAddr = post.data.attributes.ipAddress; + // Exit early if there's no IP information for the post if (!ipInformation) return; + // Extract dropdown from the VDOM const menuDropdown = vdom.children.find((e) => e.attrs?.className?.includes('dropdown-menu')); - const ipElement = menuDropdown.children.find((e) => e.tag === 'span' && e.attrs?.className === 'PostMeta-ip'); - const { description, threat, image } = getIPData(ipInformation); + // Extract IP element for modification + const ipElement = menuDropdown.children.find((e) => e.tag === 'span' && e.attrs?.className === 'PostMeta-ip'); + // Clear any default text from the IP element delete ipElement.text; - ipElement.children = [ - - {ipAddr} - , - ]; - - if (image) { - ipElement.children.unshift(image); - } + // Construct the IP element with the tooltip and interactive behavior + ipElement.children = [
{this.ipItems().toArray()}
]; + // If there's a threat level, add it as a data attribute for potential styling + // TODO: move this to an Item? if (ipInformation.threatLevel) { ipElement.attrs['data-threat-level'] = ipInformation.threatLevel(); } }); + + PostMeta.prototype.ipItems = function () { + const items = new ItemList(); + const post = this.attrs.post; + const ipInformation = post.ip_info(); + const ipAddr = post.data.attributes.ipAddress; + + if (ipInformation && ipAddr) { + const { description, threat, image, zip, country } = getIPData(ipInformation); + + items.add( + 'ipInfo', +
+ {image} + + {ipAddr} + +
, + 100 + ); + + if (zip && country) { + items.add( + 'mapButton', + +