diff --git a/README.md b/README.md index f4f1aab..007192b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式 - 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围 - 支持显示评论者设备信息 +- 支持显示评论和回复的 IP 属地 - 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式 ## 使用方式 @@ -27,6 +28,12 @@ > 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。 +## IP 属地 + +在插件「基本设置」中开启「显示 IP 属地」,即可在评论和回复中展示属地:中国大陆显示省份,其他地区显示国家或地区,无法识别时显示「未知」。开启后,历史评论也会逐步补充可识别的属地。 + +此功能默认关闭,属地信息由 [ip2region](https://github.com/lionsoul2014/ip2region) 提供,仅供参考。关闭功能只会隐藏展示,不会删除已保存的属地信息。 + ## 主题适配 主题可通过 Halo 的自定义标签接入评论组件,并通过 CSS 变量自定义样式和配色。 diff --git a/build.gradle b/build.gradle index 74e7613..3993ca2 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ dependencies { compileOnly 'run.halo.app:api' implementation 'org.altcha:altcha:2.0.3' implementation 'org.json:json:20240303' + implementation 'org.lionsoul:ip2region:3.3.7' testImplementation 'run.halo.app:api' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -39,7 +40,89 @@ test { sourceSets.main.resources.exclude 'ui/**', 'static/**' +// Pin the source data and official maker together; updates must refresh the checksum. +def ipLocationRevision = '6a276cc7c4f9f2d7ff1d8ace14b91af59752f943' +def ipLocationChecksum = '06db87f5525f9db4ae7881f8799462cce76c0c264123153525506bce5d577ac2' +def ipLocationArchive = layout.buildDirectory.file('ip-location-maker/source.zip') +def ipLocationSource = layout.buildDirectory.dir('ip-location-maker/upstream') +def ipLocationResources = layout.buildDirectory.dir('generated/ip-location') +def downloadIpLocationSource = tasks.register('downloadIpLocationSource') { + inputs.property('revision', ipLocationRevision) + inputs.property('checksum', ipLocationChecksum) + outputs.file(ipLocationArchive) + doLast { + def target = ipLocationArchive.get().asFile + target.parentFile.mkdirs() + def digest = { File file -> + def sha = java.security.MessageDigest.getInstance('SHA-256') + file.withInputStream { stream -> + byte[] buffer = new byte[8192] + int count + while ((count = stream.read(buffer)) != -1) { + sha.update(buffer, 0, count) + } + } + sha.digest().encodeHex().toString() + } + if (!target.exists() || digest(target) != ipLocationChecksum) { + def connection = URI.create("https://codeload.github.com/lionsoul2014/ip2region/zip/${ipLocationRevision}").toURL().openConnection() + connection.connectTimeout = 30000 + connection.readTimeout = 60000 + connection.inputStream.withCloseable { input -> + target.withOutputStream { output -> output << input } + } + if (digest(target) != ipLocationChecksum) { + throw new GradleException('IP location source checksum mismatch') + } + } + } +} +def extractIpLocationSource = tasks.register('extractIpLocationSource', Sync) { + dependsOn downloadIpLocationSource + from({ zipTree(ipLocationArchive.get().asFile) }) { + include '*/maker/java/src/main/java/**', '*/data/ipv4_source.txt', + '*/data/ipv6_source.txt', '*/LICENSE.md' + eachFile { path = path.substring(path.indexOf('/') + 1) } + includeEmptyDirs = false + } + into ipLocationSource +} +def compileIpLocationMaker = tasks.register('compileIpLocationMaker', JavaCompile) { + dependsOn extractIpLocationSource + source ipLocationSource.map { it.dir('maker/java/src/main/java') } + classpath = files() + destinationDirectory = layout.buildDirectory.dir('ip-location-maker/classes') +} +def prepareIpLocationDatabase = tasks.register('prepareIpLocationDatabase') +[4, 6].each { version -> + def database = ipLocationResources.map { it.file("ip-location/ip2region_v${version}.xdb") } + def sourceData = ipLocationSource.map { it.file("data/ipv${version}_source.txt") } + def generateDatabase = tasks.register("generateIpLocationV${version}", JavaExec) { + dependsOn compileIpLocationMaker + classpath = files(compileIpLocationMaker.flatMap { it.destinationDirectory }) + mainClass = 'org.lionsoul.ip2region.MakerApp' + maxHeapSize = '512m' + inputs.file(sourceData) + inputs.property('fields', '0,1,4') + outputs.file(database) + doFirst { + database.get().asFile.parentFile.mkdirs() + args = ["--src=${sourceData.get().asFile}", "--dst=${database.get().asFile}", + "--version=ipv${version}", '--field-list=0,1,4'] + } + } + prepareIpLocationDatabase.configure { dependsOn generateDatabase } +} + tasks.named('processResources', Copy) { + dependsOn tasks.named('prepareIpLocationDatabase') + from(ipLocationResources) { + include 'ip-location/*.xdb' + } + from(ipLocationSource.map { it.file('LICENSE.md') }) { + into 'ip-location' + rename { 'LICENSE.txt' } + } dependsOn project(':packages').tasks.named('pnpmBuild') from(project(':packages').layout.projectDirectory.dir('ui/build/dist')) { into 'ui' diff --git a/dev/development.md b/dev/development.md index 6086aeb..47b59e3 100644 --- a/dev/development.md +++ b/dev/development.md @@ -22,3 +22,9 @@ cd path/to/plugin-comment-widget ``` Halo 插件的详细开发文档可查阅 [插件开发](https://docs.halo.run/category/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91)。 + +## IP 属地数据库 + +`processResources` 会通过 `prepareIpLocationDatabase` 下载固定提交的 ip2region 源码归档并校验 SHA-256,使用官方 Java maker 的 `--field-list=0,1,4` 从 IPv4/IPv6 数据中仅保留国家、省份、国家代码,再将精简库和许可证打入 JAR。maker 使用项目的 JDK 编译,仅在构建时运行,不打入插件。更新数据时需要同时修改 `build.gradle` 中的提交和归档校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。 + +运行时首次解析会将数据库解压到临时目录(约 23 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。 diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 9227d86..4e16d51 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -43,6 +43,9 @@ export class BaseCommentItem extends LitElement { @property({ type: String }) ua: string | undefined; + @property({ type: String }) + ipLocation: string | undefined; + @property({ type: Boolean }) private: boolean | undefined; @@ -105,6 +108,8 @@ export class BaseCommentItem extends LitElement { ${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html``)} + ${when(this.configMapData?.basic.showIpLocation, () => html`${msg('IP location')}: ${this.ipLocation || msg('Unknown')}`)} + ${when(!this.approved, () => html`
${msg('Reviewing')}
`)} diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index 1a50609..9575c35 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -188,6 +188,7 @@ export class CommentItem extends LitElement { .pinned=${this.comment?.spec.top} .userWebsite=${this.comment?.spec.owner.annotations?.website} .ua=${this.comment?.spec.userAgent} + .ipLocation=${this.comment?.metadata.annotations?.['commentwidget.halo.run/ip-location']} .private=${this.comment?.spec.hidden} .editing=${this.showEditForm} > diff --git a/packages/comment-widget/src/generated/locales/es.ts b/packages/comment-widget/src/generated/locales/es.ts index 374f5e9..b2482c1 100644 --- a/packages/comment-widget/src/generated/locales/es.ts +++ b/packages/comment-widget/src/generated/locales/es.ts @@ -31,6 +31,7 @@ 's22ae36191f9a1a8d': `No se pudo cargar el contenido más reciente. Inténtalo de nuevo más tarde.`, 's2406b89e991a4524': `Actualizar código de verificación`, 's26e4d65f2801ac9c': `Por favor, ingrese el contenido`, +'s28dcec8c25fe4ec3': `Ubicación de IP`, 's299b10f3a58a09fd': `Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.`, 's2c8189544e3ea679': `Reintentar`, 's2ca7699f7c5a4996': `Administrar`, @@ -87,6 +88,7 @@ 'sbc0cd0b4dfb7dde1': `Desfijar`, 'sc0ffeb5d53b60209': `Error al subir`, 'sc29d248fb3e5c59b': `Ver todas las respuestas`, +'sc592307ea80f16b9': `Desconocido`, 'sc59a34be5493a0af': `Error al subir. Inténtalo de nuevo.`, 'sc8da3cc71de63832': `Iniciar sesión`, 'sd1f44f1a8bc20e67': `Correo electrónico`, diff --git a/packages/comment-widget/src/generated/locales/zh-CN.ts b/packages/comment-widget/src/generated/locales/zh-CN.ts index d365136..8dcee95 100644 --- a/packages/comment-widget/src/generated/locales/zh-CN.ts +++ b/packages/comment-widget/src/generated/locales/zh-CN.ts @@ -31,6 +31,7 @@ 's22ae36191f9a1a8d': `加载最新内容失败,请稍后重试。`, 's2406b89e991a4524': `刷新验证码`, 's26e4d65f2801ac9c': `请输入内容`, +'s28dcec8c25fe4ec3': `IP 属地`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, 's2c8189544e3ea679': `重试`, 's2ca7699f7c5a4996': `管理`, @@ -87,6 +88,7 @@ 'sbc0cd0b4dfb7dde1': `取消置顶`, 'sc0ffeb5d53b60209': `上传失败`, 'sc29d248fb3e5c59b': `查看全部回复`, +'sc592307ea80f16b9': `未知`, 'sc59a34be5493a0af': `上传失败,请重试。`, 'sc8da3cc71de63832': `登录`, 'sd1f44f1a8bc20e67': `电子邮件`, diff --git a/packages/comment-widget/src/generated/locales/zh-TW.ts b/packages/comment-widget/src/generated/locales/zh-TW.ts index cadaa87..9ddbd82 100644 --- a/packages/comment-widget/src/generated/locales/zh-TW.ts +++ b/packages/comment-widget/src/generated/locales/zh-TW.ts @@ -31,6 +31,7 @@ 's22ae36191f9a1a8d': `載入最新內容失敗,請稍後再試。`, 's2406b89e991a4524': `重新整理驗證碼`, 's26e4d65f2801ac9c': `請輸入內容`, +'s28dcec8c25fe4ec3': `IP 屬地`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, 's2c8189544e3ea679': `重試`, 's2ca7699f7c5a4996': `管理`, @@ -87,6 +88,7 @@ 'sbc0cd0b4dfb7dde1': `取消置頂`, 'sc0ffeb5d53b60209': `上傳失敗`, 'sc29d248fb3e5c59b': `查看所有回覆`, +'sc592307ea80f16b9': `未知`, 'sc59a34be5493a0af': `上傳失敗,請重試。`, 'sc8da3cc71de63832': `登入`, 'sd1f44f1a8bc20e67': `電子郵件`, diff --git a/packages/comment-widget/src/reply-item.ts b/packages/comment-widget/src/reply-item.ts index 8f8809c..4d934ea 100644 --- a/packages/comment-widget/src/reply-item.ts +++ b/packages/comment-widget/src/reply-item.ts @@ -183,6 +183,7 @@ export class ReplyItem extends LitElement { .breath=${this.isQuoteReplyHovered} .userWebsite=${this.reply?.spec.owner.annotations?.website} .ua=${this.reply?.spec.userAgent} + .ipLocation=${this.reply?.metadata.annotations?.['commentwidget.halo.run/ip-location']} .private=${this.comment?.spec.hidden || this.reply?.spec.hidden} .editing=${this.showEditForm} > diff --git a/packages/comment-widget/src/types/index.ts b/packages/comment-widget/src/types/index.ts index bd67f61..fc90cba 100644 --- a/packages/comment-widget/src/types/index.ts +++ b/packages/comment-widget/src/types/index.ts @@ -13,6 +13,7 @@ interface BasicConfig { replySize: number; allowNotificationConfigurable?: boolean; showCommenterDevice?: boolean; + showIpLocation?: boolean; enablePrivateComment?: boolean; showPrivateCommentBadge?: boolean; } diff --git a/packages/comment-widget/tests/ip-location.browser.test.js b/packages/comment-widget/tests/ip-location.browser.test.js new file mode 100644 index 0000000..f915057 --- /dev/null +++ b/packages/comment-widget/tests/ip-location.browser.test.js @@ -0,0 +1,78 @@ +import { ContextProvider } from '@lit/context'; +import { expect, test } from 'vitest'; +import { configMapDataContext } from '../src/context'; +import '../src/comment-item'; +import '../src/reply-item'; + +const locationKey = 'commentwidget.halo.run/ip-location'; + +function target(name, ipLocation) { + return { + metadata: { + name, + annotations: ipLocation ? { [locationKey]: ipLocation } : {}, + }, + spec: { + owner: { kind: 'Email', annotations: {} }, + content: '

A comment

', + approved: true, + creationTime: '2026-10-30T00:00:00Z', + // Even privileged callers must never display an IP as a location fallback. + ipAddress: '1.0.1.1', + }, + owner: { displayName: 'Reader' }, + stats: { upvote: 0 }, + status: { visibleReplyCount: 0 }, + }; +} + +async function mount(tag, showIpLocation, ipLocation) { + const host = document.createElement('div'); + new ContextProvider(host, { + context: configMapDataContext, + initialValue: { basic: { showIpLocation } }, + }); + const item = document.createElement(tag); + item.comment = target('comment', ipLocation); + if (tag === 'reply-item') item.reply = target('reply', ipLocation); + host.append(item); + document.body.append(host); + await item.updateComplete; + const base = item.shadowRoot.querySelector('base-comment-item'); + await base.updateComplete; + return { host, item, base }; +} + +for (const tag of ['comment-item', 'reply-item']) { + test(`${tag} displays location or unknown only when enabled`, async () => { + for (const enabled of [true, false, undefined]) { + for (const location of ['浙江省', '', undefined]) { + const { host, base } = await mount(tag, enabled, location); + const badge = base.shadowRoot.querySelector('.item-ip-location'); + expect(!!badge).toBe(!!enabled); + if (badge) expect(badge.textContent).toContain(location || 'Unknown'); + expect(base.shadowRoot.textContent).not.toContain('1.0.1.1'); + host.remove(); + } + } + }); + + test(`${tag} renders annotations as text and reacts to refreshed data`, async () => { + const { item, base } = await mount( + tag, + true, + '' + ); + const badge = base.shadowRoot.querySelector('.item-ip-location'); + expect(badge.textContent).toContain(''); + expect(badge.querySelector('img')).toBeNull(); + + const property = tag === 'reply-item' ? 'reply' : 'comment'; + item[property] = target(property, undefined); + await item.updateComplete; + await base.updateComplete; + expect( + base.shadowRoot.querySelector('.item-ip-location').textContent + ).toContain('Unknown'); + }); +} diff --git a/packages/comment-widget/xliff/es.xlf b/packages/comment-widget/xliff/es.xlf index f467db7..93f5b5c 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -386,6 +386,14 @@ Allow notification Permitir notificación + + IP location + Ubicación de IP + + + Unknown + Desconocido + diff --git a/packages/comment-widget/xliff/zh-CN.xlf b/packages/comment-widget/xliff/zh-CN.xlf index 4f16661..1b5ded0 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -386,6 +386,14 @@ Allow notification 允许通知 + + IP location + IP 属地 + + + Unknown + 未知 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 7342c8b..8b3758d 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -386,6 +386,14 @@ Allow notification 允許通知 + + IP location + IP 屬地 + + + Unknown + 未知 + diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java index 81d1bb6..76d8279 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -131,6 +131,7 @@ class BasicConfig { private boolean withReplies; private int withReplySize; private boolean allowNotificationConfigurable; + private boolean showIpLocation; } @Data @@ -141,4 +142,4 @@ class AvatarConfig { private String providerMirror; private String policy; } -} \ No newline at end of file +} diff --git a/src/main/java/run/halo/comment/widget/location/CommentIpLocationReconciler.java b/src/main/java/run/halo/comment/widget/location/CommentIpLocationReconciler.java new file mode 100644 index 0000000..dfc2c07 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/location/CommentIpLocationReconciler.java @@ -0,0 +1,26 @@ +package run.halo.comment.widget.location; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; + +@Component +@RequiredArgsConstructor +public class CommentIpLocationReconciler implements Reconciler { + + private final IpLocationService locations; + + @Override + public Result reconcile(Request request) { + locations.reconcileComment(request.name()); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder.extension(new Comment()).syncAllOnStart(false).build(); + } +} diff --git a/src/main/java/run/halo/comment/widget/location/IpLocationBackfillReconciler.java b/src/main/java/run/halo/comment/widget/location/IpLocationBackfillReconciler.java new file mode 100644 index 0000000..4237218 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/location/IpLocationBackfillReconciler.java @@ -0,0 +1,95 @@ +package run.halo.comment.widget.location; + +import static run.halo.app.extension.index.query.Queries.equal; +import static run.halo.app.extension.index.query.Queries.greaterThan; + +import java.time.Duration; +import java.util.Objects; +import java.util.function.Consumer; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Extension; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.plugin.PluginContext; + +/** A settings change or plugin restart starts a bounded, resumable history scan. */ +@Component +@RequiredArgsConstructor +public class IpLocationBackfillReconciler implements Reconciler { + + static final int BATCH_SIZE = 100; + private final ExtensionClient client; + private final PluginContext context; + private final IpLocationService locations; + private String cursor; + private boolean scanningReplies; + private Long settingsVersion; + + @Override + public Result reconcile(Request request) { + if (!context.getConfigMapName().equals(request.name())) { + return Result.doNotRetry(); + } + var version = client.fetch(ConfigMap.class, request.name()) + .map(config -> config.getMetadata().getVersion()).orElse(null); + if (!Objects.equals(settingsVersion, version)) { + // A quick off/on toggle can be coalesced into one event. Rescan records + // skipped while disabled instead of continuing past them with the old cursor. + reset(); + settingsVersion = version; + } + if (!locations.isEnabled()) { + reset(); + return Result.doNotRetry(); + } + if (!scanningReplies) { + if (page(Comment.class, locations::reconcileComment)) { + scanningReplies = true; + cursor = null; + } + } else if (page(Reply.class, locations::reconcileReply)) { + reset(); + return Result.doNotRetry(); + } + return Result.requeue(Duration.ofSeconds(1)); + } + + private boolean page(Class type, Consumer reconcile) { + var options = ListOptions.builder(); + if (cursor != null) { + options.andQuery(greaterThan("metadata.name", cursor)); + } + var names = client.listNamesBy(type, options.build(), + PageRequestImpl.ofSize(BATCH_SIZE).withSort(Sort.by("metadata.name"))).getItems(); + for (var name : names) { + reconcile.accept(name); + cursor = name; + } + return names.size() < BATCH_SIZE; + } + + private void reset() { + cursor = null; + scanningReplies = false; + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + var name = context.getConfigMapName(); + return builder.extension(new ConfigMap()) + .onAddMatcher(config -> name.equals(config.getMetadata().getName())) + .onUpdateMatcher(config -> name.equals(config.getMetadata().getName())) + .onDeleteMatcher(config -> name.equals(config.getMetadata().getName())) + .syncAllListOptions(ListOptions.builder().andQuery(equal("metadata.name", name)).build()) + .build(); + } +} diff --git a/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java b/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java new file mode 100644 index 0000000..d800c61 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java @@ -0,0 +1,119 @@ +package run.halo.comment.widget.location; + +import com.google.common.net.InetAddresses; +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import org.lionsoul.ip2region.service.Config; +import org.lionsoul.ip2region.service.Ip2Region; +import org.lionsoul.ip2region.service.InvalidConfigException; +import org.lionsoul.ip2region.xdb.InetAddressException; +import org.lionsoul.ip2region.xdb.XdbException; +import org.springframework.stereotype.Component; + +/** Offline lookups run only on controller workers. No IP is sent to a remote service. */ +@Component +public class IpLocationResolver { + + private Ip2Region searcher; + private Path directory; + private boolean closed; + + public synchronized String resolve(String ip) { + if (closed || ip == null || ip.isBlank()) { + return ""; + } + final InetAddress address; + try { + // ip2region's string overload accepts hostnames; use numeric bytes instead. + address = InetAddresses.forString(ip.trim()); + } catch (IllegalArgumentException e) { + return ""; + } + try { + var bytes = address.getAddress(); + if (address.isAnyLocalAddress() || address.isLoopbackAddress() + || address.isLinkLocalAddress() || address.isSiteLocalAddress() + || address.isMulticastAddress() + || (bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc)) { + return ""; + } + if (searcher == null) { + initialize(); + } + return displayLocation(searcher.search(bytes)); + } catch (InetAddressException e) { + return ""; + } catch (IOException | XdbException | InvalidConfigException e) { + throw new IllegalStateException("Cannot read the IP location database", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("IP location lookup interrupted", e); + } + } + + private void initialize() throws IOException, XdbException, InvalidConfigException { + directory = Files.createTempDirectory("halo-comment-ip-location-"); + try { + var v4 = Config.custom().setCachePolicy(Config.VIndexCache).setSearchers(1) + .setXdbPath(extract("ip2region_v4.xdb").toString()).asV4(); + var v6 = Config.custom().setCachePolicy(Config.VIndexCache).setSearchers(1) + .setXdbPath(extract("ip2region_v6.xdb").toString()).asV6(); + searcher = Ip2Region.create(v4, v6); + } catch (IOException | XdbException | InvalidConfigException | RuntimeException e) { + deleteFiles(); + throw e; + } + } + + private Path extract(String name) throws IOException { + var file = directory.resolve(name); + try (var input = getClass().getResourceAsStream("/ip-location/" + name)) { + if (input == null) { + throw new IOException("Missing IP location database: " + name); + } + Files.copy(input, file); + } + return file; + } + + static String displayLocation(String region) { + if (region == null) { + return ""; + } + // The pinned database contains country|province|countryCode. + var fields = region.split("\\|", -1); + if (fields.length != 3 || !fields[2].matches("[A-Z]{2}")) { + return ""; + } + if ("CN".equals(fields[2])) { + return usable(fields[1]) ? fields[1] : "中国"; + } + return usable(fields[0]) ? fields[0] : ""; + } + + private static boolean usable(String value) { + return !value.isBlank() && !"0".equals(value) && !"Reserved".equals(value); + } + + @PreDestroy + public synchronized void close() throws IOException, InterruptedException { + closed = true; + if (searcher != null) { + searcher.close(); + searcher = null; + } + deleteFiles(); + } + + private void deleteFiles() throws IOException { + if (directory != null) { + Files.deleteIfExists(directory.resolve("ip2region_v4.xdb")); + Files.deleteIfExists(directory.resolve("ip2region_v6.xdb")); + Files.deleteIfExists(directory); + directory = null; + } + } +} diff --git a/src/main/java/run/halo/comment/widget/location/IpLocationService.java b/src/main/java/run/halo/comment/widget/location/IpLocationService.java new file mode 100644 index 0000000..e94e723 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/location/IpLocationService.java @@ -0,0 +1,71 @@ +package run.halo.comment.widget.location; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.plugin.PluginContext; +import run.halo.comment.widget.SettingConfigGetter.BasicConfig; + +@Component +@RequiredArgsConstructor +public class IpLocationService { + + public static final String ANNOTATION = "commentwidget.halo.run/ip-location"; + private final ExtensionClient client; + private final PluginContext context; + private final IpLocationResolver resolver; + private final ObjectMapper mapper = new ObjectMapper(); + + boolean isEnabled() { + // Read the current ConfigMap: its controller may run before the settings cache refreshes. + return client.fetch(ConfigMap.class, context.getConfigMapName()) + .map(ConfigMap::getData) + .map(data -> data.get(BasicConfig.GROUP)) + .map(this::enabled) + .orElse(false); + } + + private boolean enabled(String basic) { + try { + return mapper.readTree(basic).path("showIpLocation").asBoolean(false); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot read IP location settings", e); + } + } + + void reconcileComment(String name) { + reconcile(Comment.class, name, comment -> comment.getSpec().getIpAddress()); + } + + void reconcileReply(String name) { + reconcile(Reply.class, name, reply -> reply.getSpec().getIpAddress()); + } + + private void reconcile(Class type, String name, + Function ipAddress) { + if (!isEnabled()) { + return; + } + client.fetch(type, name).ifPresent(target -> { + if (target.getMetadata().getDeletionTimestamp() != null + || target.getMetadata().getAnnotations() != null + && target.getMetadata().getAnnotations().containsKey(ANNOTATION)) { + return; + } + var location = resolver.resolve(ipAddress.apply(target)); + if (!location.isEmpty() && isEnabled()) { + MetadataUtil.nullSafeAnnotations(target).put(ANNOTATION, location); + // Version conflicts are retried by the controller with a fresh read. + client.update(target); + } + }); + } +} diff --git a/src/main/java/run/halo/comment/widget/location/ReplyIpLocationReconciler.java b/src/main/java/run/halo/comment/widget/location/ReplyIpLocationReconciler.java new file mode 100644 index 0000000..92dd76d --- /dev/null +++ b/src/main/java/run/halo/comment/widget/location/ReplyIpLocationReconciler.java @@ -0,0 +1,26 @@ +package run.halo.comment.widget.location; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; + +@Component +@RequiredArgsConstructor +public class ReplyIpLocationReconciler implements Reconciler { + + private final IpLocationService locations; + + @Override + public Result reconcile(Request request) { + locations.reconcileReply(request.name()); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder.extension(new Reply()).syncAllOnStart(false).build(); + } +} diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 7111dc4..515201a 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -43,6 +43,11 @@ spec: label: 显示评论者设备信息 name: showCommenterDevice value: false + - $formkit: checkbox + label: 显示 IP 属地 + name: showIpLocation + value: false + help: 根据评论发表时的 IP 离线解析,中国大陆显示省份,其他显示国家或地区。开启后会逐步补齐历史评论;关闭仅隐藏展示,已保存的地区仍可能通过公开接口读取。 - $formkit: checkbox label: 允许用户选择是否接收通知 name: allowNotificationConfigurable diff --git a/src/test/java/run/halo/comment/widget/location/IpLocationBackfillReconcilerTest.java b/src/test/java/run/halo/comment/widget/location/IpLocationBackfillReconcilerTest.java new file mode 100644 index 0000000..56092ea --- /dev/null +++ b/src/test/java/run/halo/comment/widget/location/IpLocationBackfillReconcilerTest.java @@ -0,0 +1,103 @@ +package run.halo.comment.widget.location; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.springframework.dao.OptimisticLockingFailureException; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.controller.Reconciler.Request; +import run.halo.app.plugin.PluginContext; + +class IpLocationBackfillReconcilerTest { + + private final ExtensionClient client = mock(ExtensionClient.class); + private final IpLocationService service = mock(IpLocationService.class); + private final PluginContext context = PluginContext.builder().configMapName("settings").build(); + private final IpLocationBackfillReconciler reconciler = + new IpLocationBackfillReconciler(client, context, service); + private final Request request = new Request("settings"); + + @Test + void scansCommentsAndRepliesInBoundedKeysetPages() { + when(service.isEnabled()).thenReturn(true); + var names = IntStream.range(0, 100).mapToObj(i -> "comment-%03d".formatted(i)).toList(); + when(client.listNamesBy(eq(Comment.class), any(), any())) + .thenReturn(new ListResult<>(names), new ListResult<>(List.of("last-comment"))); + when(client.listNamesBy(eq(Reply.class), any(), any())) + .thenReturn(new ListResult<>(List.of("reply"))); + + assertThat(reconciler.reconcile(request).reEnqueue()).isTrue(); + verify(service, times(100)).reconcileComment(any()); + verify(service, never()).reconcileReply(any()); + assertThat(reconciler.reconcile(request).reEnqueue()).isTrue(); + verify(client).listNamesBy(eq(Comment.class), + argThat(options -> options.toString().contains("comment-099")), any()); + assertThat(reconciler.reconcile(request).reEnqueue()).isFalse(); + verify(service).reconcileReply("reply"); + } + + @Test + void resumesAfterAConflictAndRestartsAfterReenabling() { + when(service.isEnabled()).thenReturn(true); + when(client.listNamesBy(eq(Comment.class), any(), any())) + .thenReturn(new ListResult<>(List.of("a", "b")), + new ListResult<>(List.of("b")), new ListResult<>(List.of("a", "b"))); + doThrow(new OptimisticLockingFailureException("conflict")).doNothing() + .when(service).reconcileComment("b"); + + assertThatThrownBy(() -> reconciler.reconcile(request)) + .isInstanceOf(OptimisticLockingFailureException.class); + assertThat(reconciler.reconcile(request).reEnqueue()).isTrue(); + verify(client).listNamesBy(eq(Comment.class), + argThat(options -> options.toString().contains("a")), any()); + + when(service.isEnabled()).thenReturn(false); + assertThat(reconciler.reconcile(request).reEnqueue()).isFalse(); + when(service.isEnabled()).thenReturn(true); + assertThat(reconciler.reconcile(request).reEnqueue()).isTrue(); + verify(service, times(2)).reconcileComment("a"); + } + + @Test + void ignoresOtherSettingsAndDoesNotScanWhileDisabled() { + reconciler.reconcile(new Request("another-plugin")); + verifyNoInteractions(service, client); + assertThat(reconciler.reconcile(request).reEnqueue()).isFalse(); + verify(client, never()).listNamesBy(any(), any(), any()); + } + + @Test + void restartsTheCursorIfSettingsChangeDuringABatch() { + var config = new ConfigMap(); + var metadata = new Metadata(); + metadata.setVersion(1L); + config.setMetadata(metadata); + when(client.fetch(ConfigMap.class, "settings")).thenReturn(Optional.of(config)); + when(service.isEnabled()).thenReturn(true); + var names = IntStream.range(0, 100).mapToObj(i -> "comment-%03d".formatted(i)).toList(); + when(client.listNamesBy(eq(Comment.class), any(), any())) + .thenReturn(new ListResult<>(names)); + doAnswer(invocation -> { + // Disable and re-enable before the next controller invocation. + metadata.setVersion(3L); + return null; + }).when(service).reconcileComment("comment-000"); + + reconciler.reconcile(request); + reconciler.reconcile(request); + + verify(client, times(2)).listNamesBy(eq(Comment.class), + argThat(options -> !options.toString().contains("comment-099")), any()); + verify(service, times(2)).reconcileComment("comment-000"); + } +} diff --git a/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java b/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java new file mode 100644 index 0000000..e13808f --- /dev/null +++ b/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java @@ -0,0 +1,57 @@ +package run.halo.comment.widget.location; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class IpLocationResolverTest { + + @ParameterizedTest + @CsvSource(delimiter = ';', value = { + "中国|浙江省|CN;浙江省", + "中国|0|CN;中国", + "中国|香港特别行政区|CN;香港特别行政区", + "United States|California|US;United States", + "Reserved|Reserved|0;''", + "0|0|US;''", + "unexpected format;''" + }) + void limitsTheDisplayToProvinceOrCountry(String region, String expected) { + assertThat(IpLocationResolver.displayLocation(region)).isEqualTo(expected); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + " ", "unknown", "example.com", "1.2.3.999", "1.2.3", "0.0.0.0", "127.0.0.1", + "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.1.1", "224.0.0.1", + "::", "::1", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:192.168.1.1" + }) + void ignoresInvalidAndNonPublicAddresses(String ip) throws Exception { + var resolver = new IpLocationResolver(); + try { + assertThat(resolver.resolve(ip)).isEmpty(); + } finally { + resolver.close(); + } + } + + @Test + void queriesBothBundledDatabasesAndClosesResources() throws Exception { + var resolver = new IpLocationResolver(); + try { + assertThat(resolver.resolve("1.0.1.1")).isEqualTo("福建省"); + assertThat(resolver.resolve(" 1.0.0.1 ")).isEqualTo("Australia"); + assertThat(resolver.resolve("::ffff:1.0.1.1")).isEqualTo("福建省"); + assertThat(resolver.resolve("240e:3b7:3272:d8d0:db09:c067:8d59:539e")) + .isNotBlank().doesNotContain("|", ":"); + } finally { + resolver.close(); + } + assertThat(resolver.resolve("1.0.1.1")).isEmpty(); + } +} diff --git a/src/test/java/run/halo/comment/widget/location/IpLocationServiceTest.java b/src/test/java/run/halo/comment/widget/location/IpLocationServiceTest.java new file mode 100644 index 0000000..bec4e22 --- /dev/null +++ b/src/test/java/run/halo/comment/widget/location/IpLocationServiceTest.java @@ -0,0 +1,134 @@ +package run.halo.comment.widget.location; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.OptimisticLockingFailureException; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; +import run.halo.app.plugin.PluginContext; + +class IpLocationServiceTest { + + private final ExtensionClient client = mock(ExtensionClient.class); + private final IpLocationResolver resolver = mock(IpLocationResolver.class); + private final PluginContext context = PluginContext.builder().configMapName("settings").build(); + private final IpLocationService service = new IpLocationService(client, context, resolver); + private final ConfigMap config = new ConfigMap(); + + @BeforeEach + void setUp() { + config.setData(new HashMap<>(Map.of("basic", "{\"showIpLocation\":true}"))); + when(client.fetch(ConfigMap.class, "settings")).thenReturn(Optional.of(config)); + when(resolver.resolve("1.0.1.1")).thenReturn("福建省"); + } + + @Test + void enrichesCommentsAndRepliesOnceWithoutCopyingTheIp() { + var comment = comment(); + comment.getMetadata().setAnnotations(new HashMap<>(Map.of("existing", "keep"))); + var reply = new Reply(); + reply.setMetadata(metadata("reply")); + var spec = new Reply.ReplySpec(); + spec.setIpAddress("1.0.1.1"); + reply.setSpec(spec); + when(client.fetch(Comment.class, "comment")).thenReturn(Optional.of(comment)); + when(client.fetch(Reply.class, "reply")).thenReturn(Optional.of(reply)); + + service.reconcileComment("comment"); + service.reconcileReply("reply"); + service.reconcileComment("comment"); + service.reconcileReply("reply"); + + assertThat(comment.getMetadata().getAnnotations()).containsExactlyInAnyOrderEntriesOf( + Map.of("existing", "keep", IpLocationService.ANNOTATION, "福建省")); + assertThat(reply.getMetadata().getAnnotations()) + .containsExactlyEntriesOf(Map.of(IpLocationService.ANNOTATION, "福建省")); + assertThat(comment.getSpec().getIpAddress()).isEqualTo("1.0.1.1"); + verify(client).update(comment); + verify(client).update(reply); + verify(resolver, times(2)).resolve("1.0.1.1"); + } + + @Test + void doesNothingWhenDisabledOrSettingsAreAbsent() { + for (var settings : new String[] {"{}", "{\"showIpLocation\":false}"}) { + config.getData().put("basic", settings); + service.reconcileComment("comment"); + service.reconcileReply("reply"); + } + config.setData(null); + service.reconcileComment("comment"); + when(client.fetch(ConfigMap.class, "settings")).thenReturn(Optional.empty()); + service.reconcileReply("reply"); + verifyNoInteractions(resolver); + verify(client, never()).update(any()); + } + + @Test + void skipsDeletedRecordsAndEmptyResults() { + var comment = comment(); + when(client.fetch(Comment.class, "comment")).thenReturn(Optional.of(comment)); + comment.getMetadata().setDeletionTimestamp(Instant.now()); + service.reconcileComment("comment"); + verifyNoInteractions(resolver); + + comment.getMetadata().setDeletionTimestamp(null); + when(resolver.resolve(any())).thenReturn(""); + service.reconcileComment("comment"); + verify(client, never()).update(any()); + assertThat(comment.getMetadata().getAnnotations()).isNull(); + } + + @Test + void checksTheSwitchAgainBeforePublishingAResult() { + when(client.fetch(Comment.class, "comment")).thenReturn(Optional.of(comment())); + when(resolver.resolve(any())).thenAnswer(invocation -> { + config.getData().put("basic", "{\"showIpLocation\":false}"); + return "福建省"; + }); + service.reconcileComment("comment"); + verify(client, never()).update(any()); + } + + @Test + void aConflictCanBeRetriedWithoutOverwritingConcurrentChanges() { + var first = comment(); + var updated = comment(); + updated.getMetadata().setAnnotations(new HashMap<>(Map.of("other", "new"))); + when(client.fetch(Comment.class, "comment")) + .thenReturn(Optional.of(first), Optional.of(updated)); + doThrow(new OptimisticLockingFailureException("conflict")).when(client).update(first); + + assertThatThrownBy(() -> service.reconcileComment("comment")) + .isInstanceOf(OptimisticLockingFailureException.class); + service.reconcileComment("comment"); + verify(client).update(updated); + assertThat(updated.getMetadata().getAnnotations()).containsEntry("other", "new"); + } + + private static Comment comment() { + var comment = new Comment(); + comment.setMetadata(metadata("comment")); + var spec = new Comment.CommentSpec(); + spec.setIpAddress("1.0.1.1"); + comment.setSpec(spec); + return comment; + } + + private static Metadata metadata(String name) { + var metadata = new Metadata(); + metadata.setName(name); + return metadata; + } +}