From e907cecb16b7f371216ec421d302cc26826e32f3 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 16:12:22 +0800 Subject: [PATCH 1/6] feat: display IP locations for comments and replies --- README.md | 7 + build.gradle | 47 ++++++ dev/development.md | 6 + .../comment-widget/src/base-comment-item.ts | 5 + packages/comment-widget/src/comment-item.ts | 1 + .../src/generated/locales/es.ts | 1 + .../src/generated/locales/zh-CN.ts | 1 + .../src/generated/locales/zh-TW.ts | 1 + packages/comment-widget/src/reply-item.ts | 1 + packages/comment-widget/src/types/index.ts | 1 + .../tests/ip-location.browser.test.js | 76 ++++++++++ packages/comment-widget/xliff/es.xlf | 4 + packages/comment-widget/xliff/zh-CN.xlf | 4 + packages/comment-widget/xliff/zh-TW.xlf | 4 + .../comment/widget/SettingConfigGetter.java | 3 +- .../location/CommentIpLocationReconciler.java | 26 ++++ .../IpLocationBackfillReconciler.java | 95 +++++++++++++ .../widget/location/IpLocationResolver.java | 119 ++++++++++++++++ .../widget/location/IpLocationService.java | 71 ++++++++++ .../location/ReplyIpLocationReconciler.java | 26 ++++ src/main/resources/extensions/settings.yaml | 5 + .../IpLocationBackfillReconcilerTest.java | 103 ++++++++++++++ .../location/IpLocationResolverTest.java | 57 ++++++++ .../location/IpLocationServiceTest.java | 134 ++++++++++++++++++ 24 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 packages/comment-widget/tests/ip-location.browser.test.js create mode 100644 src/main/java/run/halo/comment/widget/location/CommentIpLocationReconciler.java create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationBackfillReconciler.java create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationResolver.java create mode 100644 src/main/java/run/halo/comment/widget/location/IpLocationService.java create mode 100644 src/main/java/run/halo/comment/widget/location/ReplyIpLocationReconciler.java create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationBackfillReconcilerTest.java create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java create mode 100644 src/test/java/run/halo/comment/widget/location/IpLocationServiceTest.java diff --git a/README.md b/README.md index f4f1aab..e3dde3f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式 - 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围 - 支持显示评论者设备信息 +- 支持离线解析并显示评论和回复的 IP 属地,开启后自动补齐历史评论 - 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式 ## 使用方式 @@ -27,6 +28,12 @@ > 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。 +## IP 属地 + +在插件「基本设置」中开启「显示 IP 属地」,评论和回复会显示发表时 IP 对应的省份(中国大陆)或国家/地区。默认关闭,内网、无效或无法定位的 IP 不显示属地。新评论异步解析,历史评论分批补齐;缺少原始 IP 的记录无法补齐。反向代理需要正确传递客户端 IP,否则只能解析代理的地址。 + +归属地由随插件打包的 [ip2region](https://github.com/lionsoul2014/ip2region) IPv4/IPv6 数据库离线解析,不发送 IP 给第三方。结果保存后不会随数据库升级自动改写。关闭开关仅隐藏组件中的展示,已保存的地区仍可能通过公开接口读取;原始 IP 不写入属地元数据。 + ## 主题适配 主题可通过 Halo 的自定义标签接入评论组件,并通过 CSS 变量自定义样式和配色。 diff --git a/build.gradle b/build.gradle index 74e7613..0492e0a 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,53 @@ test { sourceSets.main.resources.exclude 'ui/**', 'static/**' +// Pin the data separately from the Java client; updates must refresh these checksums. +def ipLocationRevision = '6a276cc7c4f9f2d7ff1d8ace14b91af59752f943' +def ipLocationFiles = [ + 'ip2region_v4.xdb': '46249ad1b33bcaa4cc37475b93746cec2b00acffc3316cc4f56167ac821ea2ad', + 'ip2region_v6.xdb': '939f6b46bd2b8bec3cf7c5ceb8ba782266ae9b1f35b5ba7916700dec0b7506ed', + 'LICENSE.txt': 'fe01f2f8fcaafac539154e6aa80b0b7f8af54e01dc4d52322f72971991c6280e' +] +def ipLocationResources = layout.buildDirectory.dir('generated/ip-location') +tasks.register('prepareIpLocationDatabase') { + inputs.property('revision', ipLocationRevision) + inputs.property('checksums', ipLocationFiles) + outputs.dir(ipLocationResources) + doLast { + def directory = ipLocationResources.get().dir('ip-location').asFile + directory.mkdirs() + ipLocationFiles.each { name, checksum -> + def target = new File(directory, name) + 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) != checksum) { + def path = name == 'LICENSE.txt' ? 'LICENSE.md' : "data/${name}" + def connection = URI.create("https://raw.githubusercontent.com/lionsoul2014/ip2region/${ipLocationRevision}/${path}").toURL().openConnection() + connection.connectTimeout = 30000 + connection.readTimeout = 60000 + connection.inputStream.withCloseable { input -> + target.withOutputStream { output -> output << input } + } + if (digest(target) != checksum) { + throw new GradleException("IP location database checksum mismatch: ${name}") + } + } + } + } +} + tasks.named('processResources', Copy) { + dependsOn tasks.named('prepareIpLocationDatabase') + from(ipLocationResources) 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..ee5c10a 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 IPv4/IPv6 数据库和许可证,校验 SHA-256 后打入 JAR。更新数据时需要同时修改 `build.gradle` 中的提交和校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。 + +运行时首次解析会将数据库解压到临时目录(约 47 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。 diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 9227d86..1372cdb 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; @@ -107,6 +110,8 @@ export class BaseCommentItem extends LitElement { + ${when(this.ipLocation && this.configMapData?.basic.showIpLocation, () => html`${msg('IP location')}: ${this.ipLocation}`)} + ${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..30d9657 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`, diff --git a/packages/comment-widget/src/generated/locales/zh-CN.ts b/packages/comment-widget/src/generated/locales/zh-CN.ts index d365136..37e4f09 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': `管理`, diff --git a/packages/comment-widget/src/generated/locales/zh-TW.ts b/packages/comment-widget/src/generated/locales/zh-TW.ts index cadaa87..e5325c2 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': `管理`, 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..7a12839 --- /dev/null +++ b/packages/comment-widget/tests/ip-location.browser.test.js @@ -0,0 +1,76 @@ +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 only when enabled and available`, 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 && !!location); + if (badge) expect(badge.textContent).toContain('浙江省'); + 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')).toBeNull(); + }); +} diff --git a/packages/comment-widget/xliff/es.xlf b/packages/comment-widget/xliff/es.xlf index f467db7..2404a8a 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -386,6 +386,10 @@ Allow notification Permitir notificación + + IP location + Ubicación de IP + diff --git a/packages/comment-widget/xliff/zh-CN.xlf b/packages/comment-widget/xliff/zh-CN.xlf index 4f16661..6cf3790 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -386,6 +386,10 @@ Allow notification 允许通知 + + IP location + IP 属地 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 7342c8b..4548098 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -386,6 +386,10 @@ Allow notification 允許通知 + + IP location + IP 屬地 + 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..9e38501 --- /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|city|ISP|countryCode. + var fields = region.split("\\|", -1); + if (fields.length != 5 || !fields[4].matches("[A-Z]{2}")) { + return ""; + } + if ("CN".equals(fields[4])) { + 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..8c259ac --- /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|0|0|CN;中国", + "中国|香港特别行政区|0|0|CN;香港特别行政区", + "United States|California|Los Angeles|0|US;United States", + "Reserved|Reserved|Reserved|0|0;''", + "0|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; + } +} From 48643b7ef4736b0ae3e408630ea56b952f5b44b2 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 16:44:31 +0800 Subject: [PATCH 2/6] build: reduce bundled IP location database size --- build.gradle | 104 ++++++++++++------ dev/development.md | 4 +- .../widget/location/IpLocationResolver.java | 6 +- .../location/IpLocationResolverTest.java | 12 +- 4 files changed, 81 insertions(+), 45 deletions(-) diff --git a/build.gradle b/build.gradle index 0492e0a..3993ca2 100644 --- a/build.gradle +++ b/build.gradle @@ -40,53 +40,89 @@ test { sourceSets.main.resources.exclude 'ui/**', 'static/**' -// Pin the data separately from the Java client; updates must refresh these checksums. +// Pin the source data and official maker together; updates must refresh the checksum. def ipLocationRevision = '6a276cc7c4f9f2d7ff1d8ace14b91af59752f943' -def ipLocationFiles = [ - 'ip2region_v4.xdb': '46249ad1b33bcaa4cc37475b93746cec2b00acffc3316cc4f56167ac821ea2ad', - 'ip2region_v6.xdb': '939f6b46bd2b8bec3cf7c5ceb8ba782266ae9b1f35b5ba7916700dec0b7506ed', - 'LICENSE.txt': 'fe01f2f8fcaafac539154e6aa80b0b7f8af54e01dc4d52322f72971991c6280e' -] +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') -tasks.register('prepareIpLocationDatabase') { +def downloadIpLocationSource = tasks.register('downloadIpLocationSource') { inputs.property('revision', ipLocationRevision) - inputs.property('checksums', ipLocationFiles) - outputs.dir(ipLocationResources) + inputs.property('checksum', ipLocationChecksum) + outputs.file(ipLocationArchive) doLast { - def directory = ipLocationResources.get().dir('ip-location').asFile - directory.mkdirs() - ipLocationFiles.each { name, checksum -> - def target = new File(directory, name) - 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) - } + 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) != checksum) { - def path = name == 'LICENSE.txt' ? 'LICENSE.md' : "data/${name}" - def connection = URI.create("https://raw.githubusercontent.com/lionsoul2014/ip2region/${ipLocationRevision}/${path}").toURL().openConnection() - connection.connectTimeout = 30000 - connection.readTimeout = 60000 - connection.inputStream.withCloseable { input -> - target.withOutputStream { output -> output << input } - } - if (digest(target) != checksum) { - throw new GradleException("IP location database checksum mismatch: ${name}") - } + 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) + 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 ee5c10a..47b59e3 100644 --- a/dev/development.md +++ b/dev/development.md @@ -25,6 +25,6 @@ Halo 插件的详细开发文档可查阅 [插件开发](https://docs.halo.run/c ## IP 属地数据库 -`processResources` 会通过 `prepareIpLocationDatabase` 下载固定提交的 ip2region IPv4/IPv6 数据库和许可证,校验 SHA-256 后打入 JAR。更新数据时需要同时修改 `build.gradle` 中的提交和校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。 +`processResources` 会通过 `prepareIpLocationDatabase` 下载固定提交的 ip2region 源码归档并校验 SHA-256,使用官方 Java maker 的 `--field-list=0,1,4` 从 IPv4/IPv6 数据中仅保留国家、省份、国家代码,再将精简库和许可证打入 JAR。maker 使用项目的 JDK 编译,仅在构建时运行,不打入插件。更新数据时需要同时修改 `build.gradle` 中的提交和归档校验值,并运行属地解析测试。数据库生成在 `build/generated/ip-location`,不提交二进制文件。 -运行时首次解析会将数据库解压到临时目录(约 47 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。 +运行时首次解析会将数据库解压到临时目录(约 23 MiB),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。 diff --git a/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java b/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java index 9e38501..d800c61 100644 --- a/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java +++ b/src/main/java/run/halo/comment/widget/location/IpLocationResolver.java @@ -83,12 +83,12 @@ static String displayLocation(String region) { if (region == null) { return ""; } - // The pinned database contains country|province|city|ISP|countryCode. + // The pinned database contains country|province|countryCode. var fields = region.split("\\|", -1); - if (fields.length != 5 || !fields[4].matches("[A-Z]{2}")) { + if (fields.length != 3 || !fields[2].matches("[A-Z]{2}")) { return ""; } - if ("CN".equals(fields[4])) { + if ("CN".equals(fields[2])) { return usable(fields[1]) ? fields[1] : "中国"; } return usable(fields[0]) ? fields[0] : ""; diff --git a/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java b/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java index 8c259ac..e13808f 100644 --- a/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java +++ b/src/test/java/run/halo/comment/widget/location/IpLocationResolverTest.java @@ -12,12 +12,12 @@ class IpLocationResolverTest { @ParameterizedTest @CsvSource(delimiter = ';', value = { - "中国|浙江省|杭州市|中国电信|CN;浙江省", - "中国|0|0|0|CN;中国", - "中国|香港特别行政区|0|0|CN;香港特别行政区", - "United States|California|Los Angeles|0|US;United States", - "Reserved|Reserved|Reserved|0|0;''", - "0|0|0|0|US;''", + "中国|浙江省|CN;浙江省", + "中国|0|CN;中国", + "中国|香港特别行政区|CN;香港特别行政区", + "United States|California|US;United States", + "Reserved|Reserved|0;''", + "0|0|US;''", "unexpected format;''" }) void limitsTheDisplayToProvinceOrCountry(String region, String expected) { From b7532c573a50823d6044e9e73c8cec20f3b0b11c Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 17:00:06 +0800 Subject: [PATCH 3/6] fix: display IP location before comment time --- packages/comment-widget/src/base-comment-item.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 1372cdb..0fd33ec 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -108,10 +108,10 @@ export class BaseCommentItem extends LitElement { ${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html``)} - - ${when(this.ipLocation && this.configMapData?.basic.showIpLocation, () => html`${msg('IP location')}: ${this.ipLocation}`)} + + ${when(!this.approved, () => html`
${msg('Reviewing')}
`)} From 4ab21766bfa2ffe4e2d13061c14205d826de54d0 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 17:02:29 +0800 Subject: [PATCH 4/6] fix: show unknown for missing comment IP locations --- packages/comment-widget/src/base-comment-item.ts | 2 +- packages/comment-widget/src/generated/locales/es.ts | 1 + .../comment-widget/src/generated/locales/zh-CN.ts | 1 + .../comment-widget/src/generated/locales/zh-TW.ts | 1 + .../comment-widget/tests/ip-location.browser.test.js | 12 +++++++----- packages/comment-widget/xliff/es.xlf | 4 ++++ packages/comment-widget/xliff/zh-CN.xlf | 4 ++++ packages/comment-widget/xliff/zh-TW.xlf | 4 ++++ 8 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 0fd33ec..4e16d51 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -108,7 +108,7 @@ export class BaseCommentItem extends LitElement { ${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html``)} - ${when(this.ipLocation && this.configMapData?.basic.showIpLocation, () => html`${msg('IP location')}: ${this.ipLocation}`)} + ${when(this.configMapData?.basic.showIpLocation, () => html`${msg('IP location')}: ${this.ipLocation || msg('Unknown')}`)} diff --git a/packages/comment-widget/src/generated/locales/es.ts b/packages/comment-widget/src/generated/locales/es.ts index 30d9657..b2482c1 100644 --- a/packages/comment-widget/src/generated/locales/es.ts +++ b/packages/comment-widget/src/generated/locales/es.ts @@ -88,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 37e4f09..8dcee95 100644 --- a/packages/comment-widget/src/generated/locales/zh-CN.ts +++ b/packages/comment-widget/src/generated/locales/zh-CN.ts @@ -88,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 e5325c2..9ddbd82 100644 --- a/packages/comment-widget/src/generated/locales/zh-TW.ts +++ b/packages/comment-widget/src/generated/locales/zh-TW.ts @@ -88,6 +88,7 @@ 'sbc0cd0b4dfb7dde1': `取消置頂`, 'sc0ffeb5d53b60209': `上傳失敗`, 'sc29d248fb3e5c59b': `查看所有回覆`, +'sc592307ea80f16b9': `未知`, 'sc59a34be5493a0af': `上傳失敗,請重試。`, 'sc8da3cc71de63832': `登入`, 'sd1f44f1a8bc20e67': `電子郵件`, diff --git a/packages/comment-widget/tests/ip-location.browser.test.js b/packages/comment-widget/tests/ip-location.browser.test.js index 7a12839..f915057 100644 --- a/packages/comment-widget/tests/ip-location.browser.test.js +++ b/packages/comment-widget/tests/ip-location.browser.test.js @@ -44,13 +44,13 @@ async function mount(tag, showIpLocation, ipLocation) { } for (const tag of ['comment-item', 'reply-item']) { - test(`${tag} displays location only when enabled and available`, async () => { + test(`${tag} displays location or unknown only when enabled`, async () => { for (const enabled of [true, false, undefined]) { - for (const location of ['浙江省', 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 && !!location); - if (badge) expect(badge.textContent).toContain('浙江省'); + expect(!!badge).toBe(!!enabled); + if (badge) expect(badge.textContent).toContain(location || 'Unknown'); expect(base.shadowRoot.textContent).not.toContain('1.0.1.1'); host.remove(); } @@ -71,6 +71,8 @@ for (const tag of ['comment-item', 'reply-item']) { item[property] = target(property, undefined); await item.updateComplete; await base.updateComplete; - expect(base.shadowRoot.querySelector('.item-ip-location')).toBeNull(); + 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 2404a8a..93f5b5c 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -390,6 +390,10 @@ 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 6cf3790..1b5ded0 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -390,6 +390,10 @@ IP location IP 属地 + + Unknown + 未知 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 4548098..8b3758d 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -390,6 +390,10 @@ IP location IP 屬地 + + Unknown + 未知 + From be75f083a62ca7c2084dec72f1636d7608816c3f Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 17:06:46 +0800 Subject: [PATCH 5/6] docs: clarify unknown IP location display --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3dde3f..c394532 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ## IP 属地 -在插件「基本设置」中开启「显示 IP 属地」,评论和回复会显示发表时 IP 对应的省份(中国大陆)或国家/地区。默认关闭,内网、无效或无法定位的 IP 不显示属地。新评论异步解析,历史评论分批补齐;缺少原始 IP 的记录无法补齐。反向代理需要正确传递客户端 IP,否则只能解析代理的地址。 +在插件「基本设置」中开启「显示 IP 属地」,评论和回复会显示发表时 IP 对应的省份(中国大陆)或国家/地区。默认关闭,开启后内网、无效或无法定位的 IP 显示「未知」。新评论异步解析,历史评论分批补齐;缺少原始 IP 的记录无法补齐。反向代理需要正确传递客户端 IP,否则只能解析代理的地址。 归属地由随插件打包的 [ip2region](https://github.com/lionsoul2014/ip2region) IPv4/IPv6 数据库离线解析,不发送 IP 给第三方。结果保存后不会随数据库升级自动改写。关闭开关仅隐藏组件中的展示,已保存的地区仍可能通过公开接口读取;原始 IP 不写入属地元数据。 From f70b85714603961db3e13e19d4fca9612337cae7 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 30 Oct 2026 17:10:30 +0800 Subject: [PATCH 6/6] docs: simplify IP location usage description --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c394532..007192b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式 - 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围 - 支持显示评论者设备信息 -- 支持离线解析并显示评论和回复的 IP 属地,开启后自动补齐历史评论 +- 支持显示评论和回复的 IP 属地 - 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式 ## 使用方式 @@ -30,9 +30,9 @@ ## IP 属地 -在插件「基本设置」中开启「显示 IP 属地」,评论和回复会显示发表时 IP 对应的省份(中国大陆)或国家/地区。默认关闭,开启后内网、无效或无法定位的 IP 显示「未知」。新评论异步解析,历史评论分批补齐;缺少原始 IP 的记录无法补齐。反向代理需要正确传递客户端 IP,否则只能解析代理的地址。 +在插件「基本设置」中开启「显示 IP 属地」,即可在评论和回复中展示属地:中国大陆显示省份,其他地区显示国家或地区,无法识别时显示「未知」。开启后,历史评论也会逐步补充可识别的属地。 -归属地由随插件打包的 [ip2region](https://github.com/lionsoul2014/ip2region) IPv4/IPv6 数据库离线解析,不发送 IP 给第三方。结果保存后不会随数据库升级自动改写。关闭开关仅隐藏组件中的展示,已保存的地区仍可能通过公开接口读取;原始 IP 不写入属地元数据。 +此功能默认关闭,属地信息由 [ip2region](https://github.com/lionsoul2014/ip2region) 提供,仅供参考。关闭功能只会隐藏展示,不会删除已保存的属地信息。 ## 主题适配