Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- 可针对所有用户、匿名用户或指定角色启用验证,ALTCHA 支持手动和自动验证模式
- 支持 Gravatar 头像,可自定义头像服务镜像地址和适用用户范围
- 支持显示评论者设备信息
- 支持显示评论和回复的 IP 属地
- 支持通过 CSS 变量自定义样式,适配明亮和暗黑模式

## 使用方式
Expand All @@ -27,6 +28,12 @@

> 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。

## IP 属地

在插件「基本设置」中开启「显示 IP 属地」,即可在评论和回复中展示属地:中国大陆显示省份,其他地区显示国家或地区,无法识别时显示「未知」。开启后,历史评论也会逐步补充可识别的属地。

此功能默认关闭,属地信息由 [ip2region](https://github.com/lionsoul2014/ip2region) 提供,仅供参考。关闭功能只会隐藏展示,不会删除已保存的属地信息。

## 主题适配

主题可通过 Halo 的自定义标签接入评论组件,并通过 CSS 变量自定义样式和配色。
Expand Down
83 changes: 83 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions dev/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),使用索引缓存查询,避免将整个数据库载入堆内存;插件销毁时关闭文件并删除临时文件。已持久化的属地不会因数据库更新自动改变。
5 changes: 5 additions & 0 deletions packages/comment-widget/src/base-comment-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -105,6 +108,8 @@ export class BaseCommentItem extends LitElement {

${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html`<commenter-ua-bar .ua=${this.ua}></commenter-ua-bar>`)}

${when(this.configMapData?.basic.showIpLocation, () => html`<span class="item-ip-location text-xs text-text-3">${msg('IP location')}: ${this.ipLocation || msg('Unknown')}</span>`)}

<comment-link .permalink=${this.permalink} .creationTime=${this.creationTime}></comment-link>

${when(!this.approved, () => html`<div class="item-meta-info text-xs text-text-3">${msg('Reviewing')}</div>`)}
Expand Down
1 change: 1 addition & 0 deletions packages/comment-widget/src/comment-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
2 changes: 2 additions & 0 deletions packages/comment-widget/src/generated/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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`,
Expand Down
2 changes: 2 additions & 0 deletions packages/comment-widget/src/generated/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
's22ae36191f9a1a8d': `加载最新内容失败,请稍后重试。`,
's2406b89e991a4524': `刷新验证码`,
's26e4d65f2801ac9c': `请输入内容`,
's28dcec8c25fe4ec3': `IP 属地`,
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
's2c8189544e3ea679': `重试`,
's2ca7699f7c5a4996': `管理`,
Expand Down Expand Up @@ -87,6 +88,7 @@
'sbc0cd0b4dfb7dde1': `取消置顶`,
'sc0ffeb5d53b60209': `上传失败`,
'sc29d248fb3e5c59b': `查看全部回复`,
'sc592307ea80f16b9': `未知`,
'sc59a34be5493a0af': `上传失败,请重试。`,
'sc8da3cc71de63832': `登录`,
'sd1f44f1a8bc20e67': `电子邮件`,
Expand Down
2 changes: 2 additions & 0 deletions packages/comment-widget/src/generated/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
's22ae36191f9a1a8d': `載入最新內容失敗,請稍後再試。`,
's2406b89e991a4524': `重新整理驗證碼`,
's26e4d65f2801ac9c': `請輸入內容`,
's28dcec8c25fe4ec3': `IP 屬地`,
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
's2c8189544e3ea679': `重試`,
's2ca7699f7c5a4996': `管理`,
Expand Down Expand Up @@ -87,6 +88,7 @@
'sbc0cd0b4dfb7dde1': `取消置頂`,
'sc0ffeb5d53b60209': `上傳失敗`,
'sc29d248fb3e5c59b': `查看所有回覆`,
'sc592307ea80f16b9': `未知`,
'sc59a34be5493a0af': `上傳失敗,請重試。`,
'sc8da3cc71de63832': `登入`,
'sd1f44f1a8bc20e67': `電子郵件`,
Expand Down
1 change: 1 addition & 0 deletions packages/comment-widget/src/reply-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
1 change: 1 addition & 0 deletions packages/comment-widget/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface BasicConfig {
replySize: number;
allowNotificationConfigurable?: boolean;
showCommenterDevice?: boolean;
showIpLocation?: boolean;
enablePrivateComment?: boolean;
showPrivateCommentBadge?: boolean;
}
Expand Down
78 changes: 78 additions & 0 deletions packages/comment-widget/tests/ip-location.browser.test.js
Original file line number Diff line number Diff line change
@@ -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: '<p>A comment</p>',
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,
'<img src=x onerror=alert(1)>'
);
const badge = base.shadowRoot.querySelector('.item-ip-location');
expect(badge.textContent).toContain('<img src=x onerror=alert(1)>');
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');
});
}
8 changes: 8 additions & 0 deletions packages/comment-widget/xliff/es.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,14 @@
<source>Allow notification</source>
<target>Permitir notificación</target>
</trans-unit>
<trans-unit id="s28dcec8c25fe4ec3">
<source>IP location</source>
<target>Ubicación de IP</target>
</trans-unit>
<trans-unit id="sc592307ea80f16b9">
<source>Unknown</source>
<target>Desconocido</target>
</trans-unit>
</body>
</file>
</xliff>
8 changes: 8 additions & 0 deletions packages/comment-widget/xliff/zh-CN.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,14 @@
<source>Allow notification</source>
<target>允许通知</target>
</trans-unit>
<trans-unit id="s28dcec8c25fe4ec3">
<source>IP location</source>
<target>IP 属地</target>
</trans-unit>
<trans-unit id="sc592307ea80f16b9">
<source>Unknown</source>
<target>未知</target>
</trans-unit>
</body>
</file>
</xliff>
8 changes: 8 additions & 0 deletions packages/comment-widget/xliff/zh-TW.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,14 @@
<source>Allow notification</source>
<target>允許通知</target>
</trans-unit>
<trans-unit id="s28dcec8c25fe4ec3">
<source>IP location</source>
<target>IP 屬地</target>
</trans-unit>
<trans-unit id="sc592307ea80f16b9">
<source>Unknown</source>
<target>未知</target>
</trans-unit>
</body>
</file>
</xliff>
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class BasicConfig {
private boolean withReplies;
private int withReplySize;
private boolean allowNotificationConfigurable;
private boolean showIpLocation;
}

@Data
Expand All @@ -141,4 +142,4 @@ class AvatarConfig {
private String providerMirror;
private String policy;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Reconciler.Request> {

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();
}
}
Loading
Loading