diff --git a/.env.example b/.env.example index 5430252..19d999d 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,17 @@ HAOBLOG_ADMIN_PASSWORD_HASH=replace-with-a-bcrypt-hash HAOBLOG_SESSION_COOKIE_SECURE=false HAOBLOG_PUBLIC_BASE_URL=http://localhost:3000 HAOBLOG_AUTHOR_NAME=Hao +HAOBLOG_COMMENT_SECURITY_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +# Comment notification is disabled locally. Never put real SMTP credentials in this file. +HAOBLOG_COMMENT_NOTIFICATION_ENABLED=false +# HAOBLOG_COMMENT_NOTIFICATION_SMTP_HOST=smtp.example.invalid +# HAOBLOG_COMMENT_NOTIFICATION_SMTP_PORT=587 +# HAOBLOG_COMMENT_NOTIFICATION_SMTP_USERNAME=replace-with-smtp-username +# HAOBLOG_COMMENT_NOTIFICATION_SMTP_PASSWORD=replace-with-smtp-password +# HAOBLOG_COMMENT_NOTIFICATION_SMTP_TLS=true +# HAOBLOG_COMMENT_NOTIFICATION_RECIPIENT=owner@example.invalid +# HAOBLOG_COMMENT_NOTIFICATION_FROM=haoblog@example.invalid # OSS is disabled locally by default. These are server-only values; never put a real key in Git. HAOBLOG_OSS_ENABLED=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab8b3ef..b907484 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - stage-3-acceptance: + stage-4-acceptance: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,6 +44,7 @@ jobs: - name: PostgreSQL integration tests env: HAOBLOG_PUBLIC_BASE_URL: http://localhost:3000 + HAOBLOG_COMMENT_SECURITY_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= run: ./mvnw failsafe:integration-test failsafe:verify working-directory: apps/api @@ -98,6 +99,13 @@ jobs: env: LHCI_EXISTING_SERVER: true LHCI_BASE_URL: http://localhost + LHCI_ARTICLE_PATH: /articles/s3-08-advanced-markdown + run: corepack pnpm web:lighthouse + - name: Lighthouse public tools budgets + env: + LHCI_EXISTING_SERVER: true + LHCI_BASE_URL: http://localhost + LHCI_ARTICLE_PATH: /tools run: corepack pnpm web:lighthouse - name: Reject whitespace errors run: git diff --check diff --git a/AGENTS.md b/AGENTS.md index 7e7dfd0..dc9e33a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,10 +74,16 @@ The current repository uses one root pnpm workspace and one Maven application. N - API tests (POSIX shell): `cd apps/api && ./mvnw test` - API package (Windows PowerShell): `cd apps/api; .\mvnw.cmd verify` - API package (POSIX shell): `cd apps/api && ./mvnw verify` +- PostgreSQL/pgvector integration tests (Windows PowerShell): `cd apps/api; .\mvnw.cmd failsafe:integration-test failsafe:verify` - Web type check: `pnpm --dir apps/web typecheck` - Web tests: `pnpm --dir apps/web test` - Web production build: `pnpm --dir apps/web build` +- Full Chromium E2E: `pnpm --dir apps/web e2e` +- Article client bundle budget: `pnpm web:budget` +- Lighthouse with an existing server: set `LHCI_EXISTING_SERVER=true`, `LHCI_BASE_URL=http://localhost` and `LHCI_ARTICLE_PATH` to `/articles/s3-08-advanced-markdown` or `/tools`, then run `pnpm web:lighthouse`; on Windows also use a task-local `TEMP`/`TMP` directory to avoid Chrome cleanup EPERM - Compose validation using placeholder environment values: `docker compose --env-file .env.example -f infra/compose/compose.dev.yml config` +- Production Compose validation: `docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml config` +- Compose resource and health-boundary verification: `pnpm compose:verify` - Local database startup: `docker compose --env-file .env -f infra/compose/compose.dev.yml up -d` The Maven Wrapper is `apps/api/mvnw` / `apps/api/mvnw.cmd`, with Maven distribution `3.9.11`. The pnpm version is pinned as `pnpm@11.16.0` in the root `package.json`; the workspace also permits the explicitly configured `esbuild` build script. diff --git a/README.md b/README.md index b45be83..2226014 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,5 @@ # HaoBlog -HaoBlog is a Java 21 and Nuxt 4 modular-monolith developer blog. The public site is SSR-first; `/studio` is client-rendered. 阶段三已形成“极夜观测站”公开阅读体验,包含安全高级 Markdown、TOC、SEO/JSON-LD、RSS、Sitemap、无 JavaScript 与数据节省路径。 +HaoBlog 是一个面向开发者的低成本个人博客与数字花园,采用 Java 21、Spring Boot、Nuxt 4、Vue 3 和 PostgreSQL/pgvector 构建。项目使用模块化单体架构,公开站点以服务端渲染为核心,管理后台通过 `/studio` 提供内容运营能力。 -## Local prerequisites - -- Java 21 -- Maven Wrapper (included in `apps/api`) -- Node.js 24 LTS -- pnpm 11.16.0 -- Docker Desktop with Compose - -Copy `.env.example` to `.env` and replace the local database password before starting PostgreSQL. - -完整的环境准备、管理员 BCrypt 配置、Compose 启动、直接运行 API/Web、验证命令和故障排查请参阅:[本地启动指南](docs/本地启动指南.md)。 - -## 最短命令 - -Windows 11 PowerShell: - -```powershell -Copy-Item .env.example .env -docker compose --env-file .env -f infra/compose/compose.dev.yml up -d -corepack pnpm install --frozen-lockfile -corepack pnpm --filter @haoblog/api-client generate -corepack pnpm --filter @haoblog/api-client check -corepack pnpm compose:verify -corepack pnpm --dir apps/web typecheck -corepack pnpm --dir apps/web test -corepack pnpm --dir apps/web build -corepack pnpm web:budget -corepack pnpm smoke -docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100 -docker compose --env-file .env -f infra/compose/compose.dev.yml down -v -cd apps/api; .\mvnw.cmd -DskipITs verify; .\mvnw.cmd failsafe:integration-test failsafe:verify; cd ../.. -``` - -通用 Shell: - -```sh -cp .env.example .env -docker compose --env-file .env -f infra/compose/compose.dev.yml up -d -corepack pnpm install --frozen-lockfile -corepack pnpm --filter @haoblog/api-client generate -corepack pnpm --filter @haoblog/api-client check -corepack pnpm compose:verify -corepack pnpm --dir apps/web typecheck && corepack pnpm --dir apps/web test && corepack pnpm --dir apps/web build -corepack pnpm web:budget -corepack pnpm smoke -docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100 -docker compose --env-file .env -f infra/compose/compose.dev.yml down -v -cd apps/api && ./mvnw -DskipITs verify && ./mvnw failsafe:integration-test failsafe:verify -``` - -开发 Compose 提供 PostgreSQL/pgvector、API 和 Web;API/Web 构建 context 均为仓库根目录。生产 Compose 只允许 Caddy 对外开放 80/443,数据库和 Actuator 不暴露宿主机端口。生产镜像、域名和密码必须通过外部 env 文件注入,不能使用仓库中的示例值。 - -故障排查: - -- 端口占用:`Get-NetTCPConnection -LocalPort 3000,5432,8080`;Shell 使用 `ss -ltnp | grep -E ':3000|:5432|:8080'`。 -- 容器健康:`docker compose --env-file .env -f infra/compose/compose.dev.yml ps`。 -- 查看日志:`docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100 api web postgres`。 -- 数据库迁移失败:先查看 API 日志和 PostgreSQL 健康状态,再执行 `docker compose --env-file .env -f infra/compose/compose.dev.yml down -v` 清理本地开发 volume 后重试。 -- Web 无法连接 API:确认 API 健康后检查容器内地址 `http://api:8080`,不要在 Compose 内使用 `localhost`。 - -阶段三验收顺序是 API `-DskipITs verify`、Web typecheck/test/build、OpenAPI 生成一致性、dev/prod Compose 配置、资源边界、PostgreSQL/pgvector Failsafe、生产四容器健康、Smoke、Chromium Playwright、文章客户端预算、Lighthouse 和 `git diff --check`。生产编排的可复制命令与实际完成状态见 [阶段三准入清单](docs/阶段三准入清单.md)。Playwright 使用仓库测试账号 `admin/password`,应配合 `infra/compose/.env.ci.example` 的临时栈运行,不能用于生产。评论、工具箱功能、AI、RAG、Three.js、终端和部署仍不在阶段三范围内。 +项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录和结构化数据等能力,并通过安全 Markdown、资源边界、无 JavaScript、Save-Data 与 reduced-motion 路径保持可访问和可持续的阅读体验。 diff --git a/apps/api/pom.xml b/apps/api/pom.xml index 1d54881..184b19b 100644 --- a/apps/api/pom.xml +++ b/apps/api/pom.xml @@ -31,6 +31,7 @@ org.springframework.bootspring-boot-starter-security org.springframework.bootspring-boot-starter-session-jdbc org.springframework.bootspring-boot-starter-data-jpa + org.springframework.bootspring-boot-starter-mail org.springframework.bootspring-boot-starter-flyway org.flywaydbflyway-core org.flywaydbflyway-database-postgresql @@ -49,12 +50,22 @@ org.springframework.bootspring-boot-maven-plugin org.apache.maven.pluginsmaven-surefire-plugin - **/*Test.java**/*Tests.java + + **/*Test.java**/*Tests.java + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + org.apache.maven.pluginsmaven-failsafe-plugin 3.5.3 - **/*IT.java + + **/*IT.java + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + integration-testverify diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java new file mode 100644 index 0000000..8fd22d2 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java @@ -0,0 +1,72 @@ +package io.haoblog.comment.application; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +@Service +public class CommentChallengeService { + static final Duration MINIMUM_FILL_TIME = Duration.ofSeconds(3); + static final Duration MAXIMUM_AGE = Duration.ofHours(2); + private static final long MAX_CACHE_SIZE = 4096; + + private final Clock clock; + private final CommentSecurityService security; + private final Cache challenges = Caffeine.newBuilder() + .maximumSize(MAX_CACHE_SIZE) + .expireAfterWrite(MAXIMUM_AGE) + .build(); + + public CommentChallengeService(Clock clock, CommentSecurityService security) { + this.clock = clock; + this.security = security; + } + + public IssuedChallenge issue(UUID articleId) { + Instant issuedAt = clock.instant(); + Instant expiresAt = issuedAt.plus(MAXIMUM_AGE); + String token = security.challengeToken(articleId, issuedAt); + challenges.put(token, new Challenge(articleId, issuedAt, expiresAt)); + return new IssuedChallenge(token, expiresAt); + } + + public boolean consume(UUID articleId, String token) { + return validateAndConsume(articleId, token) == Validation.VALID; + } + + public Validation validateAndConsume(UUID articleId, String token) { + if (token == null || token.isBlank()) return Validation.INVALID; + Instant now = clock.instant(); + var result = new Validation[] {Validation.INVALID}; + challenges.asMap().computeIfPresent(token, (key, challenge) -> { + if (!challenge.articleId().equals(articleId)) return challenge; + if (now.isBefore(challenge.issuedAt().plus(MINIMUM_FILL_TIME))) { + result[0] = Validation.TOO_EARLY; + return challenge; + } + if (!challenge.expiresAt().isAfter(now)) { + result[0] = Validation.EXPIRED; + return null; + } + result[0] = Validation.VALID; + return null; + }); + return result[0]; + } + + long cacheSize() { + challenges.cleanUp(); + return challenges.estimatedSize(); + } + + public record IssuedChallenge(String token, Instant expiresAt) {} + + public enum Validation { VALID, INVALID, TOO_EARLY, EXPIRED } + + private record Challenge(UUID articleId, Instant issuedAt, Instant expiresAt) {} +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationException.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationException.java new file mode 100644 index 0000000..d8f8d02 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationException.java @@ -0,0 +1,7 @@ +package io.haoblog.comment.application; + +public class CommentNotificationException extends RuntimeException { + public CommentNotificationException() { + super("Comment notification could not be sent"); + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java new file mode 100644 index 0000000..d2ad7ed --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java @@ -0,0 +1,72 @@ +package io.haoblog.comment.application; + +import io.haoblog.comment.domain.Comment; +import io.haoblog.comment.persistence.CommentRepository; +import io.haoblog.content.application.ArticleCommentLookup; +import io.haoblog.site.application.SiteService; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +public class CommentNotificationMailer { + private static final int SUMMARY_LENGTH = 240; + + private final CommentRepository comments; + private final ArticleCommentLookup articles; + private final SiteService site; + private final CommentNotificationProperties properties; + private final JavaMailSender mailSender; + + public CommentNotificationMailer(CommentRepository comments, ArticleCommentLookup articles, + SiteService site, CommentNotificationProperties properties, + JavaMailSender mailSender) { + this.comments = comments; + this.articles = articles; + this.site = site; + this.properties = properties; + this.mailSender = mailSender; + } + + @Transactional(readOnly = true) + public void send(UUID commentId) { + Comment comment = comments.findById(commentId).orElseThrow(CommentNotificationException::new); + ArticleCommentLookup.NotificationArticle article = articles + .findCommentNotificationArticle(comment.getArticleId()) + .orElseThrow(CommentNotificationException::new); + String recipient = required(properties.getRecipient()); + String from = required(properties.getFrom()); + String studioUrl = site.get().siteUrl() + "/studio/comments?commentId=" + commentId; + + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(recipient); + message.setFrom(from); + message.setSubject("HaoBlog 新评论待审核"); + message.setText("有一条评论待审核\n\n" + + "昵称:" + comment.getNickname() + "\n" + + "文章:" + article.title() + "\n" + + "时间:" + comment.getCreatedAt() + "\n" + + "正文摘要:" + summarize(comment.getContent()) + "\n" + + "Studio 审核链接:" + studioUrl + "\n"); + try { + mailSender.send(message); + } catch (Exception ignored) { + throw new CommentNotificationException(); + } + } + + private static String required(String value) { + if (value == null || value.isBlank()) throw new CommentNotificationException(); + return value.trim(); + } + + private static String summarize(String content) { + String normalized = content == null ? "" : content.replaceAll("\\s+", " ").strip(); + if (normalized.codePointCount(0, normalized.length()) <= SUMMARY_LENGTH) return normalized; + int end = normalized.offsetByCodePoints(0, SUMMARY_LENGTH); + return normalized.substring(0, end) + "…"; + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java new file mode 100644 index 0000000..8c42f66 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java @@ -0,0 +1,58 @@ +package io.haoblog.comment.application; + +import io.haoblog.shared.outbox.OutboxEvent; +import io.haoblog.shared.outbox.OutboxEventStateService; +import org.slf4j.MDC; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class CommentNotificationOutboxProcessor { + private static final Logger LOG = LoggerFactory.getLogger(CommentNotificationOutboxProcessor.class); + + private final OutboxEventStateService state; + private final CommentNotificationMailer mailer; + private final CommentNotificationProperties properties; + + public CommentNotificationOutboxProcessor(OutboxEventStateService state, CommentNotificationMailer mailer, + CommentNotificationProperties properties) { + this.state = state; + this.mailer = mailer; + this.properties = properties; + } + + @Scheduled( + fixedDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_FIXED_DELAY_MS:60000}", + initialDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_INITIAL_DELAY_MS:5000}" + ) + public void processDueBatch() { + for (OutboxEvent event : state.claimCommentCreatedBatch()) { + processOne(event); + } + } + + private void processOne(OutboxEvent event) { + UUID traceId = UUID.randomUUID(); + try (MDC.MDCCloseable ignored = MDC.putCloseable("traceId", traceId.toString())) { + if (properties.isEnabled()) { + mailer.send(event.getAggregateId()); + } + state.markProcessed(event.getId()); + LOG.info("评论通知已处理 eventId={} commentId={} traceId={}", + event.getId(), event.getAggregateId(), traceId); + } catch (Exception ignored) { + try { + state.markFailedOrRetry(event.getId()); + } catch (Exception stateFailure) { + LOG.warn("评论通知状态更新失败 eventId={} commentId={} traceId={}", + event.getId(), event.getAggregateId(), traceId); + } + LOG.warn("评论通知失败 eventId={} commentId={} traceId={}", + event.getId(), event.getAggregateId(), traceId); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationProperties.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationProperties.java new file mode 100644 index 0000000..aea3e42 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationProperties.java @@ -0,0 +1,19 @@ +package io.haoblog.comment.application; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "haoblog.comment.notification") +public class CommentNotificationProperties { + private boolean enabled; + private String recipient = ""; + private String from = ""; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getRecipient() { return recipient; } + public void setRecipient(String recipient) { this.recipient = recipient; } + public String getFrom() { return from; } + public void setFrom(String from) { this.from = from; } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimitException.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimitException.java new file mode 100644 index 0000000..ea57e27 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimitException.java @@ -0,0 +1,12 @@ +package io.haoblog.comment.application; + +public class CommentRateLimitException extends RuntimeException { + private final long retryAfterSeconds; + + public CommentRateLimitException(long retryAfterSeconds) { + super("Comment rate limit exceeded"); + this.retryAfterSeconds = retryAfterSeconds; + } + + public long getRetryAfterSeconds() { return retryAfterSeconds; } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimiter.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimiter.java new file mode 100644 index 0000000..2e656c3 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimiter.java @@ -0,0 +1,72 @@ +package io.haoblog.comment.application; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +@Component +public class CommentRateLimiter { + static final Duration SHORT_WINDOW = Duration.ofMinutes(10); + static final int SHORT_LIMIT = 3; + static final int DAILY_LIMIT = 10; + private static final long MAX_CACHE_SIZE = 4096; + + private final Clock clock; + private final Cache states = Caffeine.newBuilder() + .maximumSize(MAX_CACHE_SIZE) + .expireAfterAccess(Duration.ofDays(1)) + .build(); + + public CommentRateLimiter(Clock clock) { + this.clock = clock; + } + + public Decision checkAndRecord(String source) { + Instant now = clock.instant(); + String dayKey = source + ":" + LocalDate.ofInstant(now, ZoneOffset.UTC); + var result = new Decision[] {Decision.permitted()}; + states.asMap().compute(dayKey, (key, state) -> { + List recent = state == null ? new ArrayList<>() : new ArrayList<>(state.timestamps()); + recent.removeIf(time -> !time.plus(Duration.ofDays(1)).isAfter(now)); + long shortCount = recent.stream().filter(time -> time.plus(SHORT_WINDOW).isAfter(now)).count(); + if (shortCount >= SHORT_LIMIT) { + Instant earliest = recent.stream() + .filter(time -> time.plus(SHORT_WINDOW).isAfter(now)) + .min(Instant::compareTo).orElse(now); + result[0] = new Decision(false, retryAfter(now, earliest.plus(SHORT_WINDOW))); + return new State(List.copyOf(recent)); + } + if (recent.size() >= DAILY_LIMIT) { + result[0] = new Decision(false, retryAfter(now, + LocalDate.ofInstant(now, ZoneOffset.UTC).plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC))); + return new State(List.copyOf(recent)); + } + recent.add(now); + return new State(List.copyOf(recent)); + }); + return result[0]; + } + + long cacheSize() { + states.cleanUp(); + return states.estimatedSize(); + } + + private static long retryAfter(Instant now, Instant availableAt) { + return Math.max(1, (Duration.between(now, availableAt).toMillis() + 999) / 1000); + } + + public record Decision(boolean allowed, long retryAfterSeconds) { + static Decision permitted() { return new Decision(true, 0); } + } + + private record State(List timestamps) {} +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java new file mode 100644 index 0000000..3ea7820 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java @@ -0,0 +1,144 @@ +package io.haoblog.comment.application; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.util.Base64; +import java.util.UUID; + +@Service +public class CommentSecurityService { + private static final int KEY_BYTES = 32; + private static final int NONCE_BYTES = 12; + private static final int EMAIL_KEY_VERSION = 1; + private final byte[] emailKey; + private final byte[] ipKey; + private final byte[] challengeKey; + private final SecureRandom random = new SecureRandom(); + + public CommentSecurityService(@Value("${haoblog.comment.security-key:}") String encodedKey) { + if (encodedKey == null || encodedKey.isBlank()) { + throw new IllegalArgumentException("HAOBLOG_COMMENT_SECURITY_KEY must be configured"); + } + byte[] masterKey; + try { + masterKey = Base64.getDecoder().decode(encodedKey.trim()); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("HAOBLOG_COMMENT_SECURITY_KEY must be Base64", exception); + } + if (masterKey.length != KEY_BYTES) { + throw new IllegalArgumentException("HAOBLOG_COMMENT_SECURITY_KEY must decode to 32 bytes"); + } + this.emailKey = derive(masterKey, "email-v1"); + this.ipKey = derive(masterKey, "ip-v1"); + this.challengeKey = derive(masterKey, "challenge-v1"); + } + + public EmailCiphertext encryptEmail(UUID commentId, String email) { + if (email == null || email.isBlank()) return null; + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(emailKey, "AES"), new GCMParameterSpec(128, nonce)); + cipher.updateAAD(commentId.toString().getBytes(StandardCharsets.UTF_8)); + return new EmailCiphertext(EMAIL_KEY_VERSION, nonce, + cipher.doFinal(email.trim().getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to encrypt comment email", exception); + } + } + + public String decryptEmail(UUID commentId, EmailCiphertext encrypted) { + if (encrypted == null || encrypted.keyVersion() != EMAIL_KEY_VERSION) { + throw new IllegalArgumentException("Unsupported comment email key version"); + } + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(emailKey, "AES"), + new GCMParameterSpec(128, encrypted.nonce())); + cipher.updateAAD(commentId.toString().getBytes(StandardCharsets.UTF_8)); + return new String(cipher.doFinal(encrypted.ciphertext()), StandardCharsets.UTF_8); + } catch (GeneralSecurityException exception) { + throw new IllegalArgumentException("Comment email authentication failed", exception); + } + } + + public String decryptEmail(UUID commentId, byte[] ciphertext, byte[] nonce, Integer keyVersion) { + if (ciphertext == null || nonce == null || keyVersion == null) return null; + return decryptEmail(commentId, new EmailCiphertext(keyVersion, nonce, ciphertext)); + } + + public byte[] dailyIpHmac(String ip, LocalDate date) { + return hmac(ipKey, "ip-v1:" + date + ":" + ip); + } + + public byte[] contentFingerprint(UUID articleId, String normalizedBody) { + return digest(articleId + "\n" + normalizedBody); + } + + public byte[] deleteTokenDigest(String token) { + return digest(token); + } + + public String challengeToken(UUID articleId, java.time.Instant issuedAt) { + return Base64.getUrlEncoder().withoutPadding().encodeToString( + hmac(challengeKey, "challenge-v1:" + articleId + ":" + issuedAt + ":" + UUID.randomUUID())); + } + + public String newVisitorToken() { + byte[] token = new byte[32]; + random.nextBytes(token); + return Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + + public String newDeleteToken() { + byte[] token = new byte[32]; + random.nextBytes(token); + return Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + + private static byte[] derive(byte[] masterKey, String purpose) { + return hmac(masterKey, "haoblog-comment-key:" + purpose); + } + + private static byte[] hmac(byte[] hmacKey, String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(hmacKey, "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to hash comment security value", exception); + } + } + + private static byte[] digest(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException("Unable to hash comment security value", exception); + } + } + + public record EmailCiphertext(int keyVersion, byte[] nonce, byte[] ciphertext) { + public EmailCiphertext { + if (nonce == null || nonce.length != NONCE_BYTES || ciphertext == null) { + throw new IllegalArgumentException("Invalid email ciphertext"); + } + nonce = nonce.clone(); + ciphertext = ciphertext.clone(); + } + + @Override public byte[] nonce() { return nonce.clone(); } + @Override public byte[] ciphertext() { return ciphertext.clone(); } + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java new file mode 100644 index 0000000..d7e8799 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java @@ -0,0 +1,374 @@ +package io.haoblog.comment.application; + +import io.haoblog.comment.domain.Comment; +import io.haoblog.comment.domain.CommentStatus; +import io.haoblog.comment.persistence.CommentRepository; +import io.haoblog.content.application.ArticleCommentLookup; +import io.haoblog.identity.domain.AdminUser; +import io.haoblog.identity.persistence.AdminUserRepository; +import io.haoblog.shared.outbox.OutboxEvent; +import io.haoblog.shared.outbox.OutboxEventRepository; +import io.haoblog.shared.web.ProblemException; +import io.haoblog.site.application.SiteService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.text.Normalizer; +import java.util.Base64; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class CommentService { + private static final Pattern URL_SCHEME = Pattern.compile("(?i)\\b([a-z][a-z0-9+.-]*)://"); + private static final Pattern UNSAFE_SCHEME = Pattern.compile("(?i)(?:javascript|data|vbscript|file|mailto):"); + private static final Pattern RAW_HTML = Pattern.compile("(?is)<\\s*/?\\s*[a-z!][^>]*>"); + private static final Pattern HTTPS_LINK = Pattern.compile("(?i)https://"); + private static final int MAX_PAGE_SIZE = 50; + private static final EnumSet MODERATABLE = EnumSet.of( + CommentStatus.APPROVED, CommentStatus.SPAM, CommentStatus.REJECTED); + + private final CommentRepository comments; + private final OutboxEventRepository outbox; + private final ArticleCommentLookup articles; + private final SiteService site; + private final CommentSecurityService security; + private final CommentChallengeService challenges; + private final CommentRateLimiter rateLimiter; + private final AdminUserRepository admins; + private final Clock clock; + + @Autowired + public CommentService(CommentRepository comments, OutboxEventRepository outbox, + ArticleCommentLookup articles, SiteService site, + CommentSecurityService security, CommentChallengeService challenges, + CommentRateLimiter rateLimiter, AdminUserRepository admins, Clock clock) { + this.comments = comments; + this.outbox = outbox; + this.articles = articles; + this.site = site; + this.security = security; + this.challenges = challenges; + this.rateLimiter = rateLimiter; + this.admins = admins; + this.clock = clock; + } + + public CommentService(CommentRepository comments, OutboxEventRepository outbox, + ArticleCommentLookup articles, SiteService site, + CommentSecurityService security, CommentChallengeService challenges, + CommentRateLimiter rateLimiter, Clock clock) { + this(comments, outbox, articles, site, security, challenges, rateLimiter, null, clock); + } + + @Transactional(readOnly = true) + public Page listAdmin(CommentStatus status, UUID articleId, String keyword, + int page, int size, Sort.Direction direction) { + validatePage(page, size); + String normalizedKeyword = normalizeKeyword(keyword); + var pageable = PageRequest.of(page, size); + return comments.findAdminComments(status == null ? null : status.name(), + articleId == null ? null : articleId.toString(), normalizedKeyword, + direction.name().toLowerCase(Locale.ROOT), pageable).map(this::adminListView); + } + + @Transactional(readOnly = true) + public AdminCommentDetail getAdmin(UUID commentId) { + Comment comment = comments.findById(commentId).orElseThrow(() -> notFound("COMMENT_NOT_FOUND")); + return adminDetail(comment); + } + + @Transactional + public AdminCommentDetail moderate(UUID commentId, long expectedVersion, CommentStatus target, + String reason, String username) { + if (!MODERATABLE.contains(target)) { + throw new ProblemException("COMMENT_MODERATION_STATE_CONFLICT", "Invalid moderation state", + "Comments can only be moderated to APPROVED, SPAM or REJECTED"); + } + Comment comment = comments.findById(commentId).orElseThrow(() -> notFound("COMMENT_NOT_FOUND")); + if (comment.getStatus() == CommentStatus.USER_DELETED) { + throw new ProblemException("COMMENT_MODERATION_STATE_CONFLICT", "Invalid moderation state", + "A deleted comment cannot be moderated"); + } + if (comment.getVersion() != expectedVersion) throw versionConflict(comment.getVersion()); + if (admins == null) throw new ProblemException("ADMIN_NOT_FOUND", "Administrator not found", + "The administrator account is no longer available"); + UUID moderatorId = admins.findForAuthentication(username).map(AdminUser::getId) + .orElseThrow(() -> new ProblemException("ADMIN_NOT_FOUND", "Administrator not found", + "The administrator account is no longer available")); + comment.moderate(target, moderatorId, normalizeReason(reason), clock.instant()); + return adminDetail(comments.saveAndFlush(comment)); + } + + private AdminComment adminListView(Comment comment) { + return AdminComment.from(comment, maskedEmail(comment)); + } + + private AdminCommentDetail adminDetail(Comment comment) { + return AdminCommentDetail.from(comment, decryptedEmail(comment)); + } + + private String decryptedEmail(Comment comment) { + return security.decryptEmail(comment.getId(), comment.getEmailCiphertext(), comment.getEmailNonce(), comment.getEmailKeyVersion()); + } + + private String maskedEmail(Comment comment) { + String email = decryptedEmail(comment); + if (email == null || email.isBlank()) return null; + int at = email.lastIndexOf('@'); + if (at <= 0 || at == email.length() - 1) return "***"; + String local = email.substring(0, at); + String visible = local.length() > 1 ? local.substring(0, 1) : ""; + return visible + "***@" + email.substring(at + 1); + } + + private static void validatePage(int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new IllegalArgumentException("page/size out of range"); + } + + private static String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) return null; + String normalized = keyword.strip(); + if (normalized.length() > 240) throw new IllegalArgumentException("keyword is too long"); + return normalized.toLowerCase(Locale.ROOT); + } + + private static String normalizeReason(String reason) { + return reason == null || reason.isBlank() ? null : reason.strip(); + } + + private static ProblemException notFound(String code) { + return new ProblemException(code, "Comment not found", "The requested comment does not exist"); + } + + private static ProblemException versionConflict(long currentVersion) { + return new ProblemException("COMMENT_VERSION_CONFLICT", "Comment version conflict", + "Reload the latest comment before moderating it", currentVersion); + } + + @Transactional + public CommentPage list(String slug, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("page/size out of range"); + } + UUID articleId = publicArticle(slug).articleId(); + if (!site.get().commentsEnabled()) { + return new CommentPage(List.of(), page, size, 0); + } + Page topLevel = comments.findByArticleIdAndStatusAndParentIdIsNullOrderByCreatedAtAscIdAsc( + articleId, CommentStatus.APPROVED, PageRequest.of(page, size)); + List parentIds = topLevel.getContent().stream().map(Comment::getId).toList(); + Map> replies = parentIds.isEmpty() ? Map.of() : comments + .findByArticleIdAndStatusAndParentIdInOrderByCreatedAtAscIdAsc( + articleId, CommentStatus.APPROVED, parentIds) + .stream().collect(java.util.stream.Collectors.groupingBy( + Comment::getParentId, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List items = topLevel.getContent().stream() + .map(comment -> view(comment, replies.getOrDefault(comment.getId(), List.of()))) + .toList(); + return new CommentPage(items, topLevel.getNumber(), topLevel.getSize(), topLevel.getTotalElements()); + } + + @Transactional + public CreateResult create(String slug, CreateCommand command, String visitorCookie, String remoteAddress) { + ArticleCommentLookup.Target target = publicArticle(slug); + if (!site.get().commentsEnabled() || !target.commentsEnabled()) { + throw new ProblemException("COMMENTING_DISABLED", "Comments are disabled", + "Comments are not currently accepting new submissions"); + } + String nickname = requiredTrimmed(command.nickname(), "COMMENT_NICKNAME_INVALID", "Nickname must be 2 to 40 characters"); + String content = requiredTrimmed(command.content(), "COMMENT_CONTENT_INVALID", "Content must be 2 to 2000 characters"); + String email = command.email() == null ? null : command.email().trim(); + validateLengths(nickname, content, email); + validateContent(content); + + CommentChallengeService.Validation challenge = challenges.validateAndConsume(target.articleId(), command.challenge()); + if (challenge != CommentChallengeService.Validation.VALID) { + throw switch (challenge) { + case TOO_EARLY -> new ProblemException("COMMENT_CHALLENGE_TOO_EARLY", "Comment challenge submitted too soon", + "Please keep the form open for at least three seconds"); + case EXPIRED -> new ProblemException("COMMENT_CHALLENGE_EXPIRED", "Comment challenge expired", + "Request a new comment form challenge"); + default -> new ProblemException("COMMENT_CHALLENGE_INVALID", "Invalid comment challenge", + "Request a new comment form challenge"); + }; + } + + String honeypot = command.honeypot(); + if ((honeypot != null && !honeypot.isBlank()) || (command.website() != null && !command.website().isBlank())) { + RateDecision rate = rate(visitorCookie, remoteAddress); + if (!rate.allowed()) throw new CommentRateLimitException(rate.retryAfterSeconds()); + return new CreateResult(null, CommentStatus.PENDING, clock.instant(), null); + } + + UUID parentId = command.parentId(); + if (parentId != null && comments.findByIdAndArticleIdAndStatusAndParentIdIsNull( + parentId, target.articleId(), CommentStatus.APPROVED).isEmpty()) { + throw new ProblemException("COMMENT_PARENT_INVALID", "Invalid parent comment", + "Replies must target an approved top-level comment on the same article"); + } + byte[] fingerprint = security.contentFingerprint(target.articleId(), normalizeForFingerprint(content)); + if (comments.existsByArticleIdAndContentFingerprint(target.articleId(), fingerprint)) { + throw new ProblemException("COMMENT_DUPLICATE", "Duplicate comment", + "An equivalent comment has already been submitted"); + } + RateDecision rate = rate(visitorCookie, remoteAddress); + if (!rate.allowed()) throw new CommentRateLimitException(rate.retryAfterSeconds()); + + Instant now = clock.instant(); + UUID commentId = io.haoblog.shared.id.UuidV7.generate(); + String deleteToken = security.newDeleteToken(); + CommentSecurityService.EmailCiphertext encryptedEmail = security.encryptEmail(commentId, email); + Comment comment = new Comment(commentId, target.articleId(), parentId, nickname, + encryptedEmail == null ? null : encryptedEmail.ciphertext(), + encryptedEmail == null ? null : encryptedEmail.nonce(), + encryptedEmail == null ? null : encryptedEmail.keyVersion(), content, + security.dailyIpHmac(remoteAddress, LocalDate.ofInstant(now, ZoneOffset.UTC)), + LocalDate.ofInstant(now, ZoneOffset.UTC), fingerprint, + security.deleteTokenDigest(deleteToken), now); + comments.save(comment); + outbox.save(new OutboxEvent(commentId, "COMMENT_CREATED", Map.of( + "commentId", commentId.toString(), + "articleId", target.articleId().toString(), + "eventType", "COMMENT_CREATED", + "occurredAt", now.toString()), now, now)); + return new CreateResult(commentId, CommentStatus.PENDING, now, deleteToken); + } + + @Transactional + public void delete(UUID commentId, String deleteToken) { + Comment comment = comments.findById(commentId).orElseThrow(() -> + new ProblemException("COMMENT_NOT_FOUND", "Comment not found", "The requested comment does not exist")); + if (comment.getStatus() == CommentStatus.USER_DELETED) { + throw new ProblemException("COMMENT_ALREADY_DELETED", "Comment already deleted", "The comment has already been deleted"); + } + if (deleteToken == null || deleteToken.isBlank() + || !java.security.MessageDigest.isEqual(comment.getDeleteTokenDigest(), security.deleteTokenDigest(deleteToken))) { + throw new ProblemException("COMMENT_DELETE_TOKEN_INVALID", "Invalid delete token", "The delete token is invalid"); + } + comment.userDelete(clock.instant()); + } + + private RateDecision rate(String visitorCookie, String remoteAddress) { + String source = validVisitorCookie(visitorCookie) + ? "visitor:" + visitorCookie + : "ip:" + Base64.getUrlEncoder().withoutPadding().encodeToString( + security.dailyIpHmac(remoteAddress, LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC))); + CommentRateLimiter.Decision decision = rateLimiter.checkAndRecord(source); + return new RateDecision(decision.allowed(), decision.retryAfterSeconds()); + } + + private ArticleCommentLookup.Target publicArticle(String slug) { + return articles.findPublicCommentTarget(slug).orElseThrow(() -> + new ProblemException("ARTICLE_NOT_FOUND", "Article not found", "The requested public article does not exist")); + } + + private static boolean validVisitorCookie(String value) { + return value != null && value.matches("[A-Za-z0-9_-]{43}"); + } + + private static String requiredTrimmed(String value, String code, String detail) { + String normalized = value == null ? "" : value.strip(); + if (normalized.isEmpty()) throw new ProblemException(code, "Invalid comment", detail); + return normalized; + } + + private static void validateLengths(String nickname, String content, String email) { + checkCharacters(nickname, 2, 40, "COMMENT_NICKNAME_INVALID", "Nickname must be 2 to 40 characters"); + checkCharacters(content, 2, 2000, "COMMENT_CONTENT_INVALID", "Content must be 2 to 2000 characters"); + if (email != null && email.codePointCount(0, email.length()) > 254) { + throw new ProblemException("COMMENT_EMAIL_INVALID", "Invalid comment email", "Email must be at most 254 characters"); + } + } + + private static void checkCharacters(String value, int min, int max, String code, String detail) { + int length = value.codePointCount(0, value.length()); + if (length < min || length > max) throw new ProblemException(code, "Invalid comment", detail); + } + + private static void validateContent(String content) { + if (RAW_HTML.matcher(content).find() || content.indexOf('\u0000') >= 0) { + throw new ProblemException("COMMENT_CONTENT_INVALID", "Invalid comment content", "Raw HTML is not allowed"); + } + Matcher unsafe = UNSAFE_SCHEME.matcher(content); + if (unsafe.find()) { + throw new ProblemException("COMMENT_LINK_PROTOCOL_INVALID", "Invalid comment link", + "Only https links are allowed"); + } + Matcher schemes = URL_SCHEME.matcher(content); + while (schemes.find()) { + if (!"https".equalsIgnoreCase(schemes.group(1))) { + throw new ProblemException("COMMENT_LINK_PROTOCOL_INVALID", "Invalid comment link", + "Only https links are allowed"); + } + } + Matcher links = HTTPS_LINK.matcher(content); + int count = 0; + while (links.find() && ++count <= 3) { /* 统计安全链接数量 */ } + if (count > 3) { + throw new ProblemException("COMMENT_TOO_MANY_LINKS", "Too many comment links", + "At most three https links are allowed"); + } + } + + private static String normalizeForFingerprint(String content) { + return Normalizer.normalize(content, Normalizer.Form.NFC) + .replaceAll("\\s+", " ").strip().toLowerCase(Locale.ROOT); + } + + private static CommentView view(Comment comment, List replies) { + return new CommentView(comment.getId(), comment.getNickname(), comment.getContent(), comment.getCreatedAt(), + replies.stream().map(reply -> new CommentView(reply.getId(), reply.getNickname(), reply.getContent(), + reply.getCreatedAt(), List.of())).toList()); + } + + public record CreateCommand(String nickname, String email, String content, UUID parentId, + String challenge, String honeypot, String website) {} + + public record CreateResult(UUID id, CommentStatus status, Instant createdAt, String deleteToken) {} + + public record CommentPage(List items, int page, int size, long total) {} + + public record CommentView(UUID id, String nickname, String content, Instant createdAt, + List replies) {} + + public record AdminComment(UUID id, UUID articleId, UUID parentId, String nickname, String content, + String emailMasked, CommentStatus status, UUID moderatorId, + String moderationReason, Instant moderatedAt, Instant createdAt, + Instant updatedAt, long version) { + static AdminComment from(Comment comment, String emailMasked) { + return new AdminComment(comment.getId(), comment.getArticleId(), comment.getParentId(), comment.getNickname(), + comment.getContent(), emailMasked, comment.getStatus(), comment.getModeratorId(), + comment.getModerationReason(), comment.getModeratedAt(), comment.getCreatedAt(), + comment.getUpdatedAt(), comment.getVersion()); + } + } + + public record AdminCommentDetail(UUID id, UUID articleId, UUID parentId, String nickname, String content, + String email, CommentStatus status, UUID moderatorId, + String moderationReason, Instant moderatedAt, Instant createdAt, + Instant updatedAt, long version) { + static AdminCommentDetail from(Comment comment, String email) { + return new AdminCommentDetail(comment.getId(), comment.getArticleId(), comment.getParentId(), + comment.getNickname(), comment.getContent(), email, comment.getStatus(), comment.getModeratorId(), + comment.getModerationReason(), comment.getModeratedAt(), comment.getCreatedAt(), + comment.getUpdatedAt(), comment.getVersion()); + } + } + + private record RateDecision(boolean allowed, long retryAfterSeconds) {} +} diff --git a/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java new file mode 100644 index 0000000..126fd8a --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java @@ -0,0 +1,140 @@ +package io.haoblog.comment.domain; + +import io.haoblog.shared.id.UuidV7; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.UUID; + +@Entity +@Table(name = "comment") +public class Comment { + @Id + private UUID id = UuidV7.generate(); + @Column(name = "article_id", nullable = false) + private UUID articleId; + @Column(name = "parent_id") + private UUID parentId; + @Column(nullable = false, length = 40) + private String nickname; + @Column(name = "email_ciphertext") + private byte[] emailCiphertext; + @Column(name = "email_nonce") + private byte[] emailNonce; + @Column(name = "email_key_version") + private Integer emailKeyVersion; + @Column(nullable = false, columnDefinition = "text") + private String content; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private CommentStatus status = CommentStatus.PENDING; + @Column(name = "ip_hmac", nullable = false) + private byte[] ipHmac; + @Column(name = "ip_hmac_date", nullable = false) + private LocalDate ipHmacDate; + @Column(name = "content_fingerprint", nullable = false) + private byte[] contentFingerprint; + @Column(name = "delete_token_digest", nullable = false, unique = true) + private byte[] deleteTokenDigest; + @Column(name = "moderator_id") + private UUID moderatorId; + @Column(name = "moderation_reason", length = 600) + private String moderationReason; + @Column(name = "created_at", nullable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Column(name = "moderated_at") + private Instant moderatedAt; + @Column(name = "deleted_at") + private Instant deletedAt; + @Version + @Column(nullable = false) + private long version; + + protected Comment() {} + + public Comment(UUID articleId, UUID parentId, String nickname, byte[] emailCiphertext, + byte[] emailNonce, Integer emailKeyVersion, String content, byte[] ipHmac, + LocalDate ipHmacDate, byte[] contentFingerprint, byte[] deleteTokenDigest, + Instant now) { + this.articleId = articleId; + this.parentId = parentId; + this.nickname = nickname; + this.emailCiphertext = copy(emailCiphertext); + this.emailNonce = copy(emailNonce); + this.emailKeyVersion = emailKeyVersion; + this.content = content; + this.ipHmac = copy(ipHmac); + this.ipHmacDate = ipHmacDate; + this.contentFingerprint = copy(contentFingerprint); + this.deleteTokenDigest = copy(deleteTokenDigest); + this.createdAt = now; + this.updatedAt = now; + } + + public Comment(UUID id, UUID articleId, UUID parentId, String nickname, byte[] emailCiphertext, + byte[] emailNonce, Integer emailKeyVersion, String content, byte[] ipHmac, + LocalDate ipHmacDate, byte[] contentFingerprint, byte[] deleteTokenDigest, + Instant now) { + this(articleId, parentId, nickname, emailCiphertext, emailNonce, emailKeyVersion, content, + ipHmac, ipHmacDate, contentFingerprint, deleteTokenDigest, now); + if (id == null) throw new IllegalArgumentException("Comment id is required"); + this.id = id; + } + + public UUID getId() { return id; } + public UUID getArticleId() { return articleId; } + public UUID getParentId() { return parentId; } + public String getNickname() { return nickname; } + public byte[] getEmailCiphertext() { return copy(emailCiphertext); } + public byte[] getEmailNonce() { return copy(emailNonce); } + public Integer getEmailKeyVersion() { return emailKeyVersion; } + public String getContent() { return content; } + public CommentStatus getStatus() { return status; } + public byte[] getIpHmac() { return copy(ipHmac); } + public LocalDate getIpHmacDate() { return ipHmacDate; } + public byte[] getContentFingerprint() { return copy(contentFingerprint); } + public byte[] getDeleteTokenDigest() { return copy(deleteTokenDigest); } + public UUID getModeratorId() { return moderatorId; } + public String getModerationReason() { return moderationReason; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public Instant getModeratedAt() { return moderatedAt; } + public Instant getDeletedAt() { return deletedAt; } + public long getVersion() { return version; } + + public void moderate(CommentStatus status, UUID moderatorId, String reason, Instant now) { + if (status == null || now == null) throw new IllegalArgumentException("Comment status and time are required"); + this.status = status; + this.moderatorId = moderatorId; + this.moderationReason = reason; + this.moderatedAt = now; + this.updatedAt = now; + } + + public void userDelete(Instant now) { + if (now == null) throw new IllegalArgumentException("Deletion time is required"); + if (status == CommentStatus.USER_DELETED) throw new IllegalStateException("Comment is already deleted"); + this.status = CommentStatus.USER_DELETED; + this.nickname = ""; + this.content = ""; + this.emailCiphertext = null; + this.emailNonce = null; + this.emailKeyVersion = null; + this.deletedAt = now; + this.updatedAt = now; + } + + private static byte[] copy(byte[] value) { + return value == null ? null : Arrays.copyOf(value, value.length); + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/domain/CommentStatus.java b/apps/api/src/main/java/io/haoblog/comment/domain/CommentStatus.java new file mode 100644 index 0000000..b394a8b --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/domain/CommentStatus.java @@ -0,0 +1,9 @@ +package io.haoblog.comment.domain; + +public enum CommentStatus { + PENDING, + APPROVED, + SPAM, + REJECTED, + USER_DELETED +} diff --git a/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java b/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java new file mode 100644 index 0000000..c3b6b5d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java @@ -0,0 +1,53 @@ +package io.haoblog.comment.persistence; + +import io.haoblog.comment.domain.Comment; +import io.haoblog.comment.domain.CommentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface CommentRepository extends JpaRepository { + @Query(value = """ + select c.* from comment c + where (cast(:status as varchar) is null or c.status = cast(:status as varchar)) + and (cast(:articleId as uuid) is null or c.article_id = cast(:articleId as uuid)) + and (cast(:keyword as text) is null or lower(c.nickname) like concat('%', cast(:keyword as text), '%') + or lower(c.content) like concat('%', cast(:keyword as text), '%')) + order by + case when cast(:direction as varchar) = 'asc' then c.created_at end asc, + case when cast(:direction as varchar) <> 'asc' then c.created_at end desc, + case when cast(:direction as varchar) = 'asc' then c.id end asc, + case when cast(:direction as varchar) <> 'asc' then c.id end desc + """, + countQuery = """ + select count(*) from comment c + where (cast(:status as varchar) is null or c.status = cast(:status as varchar)) + and (cast(:articleId as uuid) is null or c.article_id = cast(:articleId as uuid)) + and (cast(:keyword as text) is null or lower(c.nickname) like concat('%', cast(:keyword as text), '%') + or lower(c.content) like concat('%', cast(:keyword as text), '%')) + """, nativeQuery = true) + Page findAdminComments(@Param("status") String status, + @Param("articleId") String articleId, + @Param("keyword") String keyword, + @Param("direction") String direction, + Pageable pageable); + + Page findByArticleIdAndStatusAndParentIdIsNullOrderByCreatedAtAscIdAsc( + UUID articleId, CommentStatus status, Pageable pageable); + + List findByArticleIdAndStatusAndParentIdInOrderByCreatedAtAscIdAsc( + UUID articleId, CommentStatus status, List parentIds); + + Optional findByIdAndArticleId(UUID id, UUID articleId); + + Optional findByIdAndArticleIdAndStatusAndParentIdIsNull( + UUID id, UUID articleId, CommentStatus status); + + boolean existsByArticleIdAndContentFingerprint(UUID articleId, byte[] contentFingerprint); +} diff --git a/apps/api/src/main/java/io/haoblog/comment/web/AdminCommentController.java b/apps/api/src/main/java/io/haoblog/comment/web/AdminCommentController.java new file mode 100644 index 0000000..ab4940e --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/web/AdminCommentController.java @@ -0,0 +1,69 @@ +package io.haoblog.comment.web; + +import io.haoblog.comment.application.CommentService; +import io.haoblog.comment.domain.CommentStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.data.domain.Sort; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/comments") +public class AdminCommentController { + private final CommentService service; + + public AdminCommentController(CommentService service) { + this.service = service; + } + + @GetMapping + public ListResponse list(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) CommentStatus status, + @RequestParam(required = false) UUID articleId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "desc") String direction) { + var result = service.listAdmin(status, articleId, keyword, page, size, parseDirection(direction)); + return new ListResponse(result.getContent(), result.getNumber(), result.getSize(), result.getTotalElements()); + } + + @GetMapping("/{id}") + public CommentService.AdminCommentDetail get(@PathVariable UUID id) { + return service.getAdmin(id); + } + + @PostMapping("/{id}/moderation") + public CommentService.AdminCommentDetail moderate(@PathVariable UUID id, + @RequestBody @Valid ModerationRequest request, + Authentication authentication) { + try { + return service.moderate(id, request.version(), request.status(), request.reason(), authentication.getName()); + } catch (OptimisticLockingFailureException exception) { + throw new io.haoblog.shared.web.ProblemException("COMMENT_VERSION_CONFLICT", "Comment version conflict", + "Reload the latest comment before moderating it", service.getAdmin(id).version()); + } + } + + private static Sort.Direction parseDirection(String direction) { + if ("asc".equalsIgnoreCase(direction)) return Sort.Direction.ASC; + if ("desc".equalsIgnoreCase(direction)) return Sort.Direction.DESC; + throw new IllegalArgumentException("direction must be asc or desc"); + } + + public record ListResponse(List items, int page, int size, long total) {} + + public record ModerationRequest(@NotNull Long version, @NotNull CommentStatus status, + @Size(max = 600) String reason) {} +} diff --git a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java new file mode 100644 index 0000000..1c11d6d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java @@ -0,0 +1,76 @@ +package io.haoblog.comment.web; + +import io.haoblog.comment.application.CommentChallengeService; +import io.haoblog.content.application.ArticleCommentLookup; +import io.haoblog.shared.web.ProblemResponse; +import io.haoblog.site.application.SiteService; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.time.Duration; + +@RestController +@RequestMapping("/api/v1/public/articles") +public class PublicCommentContextController { + private final ArticleCommentLookup articles; + private final SiteService site; + private final CommentChallengeService challenges; + private final io.haoblog.comment.application.CommentSecurityService security; + + public PublicCommentContextController(ArticleCommentLookup articles, SiteService site, CommentChallengeService challenges, + io.haoblog.comment.application.CommentSecurityService security) { + this.articles = articles; + this.site = site; + this.challenges = challenges; + this.security = security; + } + + @GetMapping("/{slug}/comments/form-context") + public FormContext formContext(@PathVariable String slug, CsrfToken csrfToken, + HttpServletRequest request, HttpServletResponse response) { + var article = articles.findPublicCommentTarget(slug).orElseThrow(() -> new ArticleNotFoundException(slug)); + var issued = challenges.issue(article.articleId()); + if (visitorCookie(request) == null) { + response.addHeader("Set-Cookie", ResponseCookie.from("HAOBLOG_VISITOR", security.newVisitorToken()) + .httpOnly(true).sameSite("Lax").path("/").maxAge(Duration.ofDays(365)).build().toString()); + } + return new FormContext(csrfToken.getToken(), issued.token(), issued.expiresAt(), + site.get().commentsEnabled() && article.commentsEnabled()); + } + + private String visitorCookie(HttpServletRequest request) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if ("HAOBLOG_VISITOR".equals(cookie.getName()) && cookie.getValue() != null + && cookie.getValue().matches("[A-Za-z0-9_-]{43}")) return cookie.getValue(); + } + return null; + } + + @ExceptionHandler(ArticleNotFoundException.class) + ResponseEntity articleNotFound(ArticleNotFoundException ignored) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ProblemResponse("ARTICLE_NOT_FOUND", "Article not found", + "The requested public article does not exist", MDC.get("traceId"))); + } + + public record FormContext(String csrfToken, String challenge, Instant expiresAt, boolean commentsEnabled) {} + + private static final class ArticleNotFoundException extends RuntimeException { + private ArticleNotFoundException(String slug) { + super("Article not found: " + slug); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java new file mode 100644 index 0000000..d809e71 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java @@ -0,0 +1,94 @@ +package io.haoblog.comment.web; + +import io.haoblog.comment.application.CommentRateLimitException; +import io.haoblog.comment.application.CommentService; +import io.haoblog.shared.web.ProblemResponse; +import io.haoblog.shared.web.ProblemResponseWriter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/public") +public class PublicCommentController { + private static final String VISITOR_COOKIE = "HAOBLOG_VISITOR"; + private static final String DELETE_TOKEN_HEADER = "X-Comment-Delete-Token"; + + private final CommentService comments; + private final ProblemResponseWriter problemResponseWriter; + + public PublicCommentController(CommentService comments, ProblemResponseWriter problemResponseWriter) { + this.comments = comments; + this.problemResponseWriter = problemResponseWriter; + } + + @GetMapping("/articles/{slug}/comments") + public CommentService.CommentPage list(@PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return comments.list(slug, page, size); + } + + @PostMapping("/articles/{slug}/comments") + public ResponseEntity create(@PathVariable String slug, + @RequestBody CommentRequest request, + HttpServletRequest httpRequest) { + CommentService.CreateResult result = comments.create(slug, + new CommentService.CreateCommand(request.nickname(), request.email(), request.content(), request.parentId(), + request.challenge(), request.honeypot(), request.website()), + visitorCookie(httpRequest), httpRequest.getRemoteAddr()); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(new SubmissionResponse(result.id(), result.status().name(), result.createdAt(), result.deleteToken())); + } + + @DeleteMapping("/comments/{id}") + public ResponseEntity delete(@PathVariable UUID id, + @RequestHeader(value = DELETE_TOKEN_HEADER, required = false) String deleteToken) { + comments.delete(id, deleteToken); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(CommentRateLimitException.class) + ResponseEntity rateLimited(CommentRateLimitException exception) { + ProblemResponse body = problemResponseWriter.response(HttpStatus.TOO_MANY_REQUESTS, + "COMMENT_RATE_LIMITED", "Comment rate limit exceeded", + "Too many comments have been submitted from this source").getBody(); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, Long.toString(exception.getRetryAfterSeconds())) + .contentType(ProblemResponseWriter.PROBLEM) + .body(body); + } + + private String visitorCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (VISITOR_COOKIE.equals(cookie.getName()) && cookie.getValue() != null + && cookie.getValue().matches("[A-Za-z0-9_-]{43}")) { + return cookie.getValue(); + } + } + } + return null; + } + + public record CommentRequest(String nickname, String email, String content, UUID parentId, + String challenge, String honeypot, String website) {} + + public record SubmissionResponse(UUID id, String status, java.time.Instant createdAt, String deleteToken) {} +} diff --git a/apps/api/src/main/java/io/haoblog/content/application/AdminContentService.java b/apps/api/src/main/java/io/haoblog/content/application/AdminContentService.java index 9170b89..ad808f5 100644 --- a/apps/api/src/main/java/io/haoblog/content/application/AdminContentService.java +++ b/apps/api/src/main/java/io/haoblog/content/application/AdminContentService.java @@ -66,7 +66,7 @@ public Page
listArticles(int page, int size, ArticleStatus status, Stri @Transactional public Article createArticle(String slug, String title, String excerpt, String markdown, String seoTitle, String seoDescription, Instant scheduledAt, UUID categoryId, - UUID coverMediaId, List tagIds) { + UUID coverMediaId, List tagIds, Boolean commentsEnabled) { String resolvedTitle = title == null || title.isBlank() ? "未命名草稿" : title.trim(); String resolvedMarkdown = markdown == null ? "" : markdown; validateArticle(resolvedTitle, resolvedMarkdown, excerpt, seoTitle, seoDescription); @@ -82,6 +82,7 @@ public Article createArticle(String slug, String title, String excerpt, String m ArticleStatus.DRAFT, null, now); article.updateWorkingCopy(normalizedSlug, resolvedTitle, excerpt, resolvedMarkdown, seoTitle, seoDescription, scheduledAt, categoryId, coverMediaId, now); + article.setCommentsEnabled(commentsEnabled == null || commentsEnabled); article.replaceTags(resolvedTags); return articles.saveAndFlush(article); } @@ -142,7 +143,7 @@ public Article restoreRevision(UUID articleId, UUID revisionId, long version) { @Transactional public Article updateArticle(UUID id, long version, String slug, String title, String excerpt, String markdown, String seoTitle, String seoDescription, Instant scheduledAt, UUID categoryId, - UUID coverMediaId, List tagIds) { + UUID coverMediaId, List tagIds, Boolean commentsEnabled) { Article article = getArticle(id); if (article.getStatus() == ArticleStatus.ARCHIVED) { throw new ProblemException("ARTICLE_STATE_CONFLICT", "Article is archived", @@ -152,7 +153,7 @@ public Article updateArticle(UUID id, long version, String slug, String title, S throw new ProblemException("ARTICLE_VERSION_CONFLICT", "Article version conflict", "Reload the latest article before saving", article.getVersion()); } - String resolvedSlug = Slug.normalizeNullable(slug); + String resolvedSlug = slug == null ? article.getSlug() : Slug.normalizeNullable(slug); if (resolvedSlug != null && articles.existsBySlugAndIdNot(resolvedSlug, id)) { throw conflict("ARTICLE_SLUG_CONFLICT", "Article slug conflict", "The article slug is already in use"); } @@ -162,6 +163,7 @@ public Article updateArticle(UUID id, long version, String slug, String title, S requireMedia(coverMediaId); article.updateWorkingCopy(resolvedSlug, title.trim(), excerpt, markdown, seoTitle, seoDescription, scheduledAt, categoryId, coverMediaId, Instant.now(clock)); + if (commentsEnabled != null) article.setCommentsEnabled(commentsEnabled); article.replaceTags(resolvedTags); return articles.saveAndFlush(article); } diff --git a/apps/api/src/main/java/io/haoblog/content/application/ArticleCommentLookup.java b/apps/api/src/main/java/io/haoblog/content/application/ArticleCommentLookup.java new file mode 100644 index 0000000..d79e3fc --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/content/application/ArticleCommentLookup.java @@ -0,0 +1,14 @@ +package io.haoblog.content.application; + +import java.util.Optional; +import java.util.UUID; + +/** 为 comment 模块公开文章身份和评论开关,不暴露 content 实体或仓储。 */ +public interface ArticleCommentLookup { + Optional findPublicCommentTarget(String slug); + + Optional findCommentNotificationArticle(UUID articleId); + + record Target(UUID articleId, boolean commentsEnabled) {} + record NotificationArticle(String title) {} +} diff --git a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java index 7a469f0..958ca94 100644 --- a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java +++ b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import java.time.Clock; +import java.text.Normalizer; import java.util.Collection; import java.util.List; import java.util.Map; @@ -19,7 +20,7 @@ import java.util.stream.Collectors; @Service -public class ArticleService { +public class ArticleService implements ArticleCommentLookup { private final ArticleRevisionRepository repository; private final MediaAssetRepository mediaRepository; private final Clock clock; @@ -42,11 +43,45 @@ public Optional findPublicBySlug(String slug) { .map(this::toPublicArticle); } + @Override + public Optional findPublicCommentTarget(String slug) { + return findPublicBySlug(slug) + .map(article -> new ArticleCommentLookup.Target(article.articleId(), article.commentsEnabled())); + } + + @Override + public Optional findCommentNotificationArticle(UUID articleId) { + return repository.findNotificationArticle(articleId) + .map(article -> new ArticleCommentLookup.NotificationArticle(article.getTitle())); + } + public PublishedBatch listPublishedBatch(int page, int size) { if (page < 0 || size < 1 || size > 500) throw new IllegalArgumentException("page/size out of range"); var result = repository.findPublished(ArticleStatus.PUBLISHED, java.time.Instant.now(clock), PageRequest.of(page, size)); return new PublishedBatch(result.getContent().stream().map(this::toPublicFeedArticle).toList(), result.hasNext()); } + + public SearchPage search(String query, int page, int size) { + String normalizedQuery = normalizeSearchQuery(query); + if (page < 0 || size < 1 || size > 20) throw new IllegalArgumentException("page/size out of range"); + var result = repository.searchVisible(toLikePattern(normalizedQuery), java.time.Instant.now(clock), + PageRequest.of(page, size)); + return new SearchPage(normalizedQuery, result.map(projection -> new PublicSearchArticle( + projection.getId(), projection.getSlug(), projection.getTitle(), projection.getExcerpt(), + projection.getPublishedAt(), projection.getCoverMediaId(), projection.getCommentsEnabled()))); + } + + public static String normalizeSearchQuery(String query) { + if (query == null) throw new IllegalArgumentException("query is required"); + String normalized = Normalizer.normalize(query, Normalizer.Form.NFKC).strip(); + int length = normalized.codePointCount(0, normalized.length()); + if (length < 2 || length > 100) throw new IllegalArgumentException("query length out of range"); + return normalized; + } + + private static String toLikePattern(String query) { + return "%" + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"; + } public Map publicCoverUrls(Collection mediaIds) { if (mediaIds == null || mediaIds.isEmpty()) return Map.of(); return mediaRepository.findAllByIdInAndStatus(mediaIds, MediaAssetStatus.AVAILABLE).stream() @@ -55,7 +90,7 @@ public Map publicCoverUrls(Collection mediaIds) { .collect(Collectors.toUnmodifiableMap(MediaAsset::getId, MediaAsset::getPublicUrl)); } private PublicArticle toPublicArticle(ArticleRevisionRepository.PublicArticleProjection projection) { - return new PublicArticle(projection.getRevision(), projection.getPublishedAt()); + return new PublicArticle(projection.getRevision(), projection.getPublishedAt(), projection.getCommentsEnabled()); } private PublicFeedArticle toPublicFeedArticle(ArticleRevisionRepository.PublicArticleProjection projection) { @@ -64,8 +99,19 @@ private PublicFeedArticle toPublicFeedArticle(ArticleRevisionRepository.PublicAr revision.getExcerpt(), projection.getPublishedAt()); } - public record PublicArticle(ArticleRevision revision, java.time.Instant publishedAt) {} + public record PublicArticle(ArticleRevision revision, java.time.Instant publishedAt, boolean commentsEnabled) { + public PublicArticle(ArticleRevision revision, java.time.Instant publishedAt) { + this(revision, publishedAt, true); + } + + public UUID articleId() { + return revision.getArticleId(); + } + } public record PageResult(Page page) {} public record PublishedBatch(List items, boolean hasNext) {} public record PublicFeedArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt) {} + public record SearchPage(String query, Page page) {} + public record PublicSearchArticle(UUID id, String slug, String title, String excerpt, + java.time.Instant publishedAt, UUID coverMediaId, boolean commentsEnabled) {} } diff --git a/apps/api/src/main/java/io/haoblog/content/domain/Article.java b/apps/api/src/main/java/io/haoblog/content/domain/Article.java index 098a02d..e600166 100644 --- a/apps/api/src/main/java/io/haoblog/content/domain/Article.java +++ b/apps/api/src/main/java/io/haoblog/content/domain/Article.java @@ -44,6 +44,8 @@ public class Article { private UUID categoryId; @Column(name = "cover_media_id") private UUID coverMediaId; + @Column(name = "comments_enabled", nullable = false) + private boolean commentsEnabled = true; @ManyToMany @JoinTable(name = "article_tag", joinColumns = @JoinColumn(name = "article_id"), @@ -139,6 +141,8 @@ private static String normalizeSlug(String raw, ArticleStatus status) { public UUID getPublishedRevisionId() { return publishedRevisionId; } public UUID getCategoryId() { return categoryId; } public UUID getCoverMediaId() { return coverMediaId; } + public boolean isCommentsEnabled() { return commentsEnabled; } + public void setCommentsEnabled(boolean commentsEnabled) { this.commentsEnabled = commentsEnabled; } public Set getTags() { return tags; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } diff --git a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java index 3d65033..8017597 100644 --- a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java +++ b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -30,7 +31,15 @@ public interface ArticleRevisionRepository extends JpaRepository findByIdAndArticleId(UUID id, UUID articleId); @Query(""" - select r as revision, a.publishedAt as publishedAt + select r.title as title + from ArticleRevision r, Article a + where r.id = a.publishedRevisionId + and a.id = :articleId + """) + Optional findNotificationArticle(@Param("articleId") UUID articleId); + + @Query(""" + select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled from ArticleRevision r, Article a where r.id = a.publishedRevisionId and a.status in (:published, :scheduled) @@ -45,7 +54,7 @@ Page findVisible(@Param("published") ArticleStatus publ Pageable pageable); @Query(""" - select r as revision, a.publishedAt as publishedAt + select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled from ArticleRevision r, Article a where r.id = a.publishedRevisionId and a.status = :published @@ -58,8 +67,41 @@ Page findPublished(@Param("published") ArticleStatus pu @Param("now") java.time.Instant now, Pageable pageable); + @Query(value = """ + select a.id as id, r.slug as slug, r.title as title, r.excerpt as excerpt, + a.published_at as "publishedAt", r.cover_media_id as "coverMediaId", + a.comments_enabled as "commentsEnabled" + from article_revision r + join article a on a.published_revision_id = r.id + where a.status in ('PUBLISHED', 'SCHEDULED') + and a.published_revision_id is not null + and a.published_at is not null + and a.published_at <= :now + and (coalesce(r.title, '') || ' ' || coalesce(r.excerpt, '') || ' ' || r.markdown_source) + ilike :pattern escape chr(92) + order by case + when r.title ilike :pattern escape chr(92) then 0 + when coalesce(r.excerpt, '') ilike :pattern escape chr(92) then 1 + else 2 + end, + a.published_at desc, a.id desc + """, + countQuery = """ + select count(*) + from article_revision r + join article a on a.published_revision_id = r.id + where a.status in ('PUBLISHED', 'SCHEDULED') + and a.published_revision_id is not null + and a.published_at is not null + and a.published_at <= :now + and (coalesce(r.title, '') || ' ' || coalesce(r.excerpt, '') || ' ' || r.markdown_source) + ilike :pattern escape chr(92) + """, nativeQuery = true) + Page searchVisible(@Param("pattern") String pattern, @Param("now") Instant now, + Pageable pageable); + @Query(""" - select r as revision, a.publishedAt as publishedAt + select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled from ArticleRevision r, Article a where r.id = a.publishedRevisionId and r.slug = :slug @@ -92,6 +134,7 @@ boolean existsVisibleSlug(@Param("slug") String slug, @Param("articleId") UUID a interface PublicArticleProjection { ArticleRevision getRevision(); java.time.Instant getPublishedAt(); + boolean getCommentsEnabled(); } interface SummaryProjection { @@ -101,4 +144,18 @@ interface SummaryProjection { UUID getCreatedBy(); java.time.Instant getCreatedAt(); } + + interface NotificationArticleProjection { + String getTitle(); + } + + interface SearchProjection { + UUID getId(); + String getSlug(); + String getTitle(); + String getExcerpt(); + Instant getPublishedAt(); + UUID getCoverMediaId(); + boolean getCommentsEnabled(); + } } diff --git a/apps/api/src/main/java/io/haoblog/content/web/AdminArticleController.java b/apps/api/src/main/java/io/haoblog/content/web/AdminArticleController.java index d0c96a0..11931eb 100644 --- a/apps/api/src/main/java/io/haoblog/content/web/AdminArticleController.java +++ b/apps/api/src/main/java/io/haoblog/content/web/AdminArticleController.java @@ -51,9 +51,9 @@ public ListResponse list(@RequestParam(defaultValue = "0") int page, @PostMapping public ResponseEntity create(@RequestBody(required = false) @Valid CreateRequest request) { - CreateRequest body = request == null ? new CreateRequest(null, null, null, null, null, null, null, null, null, null) : request; + CreateRequest body = request == null ? new CreateRequest(null, null, null, null, null, null, null, null, null, null, null) : request; Response response = Response.from(service.createArticle(body.slug(), body.title(), body.excerpt(), body.markdown(), - body.seoTitle(), body.seoDescription(), body.scheduledAt(), body.categoryId(), body.coverMediaId(), body.tagIds())); + body.seoTitle(), body.seoDescription(), body.scheduledAt(), body.categoryId(), body.coverMediaId(), body.tagIds(), body.commentsEnabled())); URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(response.id()).toUri(); return ResponseEntity.created(location).body(response); } @@ -89,7 +89,7 @@ public Response restoreVersion(@PathVariable UUID id, @PathVariable UUID revisio public Response update(@PathVariable UUID id, @RequestBody @Valid UpdateRequest request) { try { return Response.from(service.updateArticle(id, request.version(), request.slug(), request.title(), request.excerpt(), - request.markdown(), request.seoTitle(), request.seoDescription(), request.scheduledAt(), request.categoryId(), request.coverMediaId(), request.tagIds())); + request.markdown(), request.seoTitle(), request.seoDescription(), request.scheduledAt(), request.categoryId(), request.coverMediaId(), request.tagIds(), request.commentsEnabled())); } catch (OptimisticLockingFailureException exception) { throw new ProblemException("ARTICLE_VERSION_CONFLICT", "Article version conflict", "Reload the latest article before saving", service.currentVersion(id)); diff --git a/apps/api/src/main/java/io/haoblog/content/web/AdminArticleDtos.java b/apps/api/src/main/java/io/haoblog/content/web/AdminArticleDtos.java index ed5d2cc..c1bcc95 100644 --- a/apps/api/src/main/java/io/haoblog/content/web/AdminArticleDtos.java +++ b/apps/api/src/main/java/io/haoblog/content/web/AdminArticleDtos.java @@ -23,7 +23,8 @@ public record CreateRequest( Instant scheduledAt, UUID categoryId, UUID coverMediaId, - List tagIds) {} + List tagIds, + Boolean commentsEnabled) {} public record UpdateRequest( @NotNull Long version, @@ -36,17 +37,18 @@ public record UpdateRequest( Instant scheduledAt, UUID categoryId, UUID coverMediaId, - List tagIds) {} + List tagIds, + Boolean commentsEnabled) {} public record ListResponse(List items, int page, int size, long total) {} public record Summary(UUID id, String slug, String title, ArticleStatus status, UUID categoryId, - Instant updatedAt, long version) { + Instant updatedAt, long version, boolean commentsEnabled) { static Summary from(Article article) { return new Summary(article.getId(), article.getSlug(), article.getTitle(), article.getStatus(), article.getCategoryId(), - article.getUpdatedAt(), article.getVersion()); + article.getUpdatedAt(), article.getVersion(), article.isCommentsEnabled()); } } @@ -54,13 +56,13 @@ public record Response(UUID id, String slug, String title, String excerpt, Strin ArticleStatus status, Instant publishedAt, Instant scheduledAt, String seoTitle, String seoDescription, UUID categoryId, UUID coverMediaId, List tagIds, Instant createdAt, - Instant updatedAt, long version) { + Instant updatedAt, long version, boolean commentsEnabled) { static Response from(Article article) { return new Response(article.getId(), article.getSlug(), article.getTitle(), article.getExcerpt(), article.getMarkdownSource(), article.getStatus(), article.getPublishedAt(), article.getScheduledAt(), article.getSeoTitle(), article.getSeoDescription(), article.getCategoryId(), article.getCoverMediaId(), article.getTags().stream().map(tag -> tag.getId()).toList(), article.getCreatedAt(), - article.getUpdatedAt(), article.getVersion()); + article.getUpdatedAt(), article.getVersion(), article.isCommentsEnabled()); } } } diff --git a/apps/api/src/main/java/io/haoblog/content/web/PublicArticleController.java b/apps/api/src/main/java/io/haoblog/content/web/PublicArticleController.java index 0022930..7310683 100644 --- a/apps/api/src/main/java/io/haoblog/content/web/PublicArticleController.java +++ b/apps/api/src/main/java/io/haoblog/content/web/PublicArticleController.java @@ -3,12 +3,14 @@ import io.haoblog.content.application.ArticleService; import io.haoblog.content.domain.ArticleRevision; import io.haoblog.shared.web.ProblemResponse; +import io.haoblog.site.application.SiteService; import org.slf4j.MDC; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Autowired; import java.security.MessageDigest; import java.time.Instant; @@ -23,8 +25,17 @@ public class PublicArticleController { private static final MediaType PROBLEM = MediaType.valueOf("application/problem+json"); private final ArticleService service; + private final SiteService siteService; - public PublicArticleController(ArticleService service) { this.service = service; } + @Autowired + public PublicArticleController(ArticleService service, SiteService siteService) { + this.service = service; + this.siteService = siteService; + } + + public PublicArticleController(ArticleService service) { + this(service, null); + } @GetMapping public ResponseEntity articles(@RequestParam(defaultValue = "0") int page, @@ -33,8 +44,10 @@ public ResponseEntity articles(@RequestParam(defaultValue = var result = service.list(page, size).page(); Map coverUrls = service.publicCoverUrls(result.getContent().stream() .map(article -> article.revision().getCoverMediaId()).filter(java.util.Objects::nonNull).collect(Collectors.toSet())); + boolean globalCommentsEnabled = globalCommentsEnabled(); var response = new ArticleListResponse(result.getContent().stream().map(article -> ArticleSummary.from(article, - article.revision().getCoverMediaId() == null ? null : coverUrls.get(article.revision().getCoverMediaId()))).toList(), + article.revision().getCoverMediaId() == null ? null : coverUrls.get(article.revision().getCoverMediaId()), + globalCommentsEnabled)).toList(), result.getNumber(), result.getSize(), result.getTotalElements()); String etag = representationHash("list", result.getContent().stream().map(article -> article.revision().getId()).toList(), response); return withCache(response, etag, ifNoneMatch); @@ -45,7 +58,7 @@ public ResponseEntity article(@PathVariable String slug, @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { ArticleService.PublicArticle article = service.findPublicBySlug(slug).orElseThrow(() -> new ArticleNotFoundException(slug)); String coverImageUrl = article.revision().getCoverMediaId() == null ? null : service.publicCoverUrls(java.util.Set.of(article.revision().getCoverMediaId())).get(article.revision().getCoverMediaId()); - var response = ArticleResponse.from(article, coverImageUrl); + var response = ArticleResponse.from(article, coverImageUrl, globalCommentsEnabled()); return withCache(response, representationHash("detail", article.revision().getId(), response), ifNoneMatch); } @@ -73,21 +86,29 @@ private static String representationHash(Object... values) { } } - public record ArticleSummary(UUID id, String slug, String title, String excerpt, Instant publishedAt, String coverImageUrl) { - static ArticleSummary from(ArticleService.PublicArticle article, String coverImageUrl) { + private boolean globalCommentsEnabled() { + if (siteService == null) return true; + var site = siteService.get(); + return site == null || site.commentsEnabled(); + } + + public record ArticleSummary(UUID id, String slug, String title, String excerpt, Instant publishedAt, String coverImageUrl, + boolean commentsEnabled) { + static ArticleSummary from(ArticleService.PublicArticle article, String coverImageUrl, boolean globalCommentsEnabled) { ArticleRevision revision = article.revision(); return new ArticleSummary(revision.getArticleId(), revision.getSlug(), revision.getTitle(), revision.getExcerpt(), - article.publishedAt(), coverImageUrl); + article.publishedAt(), coverImageUrl, article.commentsEnabled() && globalCommentsEnabled); } } public record ArticleListResponse(List items, int page, int size, long total) {} public record ArticleResponse(UUID id, String slug, String title, String excerpt, Instant publishedAt, Instant modifiedAt, - String markdown, String seoTitle, String seoDescription, String coverImageUrl) { - static ArticleResponse from(ArticleService.PublicArticle article, String coverImageUrl) { + String markdown, String seoTitle, String seoDescription, String coverImageUrl, + boolean commentsEnabled) { + static ArticleResponse from(ArticleService.PublicArticle article, String coverImageUrl, boolean globalCommentsEnabled) { ArticleRevision revision = article.revision(); return new ArticleResponse(revision.getArticleId(), revision.getSlug(), revision.getTitle(), revision.getExcerpt(), article.publishedAt(), revision.getCreatedAt(), revision.getMarkdownSource(), revision.getSeoTitle(), - revision.getSeoDescription(), coverImageUrl); + revision.getSeoDescription(), coverImageUrl, article.commentsEnabled() && globalCommentsEnabled); } } } diff --git a/apps/api/src/main/java/io/haoblog/content/web/PublicSearchController.java b/apps/api/src/main/java/io/haoblog/content/web/PublicSearchController.java new file mode 100644 index 0000000..b31252c --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/content/web/PublicSearchController.java @@ -0,0 +1,71 @@ +package io.haoblog.content.web; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.site.application.SiteService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v1/public/search") +public class PublicSearchController { + private final ArticleService service; + private final SiteService siteService; + + public PublicSearchController(ArticleService service, SiteService siteService) { + this.service = service; + this.siteService = siteService; + } + + @GetMapping("/articles") + public ResponseEntity articles( + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + var result = service.search(q, page, size); + var searchPage = result.page(); + Map coverUrls = service.publicCoverUrls(searchPage.getContent().stream() + .map(ArticleService.PublicSearchArticle::coverMediaId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet())); + var site = siteService.get(); + boolean globalCommentsEnabled = site == null || site.commentsEnabled(); + var response = new PublicArticleController.ArticleListResponse( + searchPage.getContent().stream().map(article -> new PublicArticleController.ArticleSummary( + article.id(), article.slug(), article.title(), article.excerpt(), article.publishedAt(), + article.coverMediaId() == null ? null : coverUrls.get(article.coverMediaId()), + article.commentsEnabled() && globalCommentsEnabled)).toList(), + searchPage.getNumber(), searchPage.getSize(), searchPage.getTotalElements()); + return withCache(response, representationHash("search", result.query(), response), ifNoneMatch); + } + + private ResponseEntity withCache(T body, String etag, String ifNoneMatch) { + var headers = new HttpHeaders(); + headers.setETag(etag); + headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); + if (etag.equals(ifNoneMatch)) return ResponseEntity.status(304).headers(headers).build(); + return ResponseEntity.ok().headers(headers).body(body); + } + + private static String representationHash(Object... values) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + String stableInput = java.util.Arrays.deepToString(values); + return '"' + HexFormat.of().formatHex(digest.digest(stableInput.getBytes(StandardCharsets.UTF_8))) + '"'; + } catch (Exception exception) { + throw new IllegalStateException("Unable to create article search representation ETag", exception); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEvent.java b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEvent.java index 1a6f09e..b838843 100644 --- a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEvent.java +++ b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEvent.java @@ -53,4 +53,42 @@ public OutboxEvent(UUID aggregateId, String eventType, Map paylo public UUID getAggregateId() { return aggregateId; } public String getEventType() { return eventType; } public Map getPayload() { return payload; } + public OutboxStatus getStatus() { return status; } + public int getAttemptCount() { return attemptCount; } + public Instant getAvailableAt() { return availableAt; } + public Instant getProcessedAt() { return processedAt; } + public Instant getCreatedAt() { return createdAt; } + + public void claim(Instant now, Instant leaseUntil) { + if (status != OutboxStatus.PENDING && status != OutboxStatus.PROCESSING) { + throw new IllegalStateException("Only pending outbox events can be claimed"); + } + if (now == null || leaseUntil == null || leaseUntil.isBefore(now)) { + throw new IllegalArgumentException("Outbox claim times are invalid"); + } + status = OutboxStatus.PROCESSING; + attemptCount++; + availableAt = leaseUntil; + } + + public void markProcessed(Instant now) { + if (status != OutboxStatus.PROCESSING) { + throw new IllegalStateException("Only processing outbox events can be processed"); + } + status = OutboxStatus.PROCESSED; + processedAt = now; + } + + public void retryOrFail(Instant now, Instant nextRetry, int maxAttempts) { + if (status != OutboxStatus.PROCESSING) { + throw new IllegalStateException("Only processing outbox events can be retried"); + } + if (attemptCount >= maxAttempts) { + status = OutboxStatus.FAILED; + availableAt = now; + return; + } + status = OutboxStatus.PENDING; + availableAt = nextRetry; + } } diff --git a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java index c818e9c..32469be 100644 --- a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java +++ b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java @@ -1,7 +1,27 @@ package io.haoblog.shared.outbox; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.List; import java.util.UUID; -public interface OutboxEventRepository extends JpaRepository {} +public interface OutboxEventRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select e from OutboxEvent e + where e.eventType = :eventType + and e.status in :statuses + and e.availableAt <= :now + order by e.availableAt asc, e.createdAt asc, e.id asc + """) + List findAvailable(@Param("eventType") String eventType, + @Param("statuses") List statuses, + @Param("now") Instant now, + Pageable pageable); +} diff --git a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java new file mode 100644 index 0000000..e01441d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java @@ -0,0 +1,59 @@ +package io.haoblog.shared.outbox; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +public class OutboxEventStateService { + public static final int MAX_ATTEMPTS = 5; + public static final int BATCH_SIZE = 10; + private static final Duration PROCESSING_LEASE = Duration.ofSeconds(60); + + private final OutboxEventRepository repository; + private final Clock clock; + + public OutboxEventStateService(OutboxEventRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public List claimCommentCreatedBatch() { + Instant now = clock.instant(); + List events = repository.findAvailable("COMMENT_CREATED", + List.of(OutboxStatus.PENDING, OutboxStatus.PROCESSING), now, + PageRequest.of(0, BATCH_SIZE)); + events.forEach(event -> event.claim(now, now.plus(PROCESSING_LEASE))); + return List.copyOf(events); + } + + @Transactional + public void markProcessed(UUID eventId) { + repository.findById(eventId).ifPresent(event -> { + if (event.getStatus() == OutboxStatus.PROCESSING) { + event.markProcessed(clock.instant()); + } + }); + } + + @Transactional + public void markFailedOrRetry(UUID eventId) { + repository.findById(eventId).ifPresent(event -> { + if (event.getStatus() != OutboxStatus.PROCESSING) return; + Instant now = clock.instant(); + event.retryOrFail(now, now.plus(retryDelay(event.getAttemptCount())), MAX_ATTEMPTS); + }); + } + + static Duration retryDelay(int attemptCount) { + long seconds = Math.min(120L, 10L << Math.max(0, attemptCount - 1)); + return Duration.ofSeconds(seconds); + } +} diff --git a/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java b/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java index 6ec6ac2..82b203a 100644 --- a/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java +++ b/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -29,7 +30,8 @@ public GlobalExceptionHandler(ProblemResponseWriter problemResponseWriter) { @ExceptionHandler({MethodArgumentNotValidException.class, HandlerMethodValidationException.class, MethodArgumentTypeMismatchException.class, MissingServletRequestParameterException.class, - HttpMessageNotReadableException.class, IllegalArgumentException.class}) + HttpMessageNotReadableException.class, HttpMediaTypeNotSupportedException.class, + IllegalArgumentException.class}) ResponseEntity badRequest(Exception exception) { return problemResponseWriter.response(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "Invalid request", "Request parameters are invalid"); } @@ -39,8 +41,10 @@ ResponseEntity content(ProblemException exception) { HttpStatus status = "MEDIA_STORAGE_UNAVAILABLE".equals(exception.getCode()) ? HttpStatus.SERVICE_UNAVAILABLE : exception.getCode().endsWith("_NOT_FOUND") ? HttpStatus.NOT_FOUND : (Set.of("ARTICLE_PREVIEW_GONE", "MEDIA_UPLOAD_EXPIRED").contains(exception.getCode()) ? HttpStatus.GONE : - (exception.getCode().contains("CONFLICT") || exception.getCode().endsWith("_IN_USE") - ? HttpStatus.CONFLICT : HttpStatus.BAD_REQUEST)); + (Set.of("COMMENTING_DISABLED", "COMMENT_DUPLICATE", "COMMENT_ALREADY_DELETED").contains(exception.getCode()) + || exception.getCode().contains("CONFLICT") || exception.getCode().endsWith("_IN_USE") + ? HttpStatus.CONFLICT : + ("COMMENT_DELETE_TOKEN_INVALID".equals(exception.getCode()) ? HttpStatus.FORBIDDEN : HttpStatus.BAD_REQUEST))); return problemResponseWriter.response(status, exception.getCode(), exception.getTitle(), exception.getMessage(), exception.getCurrentVersion()); } diff --git a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java index 7d09e95..5ffd22b 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java @@ -23,7 +23,7 @@ public class PublicFeedService { private static final int RSS_LIMIT = 20; private static final int SITEMAP_BATCH_SIZE = 500; private static final int SITEMAP_URL_LIMIT = 50_000; - private static final int STATIC_URL_COUNT = 3; + private static final int STATIC_URL_COUNT = 4; private static final DateTimeFormatter RSS_DATE_FORMAT = DateTimeFormatter.RFC_1123_DATE_TIME; private final SiteService siteService; private final ArticleService articleService; @@ -104,7 +104,7 @@ private static String writeSitemap(SiteService.SiteResult site, List { writer.writeStartElement("urlset"); writer.writeDefaultNamespace("http://www.sitemaps.org/schemas/sitemap/0.9"); - for (String path : List.of("/", "/articles", "/about")) { + for (String path : List.of("/", "/articles", "/tools", "/about")) { urlElement(writer, url(site.siteUrl(), path)); } for (var article : articles) { diff --git a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java index 779dac7..103d1a2 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java @@ -26,7 +26,30 @@ public SiteService(SiteSettingRepository repository, public SiteResult get() { var setting = repository.findBySiteKey("default").orElseThrow(); - return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName); + return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, setting.isCommentsEnabled()); + } + + public AdminSiteResult getAdmin() { + var setting = repository.findBySiteKey("default").orElseThrow(); + return new AdminSiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, + setting.isCommentsEnabled(), setting.getVersion()); + } + + @org.springframework.transaction.annotation.Transactional + public AdminSiteResult updateCommentsEnabled(long expectedVersion, boolean commentsEnabled) { + var setting = repository.findBySiteKey("default").orElseThrow(); + if (setting.getVersion() != expectedVersion) { + throw new io.haoblog.shared.web.ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", + "Reload the latest site settings before saving", setting.getVersion()); + } + setting.setCommentsEnabled(commentsEnabled); + var saved = repository.saveAndFlush(setting); + return new AdminSiteResult(saved.getTitle(), saved.getDescription(), publicBaseUrl, authorName, + saved.isCommentsEnabled(), saved.getVersion()); + } + + public long currentVersion() { + return repository.findBySiteKey("default").orElseThrow().getVersion(); } static String normalizePublicBaseUrl(String raw) { @@ -47,5 +70,12 @@ static String normalizePublicBaseUrl(String raw) { return raw.trim().replaceFirst("/+$", ""); } - public record SiteResult(String title, String description, String siteUrl, String authorName) {} + public record SiteResult(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + public SiteResult(String title, String description, String siteUrl, String authorName) { + this(title, description, siteUrl, authorName, true); + } + } + + public record AdminSiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, long version) {} } diff --git a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java index b56beb0..34366e1 100644 --- a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java +++ b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java @@ -11,10 +11,15 @@ public class SiteSetting { @Column(name = "site_key", nullable = false, unique = true, length = 64) private String siteKey; @Column(nullable = false, length = 160) private String title; @Column(nullable = false, length = 600) private String description; + @Column(name = "comments_enabled", nullable = false) private boolean commentsEnabled = true; + @Version @Column(nullable = false) private long version; protected SiteSetting() {} public SiteSetting(String siteKey, String title, String description) { this.siteKey = siteKey; this.title = title; this.description = description; } public String getTitle() { return title; } public String getDescription() { return description; } + public boolean isCommentsEnabled() { return commentsEnabled; } + public long getVersion() { return version; } + public void setCommentsEnabled(boolean commentsEnabled) { this.commentsEnabled = commentsEnabled; } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java new file mode 100644 index 0000000..9aff3df --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java @@ -0,0 +1,47 @@ +package io.haoblog.site.web; + +import io.haoblog.shared.web.ProblemException; +import io.haoblog.site.application.SiteService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/site") +public class AdminSiteController { + private final SiteService service; + + public AdminSiteController(SiteService service) { + this.service = service; + } + + @GetMapping + public Response get() { + return Response.from(service.getAdmin()); + } + + @PutMapping + public Response update(@RequestBody @Valid UpdateRequest request) { + try { + return Response.from(service.updateCommentsEnabled(request.version(), request.commentsEnabled())); + } catch (OptimisticLockingFailureException exception) { + throw new ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", + "Reload the latest site settings before saving", service.currentVersion()); + } + } + + public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled) {} + + public record Response(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, long version) { + static Response from(SiteService.AdminSiteResult result) { + return new Response(result.title(), result.description(), result.siteUrl(), result.authorName(), + result.commentsEnabled(), result.version()); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java index 946df8a..b2f6020 100644 --- a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java +++ b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java @@ -20,7 +20,7 @@ public class PublicSiteController { @GetMapping public ResponseEntity site(@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { var result = service.get(); - var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName()); + var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName(), result.commentsEnabled()); var headers = new HttpHeaders(); headers.setETag(representationHash(response)); headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); @@ -39,5 +39,9 @@ private static String representationHash(Object value) { } } - public record SiteResponse(String title, String description, String siteUrl, String authorName) {} + public record SiteResponse(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + public SiteResponse(String title, String description, String siteUrl, String authorName) { + this(title, description, siteUrl, authorName, true); + } + } } diff --git a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolDevelopmentSeeder.java b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolDevelopmentSeeder.java new file mode 100644 index 0000000..d11537d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolDevelopmentSeeder.java @@ -0,0 +1,74 @@ +package io.haoblog.toolbox.application; + +import io.haoblog.toolbox.domain.Tool; +import io.haoblog.toolbox.domain.ToolCategory; +import io.haoblog.toolbox.domain.ToolComponentKey; +import io.haoblog.toolbox.domain.ToolStatus; +import io.haoblog.toolbox.domain.ToolType; +import io.haoblog.toolbox.persistence.ToolCategoryRepository; +import io.haoblog.toolbox.persistence.ToolRepository; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +@Configuration +@Profile({"local", "dev"}) +@ConditionalOnProperty(name = "haoblog.seed.enabled", havingValue = "true", matchIfMissing = false) +public class ToolDevelopmentSeeder { + @Bean + ApplicationRunner toolSeedRunner(ToolSeedService seedService) { + return args -> seedService.seed(); + } + + @Configuration + static class SeedServices { + @Bean ToolSeedService toolSeedService(ToolCategoryRepository categories, ToolRepository tools, Clock clock) { + return new ToolSeedService(categories, tools, clock); + } + } + + static class ToolSeedService { + private final ToolCategoryRepository categories; + private final ToolRepository tools; + private final Clock clock; + + ToolSeedService(ToolCategoryRepository categories, ToolRepository tools, Clock clock) { + this.categories = categories; + this.tools = tools; + this.clock = clock; + } + + @Transactional + public void seed() { + Instant now = Instant.now(clock); + ToolCategory category = categories.findAllByOrderBySortOrderAscNameAsc().stream() + .filter(value -> value.getSlug().equals("embedded-tools")) + .findFirst() + .orElseGet(() -> categories.saveAndFlush(new ToolCategory( + "浏览器内嵌工具", "embedded-tools", "输入留在浏览器,结果由原生 API 计算。", 10, now))); + List seedTools = List.of( + new SeedTool("json-format", "JSON 格式化", "校验、两空格格式化或压缩 JSON。", ToolComponentKey.JSON_FORMAT, List.of("json", "format")), + new SeedTool("base64", "Base64 编解码", "在浏览器中进行 UTF-8 文本 Base64 编解码。", ToolComponentKey.BASE64, List.of("base64", "utf8")), + new SeedTool("url-codec", "URL 编解码", "使用 encodeURIComponent 和 decodeURIComponent 处理 URI 组件。", ToolComponentKey.URL_CODEC, List.of("url", "encode")), + new SeedTool("timestamp", "时间戳转换", "自动识别秒/毫秒并显示本地时间与 UTC ISO。", ToolComponentKey.TIMESTAMP, List.of("time", "date")), + new SeedTool("regex-test", "正则测试", "在受控 Web Worker 中运行 JavaScript RegExp。", ToolComponentKey.REGEX_TEST, List.of("regex", "worker"))); + for (int index = 0; index < seedTools.size(); index++) { + SeedTool item = seedTools.get(index); + if (!tools.existsBySlug(item.slug())) { + tools.save(new Tool(category.getId(), ToolType.EMBEDDED, ToolStatus.ACTIVE, item.title(), item.slug(), + item.description(), null, null, item.componentKey(), item.tags(), index + 1, now)); + } + } + tools.flush(); + } + + private record SeedTool(String slug, String title, String description, ToolComponentKey componentKey, List tags) {} + } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java new file mode 100644 index 0000000..c5567a1 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java @@ -0,0 +1,238 @@ +package io.haoblog.toolbox.application; + +import io.haoblog.shared.web.ProblemException; +import io.haoblog.toolbox.domain.Tool; +import io.haoblog.toolbox.domain.ToolCategory; +import io.haoblog.toolbox.domain.ToolComponentKey; +import io.haoblog.toolbox.domain.ToolStatus; +import io.haoblog.toolbox.domain.ToolType; +import io.haoblog.toolbox.persistence.ToolCategoryRepository; +import io.haoblog.toolbox.persistence.ToolRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class ToolManagementService { + private final ToolCategoryRepository categories; + private final ToolRepository tools; + private final Clock clock; + + public ToolManagementService(ToolCategoryRepository categories, ToolRepository tools, Clock clock) { + this.categories = categories; + this.tools = tools; + this.clock = clock; + } + + @Transactional(readOnly = true) + public List listCategories() { return categories.findAllByOrderBySortOrderAscNameAsc(); } + + @Transactional(readOnly = true) + public PublicTools listPublicTools(String category, ToolType type, String keyword) { + String normalizedCategory = category == null || category.isBlank() ? null : category.trim(); + if (normalizedCategory != null && normalizedCategory.length() > 160) { + throw new IllegalArgumentException("category is too long"); + } + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + if (normalizedKeyword != null && normalizedKeyword.length() > 240) { + throw new IllegalArgumentException("keyword is too long"); + } + return queryPublicTools(normalizedCategory, type, normalizedKeyword); + } + + private PublicTools queryPublicTools(String normalizedCategory, ToolType type, String normalizedKeyword) { + + List activeTools = tools.findAllByStatus(ToolStatus.ACTIVE, + Sort.by(Sort.Direction.ASC, "sortOrder", "title", "id")); + Map categoryById = categories.findAllById( + activeTools.stream().map(Tool::getCategoryId).distinct().toList()).stream() + .collect(Collectors.toMap(ToolCategory::getId, categoryValue -> categoryValue)); + Predicate categoryFilter = categoryPredicate(normalizedCategory, categoryById); + String keywordValue = normalizedKeyword == null ? null : normalizedKeyword.toLowerCase(Locale.ROOT); + List items = activeTools.stream() + .filter(tool -> type == null || tool.getType() == type) + .filter(categoryFilter) + .filter(tool -> keywordValue == null || containsKeyword(tool, keywordValue)) + .map(tool -> PublicTool.from(tool, categoryById.get(tool.getCategoryId()))) + .filter(tool -> tool.category() != null) + .toList(); + List validCategories = items.stream().map(PublicTool::category).distinct() + .sorted(java.util.Comparator.comparingInt(ToolCategory::getSortOrder) + .thenComparing(ToolCategory::getName) + .thenComparing(ToolCategory::getId)) + .toList(); + return new PublicTools(items, validCategories); + } + + private static Predicate categoryPredicate(String category, Map categoryById) { + if (category == null) return ignored -> true; + return tool -> { + ToolCategory value = categoryById.get(tool.getCategoryId()); + return value != null && (value.getSlug().equalsIgnoreCase(category) + || value.getId().toString().equalsIgnoreCase(category)); + }; + } + + private static boolean containsKeyword(Tool tool, String keyword) { + return Stream.of(tool.getTitle(), tool.getSlug(), tool.getDescription()) + .filter(java.util.Objects::nonNull) + .map(value -> value.toLowerCase(Locale.ROOT)) + .anyMatch(value -> value.contains(keyword)) + || tool.getTags().stream().anyMatch(tag -> tag.toLowerCase(Locale.ROOT).contains(keyword)); + } + + @Transactional + public ToolCategory createCategory(String name, String slug, String description, int sortOrder) { + validateCategory(name, slug, description); + String normalizedSlug = normalizeSlug(slug); + if (categories.existsByNameIgnoreCase(name.trim()) || categories.existsBySlug(normalizedSlug)) { + throw conflict("TOOL_CATEGORY_CONFLICT", "Tool category conflict", "The tool category name or slug is already in use"); + } + return categories.saveAndFlush(new ToolCategory(name, normalizedSlug, description, sortOrder, Instant.now(clock))); + } + + @Transactional(readOnly = true) + public ToolCategory getCategory(UUID id) { return categories.findById(id).orElseThrow(() -> notFound("TOOL_CATEGORY_NOT_FOUND")); } + + @Transactional + public ToolCategory updateCategory(UUID id, long version, String name, String slug, String description, int sortOrder) { + ToolCategory category = getCategory(id); + requireVersion(category.getVersion(), version, "TOOL_CATEGORY_VERSION_CONFLICT", "Reload the latest tool category before saving"); + validateCategory(name, slug, description); + String normalizedSlug = normalizeSlug(slug); + if (categories.existsByNameIgnoreCaseAndIdNot(name.trim(), id) || categories.existsBySlugAndIdNot(normalizedSlug, id)) { + throw conflict("TOOL_CATEGORY_CONFLICT", "Tool category conflict", "The tool category name or slug is already in use"); + } + category.update(name, normalizedSlug, description, sortOrder, Instant.now(clock)); + return categories.saveAndFlush(category); + } + + @Transactional + public void deleteCategory(UUID id, long version) { + ToolCategory category = getCategory(id); + requireVersion(category.getVersion(), version, "TOOL_CATEGORY_VERSION_CONFLICT", "Reload the latest tool category before deleting"); + if (tools.existsByCategoryId(id)) { + throw conflict("TOOL_CATEGORY_IN_USE", "Tool category in use", "The category is referenced by a tool"); + } + categories.delete(category); + categories.flush(); + } + + @Transactional(readOnly = true) + public Page listTools(int page, int size, UUID categoryId, ToolType type, ToolStatus status, + String keyword, String sort, Sort.Direction direction) { + if (page < 0 || size < 1 || size > 50) throw new IllegalArgumentException("page/size out of range"); + if (keyword != null && keyword.length() > 240) throw new IllegalArgumentException("keyword is too long"); + String value = keyword == null || keyword.isBlank() ? null : escapeLike(keyword.trim()); + Specification specification = (root, query, cb) -> cb.conjunction(); + if (categoryId != null) specification = specification.and((root, query, cb) -> cb.equal(root.get("categoryId"), categoryId)); + if (type != null) specification = specification.and((root, query, cb) -> cb.equal(root.get("type"), type)); + if (status != null) specification = specification.and((root, query, cb) -> cb.equal(root.get("status"), status)); + if (value != null) { + String pattern = "%" + value.toLowerCase() + "%"; + specification = specification.and((root, query, cb) -> cb.or( + cb.like(cb.lower(root.get("title")), pattern, '!'), + cb.like(cb.lower(root.get("slug")), pattern, '!'), + cb.like(cb.lower(root.get("description")), pattern, '!'))); + } + Sort safeSort = Sort.by(direction, allowedSort(sort)).and(Sort.by(direction, "id")); + return tools.findAll(specification, PageRequest.of(page, size, safeSort)); + } + + @Transactional(readOnly = true) + public Tool getTool(UUID id) { return tools.findById(id).orElseThrow(() -> notFound("TOOL_NOT_FOUND")); } + + @Transactional + public Tool createTool(UUID categoryId, ToolType type, ToolStatus status, String title, String slug, + String description, String url, String imageUrl, ToolComponentKey componentKey, + List tags, int sortOrder) { + requireCategory(categoryId); + validateTool(title, slug, description); + String normalizedSlug = normalizeSlug(slug); + if (tools.existsBySlug(normalizedSlug)) throw conflict("TOOL_CONFLICT", "Tool conflict", "The tool slug is already in use"); + return tools.saveAndFlush(new Tool(categoryId, type, status, title, normalizedSlug, description, url, imageUrl, + componentKey, tags, sortOrder, Instant.now(clock))); + } + + @Transactional + public Tool updateTool(UUID id, long version, UUID categoryId, ToolType type, ToolStatus status, String title, String slug, + String description, String url, String imageUrl, ToolComponentKey componentKey, + List tags, int sortOrder) { + Tool tool = getTool(id); + requireVersion(tool.getVersion(), version, "TOOL_VERSION_CONFLICT", "Reload the latest tool before saving"); + requireCategory(categoryId); + validateTool(title, slug, description); + String normalizedSlug = normalizeSlug(slug); + if (tools.existsBySlugAndIdNot(normalizedSlug, id)) throw conflict("TOOL_CONFLICT", "Tool conflict", "The tool slug is already in use"); + tool.update(categoryId, type, status, title, normalizedSlug, description, url, imageUrl, componentKey, + tags, sortOrder, Instant.now(clock)); + return tools.saveAndFlush(tool); + } + + @Transactional + public void deleteTool(UUID id, long version) { + Tool tool = getTool(id); + requireVersion(tool.getVersion(), version, "TOOL_VERSION_CONFLICT", "Reload the latest tool before deleting"); + tools.delete(tool); + tools.flush(); + } + + public long currentCategoryVersion(UUID id) { return getCategory(id).getVersion(); } + public long currentToolVersion(UUID id) { return getTool(id).getVersion(); } + + private void requireCategory(UUID id) { + if (id == null || !categories.existsById(id)) throw notFound("TOOL_CATEGORY_NOT_FOUND"); + } + + private static void validateCategory(String name, String slug, String description) { + if (name == null || name.isBlank() || name.trim().length() > 120) throw new IllegalArgumentException("name is invalid"); + if (description != null && description.length() > 600) throw new IllegalArgumentException("description is too long"); + normalizeSlug(slug); + } + + private static void validateTool(String title, String slug, String description) { + if (title == null || title.isBlank() || title.trim().length() > 160) throw new IllegalArgumentException("title is invalid"); + if (description != null && description.length() > 600) throw new IllegalArgumentException("description is too long"); + normalizeSlug(slug); + } + + private static void requireVersion(long current, long requested, String code, String detail) { + if (current != requested) throw new ProblemException(code, "Version conflict", detail, current); + } + + private static String escapeLike(String value) { return value.replace("!", "!!").replace("%", "!%").replace("_", "!_"); } + private static String normalizeSlug(String value) { return io.haoblog.toolbox.domain.ToolSlug.normalizeRequired(value); } + private static String allowedSort(String sort) { + return switch (sort == null || sort.isBlank() ? "sortOrder" : sort) { + case "sortOrder", "createdAt", "updatedAt", "title" -> sort == null || sort.isBlank() ? "sortOrder" : sort; + default -> throw new IllegalArgumentException("sort must be sortOrder, createdAt, updatedAt or title"); + }; + } + private static ProblemException notFound(String code) { return new ProblemException(code, "Resource not found", "The requested toolbox resource was not found"); } + private static ProblemException conflict(String code, String title, String detail) { return new ProblemException(code, title, detail); } + + public record PublicTools(List items, List categories) {} + + public record PublicTool(UUID id, ToolCategory category, ToolType type, String title, String slug, + String description, String url, String imageUrl, String componentKey, List tags, + int sortOrder) { + static PublicTool from(Tool tool, ToolCategory category) { + return new PublicTool(tool.getId(), category, tool.getType(), tool.getTitle(), tool.getSlug(), tool.getDescription(), + tool.getUrl(), tool.getImageUrl(), tool.getComponentKey() == null ? null : tool.getComponentKey().getValue(), + tool.getTags(), tool.getSortOrder()); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/Tool.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/Tool.java new file mode 100644 index 0000000..6dd59da --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/Tool.java @@ -0,0 +1,150 @@ +package io.haoblog.toolbox.domain; + +import io.haoblog.shared.id.UuidV7; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Convert; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.net.URI; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "tool") +public class Tool { + @Id + private UUID id = UuidV7.generate(); + @Column(name = "category_id", nullable = false) + private UUID categoryId; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ToolType type; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ToolStatus status; + @Column(nullable = false, length = 160) + private String title; + @Column(nullable = false, unique = true, length = 160) + private String slug; + @Column(length = 600) + private String description; + @Column(length = 2048) + private String url; + @Column(name = "image_url", length = 2048) + private String imageUrl; + @Convert(converter = ToolComponentKeyConverter.class) + @Column(name = "component_key", length = 32) + private ToolComponentKey componentKey; + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private List tags = new ArrayList<>(); + @Column(name = "sort_order", nullable = false) + private int sortOrder; + @Version + @Column(nullable = false) + private long version; + @Column(name = "created_at", nullable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected Tool() {} + + public Tool(UUID categoryId, ToolType type, ToolStatus status, String title, String slug, + String description, String url, String imageUrl, ToolComponentKey componentKey, + List tags, int sortOrder, Instant now) { + this.categoryId = require(categoryId, "categoryId"); + this.type = require(type, "type"); + this.status = require(status, "status"); + this.title = title.trim(); + this.slug = ToolSlug.normalizeRequired(slug); + this.description = description; + this.url = httpsOrNull(url); + this.imageUrl = httpsOrNull(imageUrl); + this.componentKey = componentKey; + this.tags = copyTags(tags); + this.sortOrder = sortOrder; + validateFields(this.type, this.url, this.componentKey); + this.createdAt = now; + this.updatedAt = now; + } + + public void update(UUID categoryId, ToolType type, ToolStatus status, String title, String slug, + String description, String url, String imageUrl, ToolComponentKey componentKey, + List tags, int sortOrder, Instant now) { + this.categoryId = require(categoryId, "categoryId"); + this.type = require(type, "type"); + this.status = require(status, "status"); + this.title = title.trim(); + this.slug = ToolSlug.normalizeRequired(slug); + this.description = description; + this.url = httpsOrNull(url); + this.imageUrl = httpsOrNull(imageUrl); + this.componentKey = componentKey; + this.tags = copyTags(tags); + this.sortOrder = sortOrder; + validateFields(this.type, this.url, this.componentKey); + this.updatedAt = now; + } + + private static void validateFields(ToolType type, String url, ToolComponentKey componentKey) { + if (type == ToolType.EMBEDDED && (url != null || componentKey == null)) { + throw new IllegalArgumentException("Embedded tools require a whitelisted componentKey and no url"); + } + if (type != ToolType.EMBEDDED && (url == null || componentKey != null)) { + throw new IllegalArgumentException("Link and showcase tools require an https url and no componentKey"); + } + } + + private static String httpsOrNull(String raw) { + if (raw == null || raw.isBlank()) return null; + String value = raw.trim(); + try { + URI uri = URI.create(value); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null) { + throw new IllegalArgumentException("Only absolute https URLs are allowed"); + } + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Only absolute https URLs are allowed", exception); + } + return value; + } + + private static List copyTags(List values) { + if (values == null) return new ArrayList<>(); + if (values.size() > 32 || values.stream().anyMatch(value -> value == null || value.isBlank() || value.trim().length() > 64)) { + throw new IllegalArgumentException("tags are invalid"); + } + return values.stream().map(String::trim).distinct().toList(); + } + + private static T require(T value, String name) { + if (value == null) throw new IllegalArgumentException(name + " is required"); + return value; + } + + public UUID getId() { return id; } + public UUID getCategoryId() { return categoryId; } + public ToolType getType() { return type; } + public ToolStatus getStatus() { return status; } + public String getTitle() { return title; } + public String getSlug() { return slug; } + public String getDescription() { return description; } + public String getUrl() { return url; } + public String getImageUrl() { return imageUrl; } + public ToolComponentKey getComponentKey() { return componentKey; } + public List getTags() { return List.copyOf(tags); } + public int getSortOrder() { return sortOrder; } + public long getVersion() { return version; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolCategory.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolCategory.java new file mode 100644 index 0000000..c727d47 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolCategory.java @@ -0,0 +1,61 @@ +package io.haoblog.toolbox.domain; + +import io.haoblog.shared.id.UuidV7; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "tool_category") +public class ToolCategory { + @Id + private UUID id = UuidV7.generate(); + @Column(nullable = false, unique = true, length = 120) + private String name; + @Column(nullable = false, unique = true, length = 160) + private String slug; + @Column(length = 600) + private String description; + @Column(name = "sort_order", nullable = false) + private int sortOrder; + @Version + @Column(nullable = false) + private long version; + @Column(name = "created_at", nullable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected ToolCategory() {} + + public ToolCategory(String name, String slug, String description, int sortOrder, Instant now) { + this.name = name.trim(); + this.slug = ToolSlug.normalizeRequired(slug); + this.description = description; + this.sortOrder = sortOrder; + this.createdAt = now; + this.updatedAt = now; + } + + public void update(String name, String slug, String description, int sortOrder, Instant now) { + this.name = name.trim(); + this.slug = ToolSlug.normalizeRequired(slug); + this.description = description; + this.sortOrder = sortOrder; + this.updatedAt = now; + } + + public UUID getId() { return id; } + public String getName() { return name; } + public String getSlug() { return slug; } + public String getDescription() { return description; } + public int getSortOrder() { return sortOrder; } + public long getVersion() { return version; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKey.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKey.java new file mode 100644 index 0000000..c98a8f9 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKey.java @@ -0,0 +1,17 @@ +package io.haoblog.toolbox.domain; + +public enum ToolComponentKey { + JSON_FORMAT("json-format"), BASE64("base64"), URL_CODEC("url-codec"), TIMESTAMP("timestamp"), REGEX_TEST("regex-test"); + + private final String value; + + ToolComponentKey(String value) { this.value = value; } + + public String getValue() { return value; } + + public static ToolComponentKey fromValue(String value) { + if (value == null || value.isBlank()) return null; + for (ToolComponentKey key : values()) if (key.value.equals(value)) return key; + throw new IllegalArgumentException("componentKey is not allowed"); + } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKeyConverter.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKeyConverter.java new file mode 100644 index 0000000..adf7eb3 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolComponentKeyConverter.java @@ -0,0 +1,17 @@ +package io.haoblog.toolbox.domain; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class ToolComponentKeyConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(ToolComponentKey attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public ToolComponentKey convertToEntityAttribute(String value) { + return ToolComponentKey.fromValue(value); + } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolSlug.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolSlug.java new file mode 100644 index 0000000..a63f3e1 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolSlug.java @@ -0,0 +1,20 @@ +package io.haoblog.toolbox.domain; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class ToolSlug { + private static final Pattern VALID = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + + private ToolSlug() {} + + public static String normalizeRequired(String raw) { + if (raw == null || raw.isBlank()) throw new IllegalArgumentException("Slug is required"); + String value = raw.trim().toLowerCase(Locale.ROOT).replaceAll("[\\s_]+", "-") + .replaceAll("-+", "-").replaceAll("^-|-$", ""); + if (value.isEmpty() || value.length() > 160 || !VALID.matcher(value).matches()) { + throw new IllegalArgumentException("Slug must be lowercase ASCII kebab-case"); + } + return value; + } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolStatus.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolStatus.java new file mode 100644 index 0000000..0b0a21d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolStatus.java @@ -0,0 +1,5 @@ +package io.haoblog.toolbox.domain; + +public enum ToolStatus { + ACTIVE, INACTIVE +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolType.java b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolType.java new file mode 100644 index 0000000..58540ba --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/domain/ToolType.java @@ -0,0 +1,5 @@ +package io.haoblog.toolbox.domain; + +public enum ToolType { + LINK, EMBEDDED, SHOWCASE +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/package-info.java b/apps/api/src/main/java/io/haoblog/toolbox/package-info.java index 459224e..d10be0d 100644 --- a/apps/api/src/main/java/io/haoblog/toolbox/package-info.java +++ b/apps/api/src/main/java/io/haoblog/toolbox/package-info.java @@ -1,2 +1,2 @@ -/** Toolbox boundary reserved for browser tools and links. */ +/** 管理浏览器工具、链接及白名单内嵌组件配置。 */ package io.haoblog.toolbox; diff --git a/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolCategoryRepository.java b/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolCategoryRepository.java new file mode 100644 index 0000000..0290e35 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolCategoryRepository.java @@ -0,0 +1,15 @@ +package io.haoblog.toolbox.persistence; + +import io.haoblog.toolbox.domain.ToolCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface ToolCategoryRepository extends JpaRepository { + List findAllByOrderBySortOrderAscNameAsc(); + boolean existsByNameIgnoreCase(String name); + boolean existsByNameIgnoreCaseAndIdNot(String name, UUID id); + boolean existsBySlug(String slug); + boolean existsBySlugAndIdNot(String slug, UUID id); +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolRepository.java b/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolRepository.java new file mode 100644 index 0000000..404647d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/persistence/ToolRepository.java @@ -0,0 +1,17 @@ +package io.haoblog.toolbox.persistence; + +import io.haoblog.toolbox.domain.Tool; +import io.haoblog.toolbox.domain.ToolStatus; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; +import java.util.UUID; + +public interface ToolRepository extends JpaRepository, JpaSpecificationExecutor { + boolean existsByCategoryId(UUID categoryId); + boolean existsBySlug(String slug); + boolean existsBySlugAndIdNot(String slug, UUID id); + List findAllByStatus(ToolStatus status, Sort sort); +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolCategoryController.java b/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolCategoryController.java new file mode 100644 index 0000000..db1c955 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolCategoryController.java @@ -0,0 +1,77 @@ +package io.haoblog.toolbox.web; + +import io.haoblog.shared.web.ProblemException; +import io.haoblog.toolbox.application.ToolManagementService; +import io.haoblog.toolbox.domain.ToolCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/tool-categories") +public class AdminToolCategoryController { + private final ToolManagementService service; + + public AdminToolCategoryController(ToolManagementService service) { this.service = service; } + + @GetMapping + public List list() { return service.listCategories().stream().map(Response::from).toList(); } + + @PostMapping + public ResponseEntity create(@RequestBody @Valid CreateRequest request) { + Response result = Response.from(service.createCategory(request.name(), request.slug(), request.description(), sortOrderOrDefault(request.sortOrder()))); + return ResponseEntity.created(URI.create("/api/v1/admin/tool-categories/" + result.id())).body(result); + } + + @GetMapping("/{id}") + public Response get(@PathVariable UUID id) { return Response.from(service.getCategory(id)); } + + @PutMapping("/{id}") + public Response update(@PathVariable UUID id, @RequestBody @Valid UpdateRequest request) { + try { + return Response.from(service.updateCategory(id, request.version(), request.name(), request.slug(), request.description(), sortOrderOrDefault(request.sortOrder()))); + } catch (OptimisticLockingFailureException exception) { + throw new ProblemException("TOOL_CATEGORY_VERSION_CONFLICT", "Version conflict", "Reload the latest tool category before saving", service.currentCategoryVersion(id)); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id, @RequestParam long version) { + try { + service.deleteCategory(id, version); + return ResponseEntity.noContent().build(); + } catch (OptimisticLockingFailureException exception) { + throw new ProblemException("TOOL_CATEGORY_VERSION_CONFLICT", "Version conflict", "Reload the latest tool category before deleting", service.currentCategoryVersion(id)); + } + } + + public record CreateRequest(@NotBlank @Size(max = 120) String name, @NotBlank @Size(max = 160) String slug, + @Size(max = 600) String description, Integer sortOrder) {} + public record UpdateRequest(@NotNull Long version, @NotBlank @Size(max = 120) String name, + @NotBlank @Size(max = 160) String slug, @Size(max = 600) String description, + Integer sortOrder) {} + public record Response(UUID id, String name, String slug, String description, int sortOrder, long version, + Instant createdAt, Instant updatedAt) { + static Response from(ToolCategory value) { return new Response(value.getId(), value.getName(), value.getSlug(), value.getDescription(), + value.getSortOrder(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt()); } + } + + private static int sortOrderOrDefault(Integer sortOrder) { return sortOrder == null ? 0 : sortOrder; } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolController.java b/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolController.java new file mode 100644 index 0000000..89dc2fa --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/web/AdminToolController.java @@ -0,0 +1,104 @@ +package io.haoblog.toolbox.web; + +import io.haoblog.shared.web.ProblemException; +import io.haoblog.toolbox.application.ToolManagementService; +import io.haoblog.toolbox.domain.Tool; +import io.haoblog.toolbox.domain.ToolCategory; +import io.haoblog.toolbox.domain.ToolComponentKey; +import io.haoblog.toolbox.domain.ToolStatus; +import io.haoblog.toolbox.domain.ToolType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/tools") +public class AdminToolController { + private final ToolManagementService service; + + public AdminToolController(ToolManagementService service) { this.service = service; } + + @GetMapping + public ListResponse list(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) UUID categoryId, @RequestParam(required = false) ToolType type, + @RequestParam(required = false) ToolStatus status, @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "sortOrder") String sort, @RequestParam(defaultValue = "asc") String direction) { + PageResult result = new PageResult(service.listTools(page, size, categoryId, type, status, keyword, sort, parseDirection(direction))); + return new ListResponse(result.items(), result.page(), result.size(), result.total()); + } + + @GetMapping("/{id}") + public Response get(@PathVariable UUID id) { return Response.from(service.getTool(id)); } + + @PostMapping + public org.springframework.http.ResponseEntity create(@RequestBody @Valid Request request) { + Response result = Response.from(service.createTool(request.categoryId(), request.type(), request.status(), request.title(), request.slug(), + request.description(), request.url(), request.imageUrl(), ToolComponentKey.fromValue(request.componentKey()), request.tags(), sortOrderOrDefault(request.sortOrder()))); + return org.springframework.http.ResponseEntity.created(java.net.URI.create("/api/v1/admin/tools/" + result.id())).body(result); + } + + @PutMapping("/{id}") + public Response update(@PathVariable UUID id, @RequestBody @Valid UpdateRequest request) { + try { + return Response.from(service.updateTool(id, request.version(), request.categoryId(), request.type(), request.status(), request.title(), request.slug(), + request.description(), request.url(), request.imageUrl(), ToolComponentKey.fromValue(request.componentKey()), request.tags(), sortOrderOrDefault(request.sortOrder()))); + } catch (OptimisticLockingFailureException exception) { + throw new ProblemException("TOOL_VERSION_CONFLICT", "Version conflict", "Reload the latest tool before saving", service.currentToolVersion(id)); + } + } + + @DeleteMapping("/{id}") + public org.springframework.http.ResponseEntity delete(@PathVariable UUID id, @RequestParam long version) { + try { + service.deleteTool(id, version); + return org.springframework.http.ResponseEntity.noContent().build(); + } catch (OptimisticLockingFailureException exception) { + throw new ProblemException("TOOL_VERSION_CONFLICT", "Version conflict", "Reload the latest tool before deleting", service.currentToolVersion(id)); + } + } + + private static Sort.Direction parseDirection(String direction) { + if ("asc".equalsIgnoreCase(direction)) return Sort.Direction.ASC; + if ("desc".equalsIgnoreCase(direction)) return Sort.Direction.DESC; + throw new IllegalArgumentException("direction must be asc or desc"); + } + + public record Request(@NotNull UUID categoryId, @NotNull ToolType type, @NotNull ToolStatus status, + @NotBlank @Size(max = 160) String title, @NotBlank @Size(max = 160) String slug, + @Size(max = 600) String description, @Size(max = 2048) String url, + @Size(max = 2048) String imageUrl, @Size(max = 32) String componentKey, + @Size(max = 32) List<@NotBlank @Size(max = 64) String> tags, Integer sortOrder) {} + public record UpdateRequest(@NotNull Long version, @NotNull UUID categoryId, @NotNull ToolType type, @NotNull ToolStatus status, + @NotBlank @Size(max = 160) String title, @NotBlank @Size(max = 160) String slug, + @Size(max = 600) String description, @Size(max = 2048) String url, + @Size(max = 2048) String imageUrl, @Size(max = 32) String componentKey, + @Size(max = 32) List<@NotBlank @Size(max = 64) String> tags, Integer sortOrder) {} + public record ListResponse(List items, int page, int size, long total) {} + private record PageResult(List items, int page, int size, long total) { + PageResult(org.springframework.data.domain.Page page) { this(page.getContent().stream().map(Response::from).toList(), page.getNumber(), page.getSize(), page.getTotalElements()); } + } + public record Response(UUID id, UUID categoryId, ToolType type, ToolStatus status, String title, String slug, + String description, String url, String imageUrl, String componentKey, List tags, + int sortOrder, long version, Instant createdAt, Instant updatedAt) { + static Response from(Tool value) { return new Response(value.getId(), value.getCategoryId(), value.getType(), value.getStatus(), value.getTitle(), value.getSlug(), + value.getDescription(), value.getUrl(), value.getImageUrl(), value.getComponentKey() == null ? null : value.getComponentKey().getValue(), value.getTags(), value.getSortOrder(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt()); } + } + + private static int sortOrderOrDefault(Integer sortOrder) { return sortOrder == null ? 0 : sortOrder; } +} diff --git a/apps/api/src/main/java/io/haoblog/toolbox/web/PublicToolController.java b/apps/api/src/main/java/io/haoblog/toolbox/web/PublicToolController.java new file mode 100644 index 0000000..f01a746 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/toolbox/web/PublicToolController.java @@ -0,0 +1,77 @@ +package io.haoblog.toolbox.web; + +import io.haoblog.toolbox.application.ToolManagementService; +import io.haoblog.toolbox.domain.ToolCategory; +import io.haoblog.toolbox.domain.ToolType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/public/tools") +public class PublicToolController { + private final ToolManagementService service; + + public PublicToolController(ToolManagementService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity list( + @RequestParam(required = false) String category, + @RequestParam(required = false) ToolType type, + @RequestParam(required = false) String keyword, + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + ToolManagementService.PublicTools result = service.listPublicTools(category, type, keyword); + Response response = Response.from(result); + String etag = representationHash(response); + var headers = new HttpHeaders(); + headers.setETag(etag); + headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); + if (etag.equals(ifNoneMatch)) { + return ResponseEntity.status(304).headers(headers).build(); + } + return ResponseEntity.ok().headers(headers).body(response); + } + + private static String representationHash(Object value) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + return '"' + HexFormat.of().formatHex(digest.digest(value.toString().getBytes(StandardCharsets.UTF_8))) + '"'; + } catch (Exception exception) { + throw new IllegalStateException("Unable to create public tools ETag", exception); + } + } + + public record Response(List items, List categories) { + static Response from(ToolManagementService.PublicTools value) { + return new Response(value.items().stream().map(ToolResponse::from).toList(), + value.categories().stream().map(CategoryResponse::from).toList()); + } + } + + public record CategoryResponse(UUID id, String name, String slug, String description, int sortOrder) { + static CategoryResponse from(ToolCategory value) { + return new CategoryResponse(value.getId(), value.getName(), value.getSlug(), value.getDescription(), value.getSortOrder()); + } + } + + public record ToolResponse(UUID id, CategoryResponse category, ToolType type, String title, String slug, + String description, String url, String imageUrl, String componentKey, List tags, + int sortOrder) { + static ToolResponse from(ToolManagementService.PublicTool value) { + return new ToolResponse(value.id(), CategoryResponse.from(value.category()), value.type(), value.title(), value.slug(), + value.description(), value.url(), value.imageUrl(), value.componentKey(), value.tags(), value.sortOrder()); + } + } +} diff --git a/apps/api/src/main/resources/application.yml b/apps/api/src/main/resources/application.yml index b60b158..0cfb731 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -8,6 +8,17 @@ spring: hikari: maximum-pool-size: ${DB_POOL_MAXIMUM_SIZE:6} minimum-idle: ${DB_POOL_MINIMUM_IDLE:1} + mail: + host: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_HOST:} + port: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_PORT:25} + username: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_USERNAME:} + password: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_PASSWORD:} + properties: + mail.smtp.auth: true + mail.smtp.starttls.enable: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_TLS:false} + mail.smtp.connectiontimeout: 5000 + mail.smtp.timeout: 3000 + mail.smtp.writetimeout: 5000 jpa: open-in-view: false hibernate: @@ -39,6 +50,9 @@ management: endpoint: health: show-details: never + health: + mail: + enabled: ${HAOBLOG_COMMENT_NOTIFICATION_ENABLED:false} server: port: ${API_PORT:8080} tomcat: @@ -74,3 +88,9 @@ haoblog: public-base-url: ${HAOBLOG_OSS_PUBLIC_BASE_URL:} max-size-bytes: ${HAOBLOG_OSS_MAX_SIZE_BYTES:5242880} max-dimension: ${HAOBLOG_OSS_MAX_DIMENSION:2560} + comment: + security-key: ${HAOBLOG_COMMENT_SECURITY_KEY:} + notification: + enabled: ${HAOBLOG_COMMENT_NOTIFICATION_ENABLED:false} + recipient: ${HAOBLOG_COMMENT_NOTIFICATION_RECIPIENT:} + from: ${HAOBLOG_COMMENT_NOTIFICATION_FROM:} diff --git a/apps/api/src/main/resources/db/migration/V10__comment_model_and_flags.sql b/apps/api/src/main/resources/db/migration/V10__comment_model_and_flags.sql new file mode 100644 index 0000000..e3fb46e --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V10__comment_model_and_flags.sql @@ -0,0 +1,36 @@ +ALTER TABLE site_setting + ADD COLUMN comments_enabled boolean NOT NULL DEFAULT true, + ADD COLUMN version bigint NOT NULL DEFAULT 0; + +ALTER TABLE article + ADD COLUMN comments_enabled boolean NOT NULL DEFAULT true; + +CREATE TABLE comment ( + id uuid PRIMARY KEY, + article_id uuid NOT NULL REFERENCES article(id) ON DELETE CASCADE, + parent_id uuid, + nickname varchar(40) NOT NULL, + email_ciphertext bytea, + email_nonce bytea, + body text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'APPROVED', 'SPAM', 'REJECTED', 'USER_DELETED')), + ip_hmac bytea NOT NULL CHECK (octet_length(ip_hmac) = 32), + content_fingerprint bytea NOT NULL CHECK (octet_length(content_fingerprint) = 32), + delete_token_digest bytea NOT NULL UNIQUE CHECK (octet_length(delete_token_digest) = 32), + moderated_by uuid REFERENCES admin_user(id) ON DELETE SET NULL, + moderation_reason varchar(600), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + moderated_at timestamptz, + deleted_at timestamptz, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT comment_id_article_uq UNIQUE (id, article_id), + CONSTRAINT comment_email_pair CHECK ((email_ciphertext IS NULL) = (email_nonce IS NULL)), + CONSTRAINT comment_email_nonce_length CHECK (email_nonce IS NULL OR octet_length(email_nonce) = 12), + CONSTRAINT comment_article_parent_fk FOREIGN KEY (parent_id, article_id) + REFERENCES comment(id, article_id) ON DELETE RESTRICT +); + +CREATE INDEX comment_article_status_created_idx ON comment (article_id, status, created_at, id); +CREATE INDEX comment_parent_created_idx ON comment (parent_id, created_at, id); diff --git a/apps/api/src/main/resources/db/migration/V11__comment_security_and_outbox_payloads.sql b/apps/api/src/main/resources/db/migration/V11__comment_security_and_outbox_payloads.sql new file mode 100644 index 0000000..440ae99 --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V11__comment_security_and_outbox_payloads.sql @@ -0,0 +1,49 @@ +ALTER TABLE comment + RENAME COLUMN body TO content; + +ALTER TABLE comment + RENAME COLUMN moderated_by TO moderator_id; + +ALTER TABLE comment + ADD COLUMN email_key_version integer, + ADD COLUMN ip_hmac_date date; + +UPDATE comment +SET email_key_version = 1 +WHERE email_ciphertext IS NOT NULL; + +UPDATE comment +SET ip_hmac_date = (created_at AT TIME ZONE 'UTC')::date; + +ALTER TABLE comment + ALTER COLUMN ip_hmac_date SET NOT NULL, + DROP CONSTRAINT comment_email_pair, + ADD CONSTRAINT comment_email_pair CHECK ( + (email_ciphertext IS NULL AND email_nonce IS NULL AND email_key_version IS NULL) + OR (email_ciphertext IS NOT NULL AND email_nonce IS NOT NULL AND email_key_version = 1) + ), + ADD CONSTRAINT comment_uuid_v7_check CHECK (substring(id::text, 15, 1) = '7'); + +ALTER TABLE outbox_event + DROP CONSTRAINT outbox_article_payload, + ADD CONSTRAINT outbox_event_payload_by_type CHECK ( + event_type NOT IN ('ARTICLE_PUBLISHED', 'COMMENT_CREATED') + OR ( + event_type = 'ARTICLE_PUBLISHED' + AND jsonb_typeof(payload) = 'object' + AND payload->>'eventType' = 'ARTICLE_PUBLISHED' + AND payload ?& ARRAY['articleId', 'revisionId', 'eventType', 'occurredAt'] + AND (payload - ARRAY['articleId', 'revisionId', 'eventType', 'occurredAt']) = '{}'::jsonb + ) + OR ( + event_type = 'COMMENT_CREATED' + AND jsonb_typeof(payload) = 'object' + AND payload->>'eventType' = 'COMMENT_CREATED' + AND payload ?& ARRAY['commentId', 'articleId', 'eventType', 'occurredAt'] + AND (payload - ARRAY['commentId', 'articleId', 'eventType', 'occurredAt']) = '{}'::jsonb + ) + ); + +CREATE UNIQUE INDEX outbox_comment_created_uq + ON outbox_event (aggregate_id, event_type) + WHERE event_type = 'COMMENT_CREATED'; diff --git a/apps/api/src/main/resources/db/migration/V12__comment_fingerprint_uniqueness.sql b/apps/api/src/main/resources/db/migration/V12__comment_fingerprint_uniqueness.sql new file mode 100644 index 0000000..7d2bb74 --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V12__comment_fingerprint_uniqueness.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX comment_article_fingerprint_uq + ON comment (article_id, content_fingerprint); diff --git a/apps/api/src/main/resources/db/migration/V13__outbox_claim_index.sql b/apps/api/src/main/resources/db/migration/V13__outbox_claim_index.sql new file mode 100644 index 0000000..133e0a0 --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V13__outbox_claim_index.sql @@ -0,0 +1,4 @@ +CREATE INDEX outbox_comment_available_idx + ON outbox_event (available_at, created_at, id) + WHERE event_type = 'COMMENT_CREATED' + AND status IN ('PENDING', 'PROCESSING'); diff --git a/apps/api/src/main/resources/db/migration/V14__toolbox_domain_model.sql b/apps/api/src/main/resources/db/migration/V14__toolbox_domain_model.sql new file mode 100644 index 0000000..4fdf07e --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V14__toolbox_domain_model.sql @@ -0,0 +1,44 @@ +CREATE TABLE tool_category ( + id uuid PRIMARY KEY, + name varchar(120) NOT NULL UNIQUE, + slug varchar(160) NOT NULL UNIQUE, + description varchar(600), + sort_order integer NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT tool_category_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$') +); + +CREATE TABLE tool ( + id uuid PRIMARY KEY, + category_id uuid NOT NULL REFERENCES tool_category(id) ON DELETE RESTRICT, + type varchar(16) NOT NULL CHECK (type IN ('LINK', 'EMBEDDED', 'SHOWCASE')), + status varchar(16) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')), + title varchar(160) NOT NULL, + slug varchar(160) NOT NULL UNIQUE, + description varchar(600), + url varchar(2048), + image_url varchar(2048), + component_key varchar(32), + tags jsonb NOT NULL DEFAULT '[]'::jsonb, + sort_order integer NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT tool_tags_array CHECK (jsonb_typeof(tags) = 'array'), + CONSTRAINT tool_component_key CHECK (component_key IS NULL OR component_key IN ( + 'json-format', 'base64', 'url-codec', 'timestamp', 'regex-test' + )), + CONSTRAINT tool_https_urls CHECK ( + (url IS NULL OR url ~ '^https://[^[:space:]]+$') + AND (image_url IS NULL OR image_url ~ '^https://[^[:space:]]+$') + ), + CONSTRAINT tool_type_fields CHECK ( + (type IN ('LINK', 'SHOWCASE') AND url IS NOT NULL AND component_key IS NULL) + OR (type = 'EMBEDDED' AND url IS NULL AND component_key IS NOT NULL) + ) +); + +CREATE INDEX tool_category_sort_idx ON tool (category_id, sort_order, id); +CREATE INDEX tool_filter_idx ON tool (type, status, updated_at, id); diff --git a/apps/api/src/main/resources/db/migration/V15__article_search_trgm_index.sql b/apps/api/src/main/resources/db/migration/V15__article_search_trgm_index.sql new file mode 100644 index 0000000..4f20f56 --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V15__article_search_trgm_index.sql @@ -0,0 +1,4 @@ +CREATE INDEX article_revision_search_trgm_idx + ON article_revision USING gin ( + (COALESCE(title, '') || ' ' || COALESCE(excerpt, '') || ' ' || markdown_source) gin_trgm_ops + ); diff --git a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java new file mode 100644 index 0000000..f09f095 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java @@ -0,0 +1,193 @@ +package io.haoblog; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import io.haoblog.comment.application.CommentSecurityService; +import io.haoblog.content.domain.Article; +import io.haoblog.content.domain.ArticleStatus; +import io.haoblog.content.persistence.ArticleRepository; +import io.haoblog.shared.id.UuidV7; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.not; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +class AdminCommentIT { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("pgvector/pgvector:0.8.6-pg17"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.placeholders.admin_username", () -> "admin"); + registry.add("spring.flyway.placeholders.admin_password_hash", () -> "$2a$10$0V.Xs7CLOUYSekm7RKq3Z.iY76KUan/Xbeu5vjmLpX.sVd4pcFpIu"); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper objectMapper; + @Autowired JdbcTemplate jdbc; + @Autowired ArticleRepository articles; + @Autowired CommentSecurityService security; + private UUID articleId; + + @BeforeEach + void seed() { + jdbc.execute("TRUNCATE comment, article_revision, article CASCADE"); + jdbc.update("UPDATE site_setting SET comments_enabled=true, version=0 WHERE site_key='default'"); + Instant now = Instant.parse("2020-01-01T00:00:00Z"); + Article article = articles.saveAndFlush(new Article("moderation-signal", "Moderation signal", "Excerpt", "# body", + ArticleStatus.PUBLISHED, now.minus(1, ChronoUnit.DAYS), now)); + articleId = article.getId(); + UUID revisionId = UuidV7.generate(); + jdbc.update("INSERT INTO article_revision(id, article_id, source_version, title, slug, excerpt, markdown_source, tag_snapshot, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, '[]'::jsonb, ?)", + revisionId, articleId, 0L, "Moderation signal", "moderation-signal", "Excerpt", "# body", Timestamp.from(now)); + jdbc.update("UPDATE article SET published_revision_id=? WHERE id=?", revisionId, articleId); + insertComment("Alice", "first signal", "alice@example.com", "PENDING", now.minusSeconds(30)); + insertComment("Bob", "second signal", null, "APPROVED", now.minusSeconds(10)); + insertComment("Alice", "third signal", null, "REJECTED", now); + } + + @Test + void protectsAdminEndpointsWithAuthenticationRoleAndCsrf() throws Exception { + mvc.perform(get("/api/v1/admin/comments")).andExpect(status().isUnauthorized()) + .andExpect(content().contentType("application/problem+json")); + mvc.perform(get("/api/v1/admin/comments").with(user("reader").roles("USER"))) + .andExpect(status().isForbidden()); + mvc.perform(post("/api/v1/admin/comments/{id}/moderation", firstComment()).with(user("admin").roles("ADMIN")) + .contentType("application/json").content("{}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); + } + + @Test + void filtersAndPagesModerationLog() throws Exception { + mvc.perform(get("/api/v1/admin/comments").with(admin()) + .param("status", "PENDING").param("articleId", articleId.toString()) + .param("keyword", "Alice").param("page", "0").param("size", "1").param("direction", "asc")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.items[0].nickname").value("Alice")) + .andExpect(jsonPath("$.items[0].emailMasked").value("a***@example.com")) + .andExpect(jsonPath("$.items[0].ipHmac").doesNotExist()) + .andExpect(jsonPath("$.items[0].deleteTokenDigest").doesNotExist()); + } + + @Test + void onlyDetailReturnsDecryptedEmailAndModerationUsesVersionAndAudit() throws Exception { + UUID id = firstComment(); + var detail = mvc.perform(get("/api/v1/admin/comments/{id}", id).with(admin())) + .andExpect(status().isOk()).andExpect(jsonPath("$.email").value("alice@example.com")) + .andExpect(jsonPath("$.ipHmac").doesNotExist()).andReturn(); + JsonNode body = objectMapper.readTree(detail.getResponse().getContentAsString()); + long version = body.get("version").asLong(); + + mvc.perform(post("/api/v1/admin/comments/{id}/moderation", id).with(admin()).with(csrf()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(Map.of("version", version, "status", "SPAM", "reason", "链接密度过高")))) + .andExpect(status().isOk()).andExpect(jsonPath("$.status").value("SPAM")) + .andExpect(jsonPath("$.moderationReason").value("链接密度过高")) + .andExpect(jsonPath("$.moderatorId").isNotEmpty()).andExpect(jsonPath("$.version").value(1)); + + mvc.perform(post("/api/v1/admin/comments/{id}/moderation", id).with(admin()).with(csrf()) + .contentType("application/json") + .content("{\"version\":0,\"status\":\"APPROVED\"}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("COMMENT_VERSION_CONFLICT")); + + mvc.perform(post("/api/v1/admin/comments/{id}/moderation", id).with(admin()).with(csrf()) + .contentType("application/json") + .content("{\"version\":1,\"status\":\"PENDING\"}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("COMMENT_MODERATION_STATE_CONFLICT")); + } + + @Test + void globalSiteSwitchChangesPublicSiteAndArticleEtags() throws Exception { + var site = mvc.perform(get("/api/v1/public/site")).andExpect(status().isOk()).andReturn(); + var article = mvc.perform(get("/api/v1/public/articles/moderation-signal")).andExpect(status().isOk()).andReturn(); + String siteEtag = site.getResponse().getHeader("ETag"); + String articleEtag = article.getResponse().getHeader("ETag"); + + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":false}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.commentsEnabled").value(false)); + + mvc.perform(get("/api/v1/public/site").header("If-None-Match", siteEtag)) + .andExpect(status().isOk()).andExpect(header().string("ETag", not(siteEtag))) + .andExpect(jsonPath("$.commentsEnabled").value(false)); + mvc.perform(get("/api/v1/public/articles/moderation-signal").header("If-None-Match", articleEtag)) + .andExpect(status().isOk()).andExpect(header().string("ETag", not(articleEtag))) + .andExpect(jsonPath("$.commentsEnabled").value(false)); + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SITE_VERSION_CONFLICT")); + } + + @Test + void articleSwitchChangesPublicArticleEtag() throws Exception { + var before = mvc.perform(get("/api/v1/public/articles/moderation-signal")).andExpect(status().isOk()).andReturn(); + var adminArticle = mvc.perform(get("/api/v1/admin/articles/{id}", articleId).with(admin())) + .andExpect(status().isOk()).andReturn(); + long version = objectMapper.readTree(adminArticle.getResponse().getContentAsString()).get("version").asLong(); + mvc.perform(put("/api/v1/admin/articles/{id}", articleId).with(admin()).with(csrf()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(Map.of("version", version, "title", "Moderation signal", + "markdown", "# body", "commentsEnabled", false)))) + .andExpect(status().isOk()).andExpect(jsonPath("$.commentsEnabled").value(false)); + mvc.perform(get("/api/v1/public/articles/moderation-signal") + .header("If-None-Match", before.getResponse().getHeader("ETag"))) + .andExpect(status().isOk()).andExpect(header().string("ETag", not(before.getResponse().getHeader("ETag")))) + .andExpect(jsonPath("$.commentsEnabled").value(false)); + } + + private org.springframework.test.web.servlet.request.RequestPostProcessor admin() { + return user("admin").roles("ADMIN"); + } + + private UUID firstComment() { + return jdbc.queryForObject("SELECT id FROM comment WHERE nickname='Alice' AND content='first signal'", UUID.class); + } + + private void insertComment(String nickname, String content, String email, String status, Instant createdAt) { + UUID id = UuidV7.generate(); + var encrypted = security.encryptEmail(id, email); + jdbc.update(""" + INSERT INTO comment(id, article_id, nickname, email_ciphertext, email_nonce, email_key_version, + content, status, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, id, articleId, nickname, + encrypted == null ? null : encrypted.ciphertext(), encrypted == null ? null : encrypted.nonce(), + encrypted == null ? null : encrypted.keyVersion(), content, status, new byte[32], + Date.valueOf(LocalDate.ofInstant(createdAt, java.time.ZoneOffset.UTC)), + security.contentFingerprint(articleId, nickname + content), security.deleteTokenDigest(security.newDeleteToken()), + Timestamp.from(createdAt), Timestamp.from(createdAt)); + } +} diff --git a/apps/api/src/test/java/io/haoblog/ArchitectureTest.java b/apps/api/src/test/java/io/haoblog/ArchitectureTest.java index 7238774..488c6fd 100644 --- a/apps/api/src/test/java/io/haoblog/ArchitectureTest.java +++ b/apps/api/src/test/java/io/haoblog/ArchitectureTest.java @@ -16,8 +16,14 @@ class ArchitectureTest { @ArchTest static final ArchRule other_modules_must_not_depend_on_content_internals = noClasses() - .that().resideInAnyPackage("io.haoblog.identity..", "io.haoblog.comment..", + .that().resideInAnyPackage("io.haoblog.identity..", "io.haoblog.toolbox..", "io.haoblog.ai..", "io.haoblog.media..", "io.haoblog.site..") .should().dependOnClassesThat().resideInAnyPackage( "io.haoblog.content.persistence..", "io.haoblog.content.domain.."); + + @ArchTest + static final ArchRule comment_must_use_content_public_boundary = noClasses() + .that().resideInAnyPackage("io.haoblog.comment..") + .should().dependOnClassesThat().resideInAnyPackage( + "io.haoblog.content.persistence..", "io.haoblog.content.domain.."); } diff --git a/apps/api/src/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index 6e61fa2..7ca0255 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -1,8 +1,12 @@ package io.haoblog; +import io.haoblog.comment.domain.Comment; +import io.haoblog.comment.domain.CommentStatus; +import io.haoblog.comment.persistence.CommentRepository; import io.haoblog.content.domain.Article; import io.haoblog.content.domain.ArticleStatus; import io.haoblog.content.persistence.ArticleRepository; +import io.haoblog.shared.id.UuidV7; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.RollbackException; @@ -20,6 +24,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.sql.Timestamp; import java.util.List; @@ -47,13 +52,14 @@ static void database(DynamicPropertyRegistry registry) { @Autowired JdbcTemplate jdbc; @Autowired ArticleRepository articles; + @Autowired CommentRepository comments; @Autowired EntityManagerFactory entityManagerFactory; @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(9, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(15, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); for (String table : List.of("article", "category", "tag", "article_tag", "article_revision", - "article_preview_token", "media_asset", "media_upload", "outbox_event")) { + "article_preview_token", "media_asset", "media_upload", "outbox_event", "comment")) { assertEquals(1, jdbc.queryForObject( "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name=?", Integer.class, table)); @@ -62,6 +68,29 @@ void migratesAllVersionsAndCreatesContentTables() { "SELECT is_nullable FROM information_schema.columns WHERE table_name='article' AND column_name='slug'", String.class)); assertEquals("bigint", jdbc.queryForObject( "SELECT data_type FROM information_schema.columns WHERE table_name='article' AND column_name='version'", String.class)); + assertEquals("boolean", jdbc.queryForObject( + "SELECT data_type FROM information_schema.columns WHERE table_name='article' AND column_name='comments_enabled'", String.class)); + assertEquals("boolean", jdbc.queryForObject( + "SELECT data_type FROM information_schema.columns WHERE table_name='site_setting' AND column_name='comments_enabled'", String.class)); + assertEquals("bigint", jdbc.queryForObject( + "SELECT data_type FROM information_schema.columns WHERE table_name='site_setting' AND column_name='version'", String.class)); + for (String column : List.of("article_id", "parent_id", "content", "email_ciphertext", "email_nonce", + "email_key_version", "ip_hmac", "ip_hmac_date", "content_fingerprint", "delete_token_digest", + "moderator_id", "moderation_reason", "moderated_at", "version", "created_at", "updated_at")) { + assertEquals(1, jdbc.queryForObject( + "SELECT count(*) FROM information_schema.columns WHERE table_name='comment' AND column_name=?", + Integer.class, column)); + } + for (String index : List.of("comment_article_status_created_idx", "comment_parent_created_idx", + "comment_article_fingerprint_uq", "outbox_comment_created_uq", "outbox_comment_available_idx")) { + assertEquals(1, jdbc.queryForObject( + "SELECT count(*) FROM pg_indexes WHERE schemaname='public' AND indexname=?", Integer.class, index)); + } + for (String constraint : List.of("comment_article_parent_fk", "comment_email_pair", "comment_uuid_v7_check", + "outbox_event_payload_by_type")) { + assertEquals(1, jdbc.queryForObject( + "SELECT count(*) FROM pg_constraint WHERE conname=?", Integer.class, constraint)); + } } @Test @@ -166,7 +195,7 @@ void articleRevisionCannotBeUpdatedOrDeleted() { } @Test - void outboxPayloadIsRestrictedToPublicationEnvelope() { + void outboxPayloadsAreRestrictedAndDeduplicatedByEventType() { Instant now = Instant.now(); Timestamp timestamp = Timestamp.from(now); UUID eventId = UUID.randomUUID(); @@ -186,6 +215,94 @@ void outboxPayloadIsRestrictedToPublicationEnvelope() { assertThrows(DataAccessException.class, () -> jdbc.update( "INSERT INTO outbox_event(id, aggregate_id, event_type, payload, available_at, created_at) VALUES (?, ?, ?, ?::jsonb, ?, ?)", UUID.randomUUID(), UUID.randomUUID(), "ARTICLE_PUBLISHED", "{\"articleId\":\"x\",\"revisionId\":\"y\",\"eventType\":\"x\",\"occurredAt\":\"z\",\"markdown\":\"secret\"}", timestamp, timestamp)); + + UUID commentId = UuidV7.generate(); + String commentPayload = "{\"commentId\":\"" + commentId + "\",\"articleId\":\"" + aggregateId + + "\",\"eventType\":\"COMMENT_CREATED\",\"occurredAt\":\"" + now + "\"}"; + jdbc.update("INSERT INTO outbox_event(id, aggregate_id, event_type, payload, available_at, created_at) VALUES (?, ?, ?, ?::jsonb, ?, ?)", + UUID.randomUUID(), commentId, "COMMENT_CREATED", commentPayload, timestamp, timestamp); + assertThrows(DataAccessException.class, () -> jdbc.update( + "INSERT INTO outbox_event(id, aggregate_id, event_type, payload, available_at, created_at) VALUES (?, ?, ?, ?::jsonb, ?, ?)", + UUID.randomUUID(), commentId, "COMMENT_CREATED", commentPayload, timestamp, timestamp)); + assertThrows(DataAccessException.class, () -> jdbc.update( + "INSERT INTO outbox_event(id, aggregate_id, event_type, payload, available_at, created_at) VALUES (?, ?, ?, ?::jsonb, ?, ?)", + UUID.randomUUID(), UUID.randomUUID(), "COMMENT_CREATED", + commentPayload.replace("COMMENT_CREATED", "ARTICLE_PUBLISHED"), timestamp, timestamp)); + } + + @Test + void commentConstraintsStatusParentAndUuidAreEnforced() { + Instant now = Instant.now(); + Timestamp timestamp = Timestamp.from(now); + UUID articleId = UUID.randomUUID(); + UUID otherArticleId = UUID.randomUUID(); + insertDraft(articleId, "comment-article-" + articleId); + insertDraft(otherArticleId, "comment-article-" + otherArticleId); + UUID parentId = UuidV7.generate(); + byte[] digest = new byte[32]; + jdbc.update("INSERT INTO comment(id, article_id, nickname, content, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + parentId, articleId, "Hao", "parent", digest, now.atZone(ZoneOffset.UTC).toLocalDate(), digest, digest, timestamp, timestamp); + assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM comment WHERE id=?", String.class, parentId)); + assertThrows(DataAccessException.class, () -> jdbc.update( + "INSERT INTO comment(id, article_id, nickname, content, status, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, 'UNKNOWN', ?, ?, ?, ?, ?, ?)", + UuidV7.generate(), articleId, "Hao", "invalid", digest, now.atZone(ZoneOffset.UTC).toLocalDate(), digest, new byte[31], timestamp, timestamp)); + assertThrows(DataAccessException.class, () -> jdbc.update( + "INSERT INTO comment(id, article_id, nickname, content, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + UUID.randomUUID(), articleId, "Hao", "not v7", digest, now.atZone(ZoneOffset.UTC).toLocalDate(), digest, new byte[32], timestamp, timestamp)); + assertThrows(DataAccessException.class, () -> jdbc.update( + "INSERT INTO comment(id, article_id, parent_id, nickname, content, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + UuidV7.generate(), otherArticleId, parentId, "Hao", "wrong parent article", digest, now.atZone(ZoneOffset.UTC).toLocalDate(), digest, + new byte[32], timestamp, timestamp)); + } + + @Test + void commentStatusPersistsAndOptimisticLockRejectsStaleUpdate() { + Instant now = Instant.now(); + Article article = articles.saveAndFlush(new Article("comment-lock-" + UUID.randomUUID(), "Comment lock", null, "# lock", + ArticleStatus.DRAFT, null, now)); + byte[] deleteTokenDigest; + try { + deleteTokenDigest = MessageDigest.getInstance("SHA-256") + .digest(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)); + } catch (Exception exception) { + throw new AssertionError(exception); + } + Comment saved = comments.saveAndFlush(new Comment(article.getId(), null, "Hao", null, null, null, + "body", new byte[32], now.atZone(ZoneOffset.UTC).toLocalDate(), new byte[32], deleteTokenDigest, now)); + assertEquals(7, saved.getId().version()); + assertEquals(CommentStatus.PENDING, saved.getStatus()); + EntityManager firstManager = entityManagerFactory.createEntityManager(); + EntityManager secondManager = entityManagerFactory.createEntityManager(); + var firstTransaction = firstManager.getTransaction(); + var secondTransaction = secondManager.getTransaction(); + try { + firstTransaction.begin(); + secondTransaction.begin(); + Comment first = firstManager.find(Comment.class, saved.getId()); + Comment second = secondManager.find(Comment.class, saved.getId()); + first.moderate(CommentStatus.APPROVED, UUID.fromString("0198a4f0-0000-7000-8000-000000000002"), "ok", now.plusSeconds(1)); + firstTransaction.commit(); + assertEquals(1, first.getVersion()); + assertEquals(CommentStatus.APPROVED, first.getStatus()); + second.moderate(CommentStatus.SPAM, null, "stale", now.plusSeconds(2)); + assertThrows(RollbackException.class, secondTransaction::commit); + } finally { + if (firstTransaction.isActive()) firstTransaction.rollback(); + if (secondTransaction.isActive()) secondTransaction.rollback(); + firstManager.close(); + secondManager.close(); + } + } + + private void insertDraft(UUID id, String slug) { + Instant now = Instant.now(); + Timestamp timestamp = Timestamp.from(now); + jdbc.update("INSERT INTO article(id, slug, title, markdown_source, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'DRAFT', ?, ?)", + id, slug, "Comment article", "# article", timestamp, timestamp); } private static String toJson(Map values) { diff --git a/apps/api/src/test/java/io/haoblog/PublicApiIT.java b/apps/api/src/test/java/io/haoblog/PublicApiIT.java index afb405b..c252f7f 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiIT.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiIT.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; +import java.util.List; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.junit.jupiter.api.Assertions.*; @@ -109,6 +110,77 @@ static void database(DynamicPropertyRegistry registry) { .andExpect(jsonPath("$.title").value("HaoBlog")); } + @Test + void searchNormalizesUnicodeMatchesCaseInsensitivelyAndRanksFields() throws Exception { + seedPublished("search-title", "SIGNAL title", "no match", "# body", Instant.now().minus(3, ChronoUnit.HOURS), Instant.now()); + seedPublished("search-excerpt", "Older title", "SIGNAL excerpt", "# body", Instant.now().minus(2, ChronoUnit.HOURS), Instant.now()); + seedPublished("search-body", "Oldest title", "no match", "# SIGNAL body", Instant.now().minus(1, ChronoUnit.HOURS), Instant.now()); + + mvc.perform(get("/api/v1/public/search/articles").param("q", " SIGNAL ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(3)) + .andExpect(jsonPath("$.items[0].title").value("SIGNAL title")) + .andExpect(jsonPath("$.items[1].title").value("Older title")) + .andExpect(jsonPath("$.items[2].title").value("Oldest title")) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("markdown")))); + } + + @Test + void searchEscapesPercentUnderscoreAndBackslashAndKeepsOnlyPublishedSnapshot() throws Exception { + seedPublished("search-special", "Literal %_\\ path", "special token", "# special", Instant.now(), Instant.now()); + UUID archivedId = seedPublished("search-archived", "Literal %_\\ archived", "special token", "# special", Instant.now(), Instant.now()); + jdbc.update("UPDATE article SET status='ARCHIVED' WHERE id=?", archivedId); + + mvc.perform(get("/api/v1/public/search/articles").param("q", "%_\\")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.items[0].slug").value("search-special")); + mvc.perform(get("/api/v1/public/search/articles").param("q", "Future")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(0)); + } + + @Test + void searchPaginatesEmptyResultsAndIsolatesUnpublishedWorkingChanges() throws Exception { + for (int index = 0; index < 21; index++) { + seedPublished("search-page-" + index, "Page match " + index, "page", "# page", Instant.now().minus(index, ChronoUnit.MINUTES), Instant.now()); + } + mvc.perform(get("/api/v1/public/search/articles").param("q", "page").param("size", "20")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(21)).andExpect(jsonPath("$.items.length()").value(20)); + mvc.perform(get("/api/v1/public/search/articles").param("q", "page").param("page", "1").param("size", "20")) + .andExpect(status().isOk()).andExpect(jsonPath("$.items.length()").value(1)); + mvc.perform(get("/api/v1/public/search/articles").param("q", "no-such-signal")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(0)).andExpect(jsonPath("$.items.length()").value(0)); + + UUID articleId = seedPublished("search-snapshot", "Published snapshot", "published", "# published", Instant.now(), Instant.now()); + jdbc.update("UPDATE article SET title=?, excerpt=?, markdown_source=? WHERE id=?", + "Unpublished working copy", "unpublished", "# unpublished", articleId); + mvc.perform(get("/api/v1/public/search/articles").param("q", "Published")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(1)); + mvc.perform(get("/api/v1/public/search/articles").param("q", "Unpublished")) + .andExpect(status().isOk()).andExpect(jsonPath("$.total").value(0)); + } + + @Test + void searchQueryUsesTheTrigramExpressionIndex() { + jdbc.execute("SET enable_seqscan = off"); + try { + List plan = jdbc.queryForList(""" + EXPLAIN (COSTS OFF) + SELECT r.id + FROM article_revision r + JOIN article a ON a.published_revision_id = r.id + WHERE a.status IN ('PUBLISHED', 'SCHEDULED') + AND a.published_at IS NOT NULL + AND a.published_at <= now() + AND (coalesce(r.title, '') || ' ' || coalesce(r.excerpt, '') || ' ' || r.markdown_source) + ILIKE '%Vis%' ESCAPE chr(92) + """, String.class); + assertTrue(plan.stream().anyMatch(line -> line.contains("article_revision_search_trgm_idx")), plan.toString()); + } finally { + jdbc.execute("RESET enable_seqscan"); + } + } + @Test void feedsContainOnlyPublishedArticlesAndSupportConditionalCaching() throws Exception { seedPublished("newer-feed", "Newer feed", "Newest", "# newer", Instant.now().minus(30, ChronoUnit.SECONDS), Instant.now()); UUID archivedId = seedPublished("archived-feed", "Archived feed", "No", "# archived", Instant.now().minus(1, ChronoUnit.DAYS), Instant.now()); @@ -140,7 +212,7 @@ static void database(DynamicPropertyRegistry registry) { .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("scheduled-feed")))) .andExpect(header().exists("ETag")) .andReturn(); - assertEquals(5, count(sitemap.getResponse().getContentAsString(), "")); + assertEquals(6, count(sitemap.getResponse().getContentAsString(), "")); mvc.perform(get("/sitemap.xml").header("If-None-Match", sitemap.getResponse().getHeader("ETag"))) .andExpect(status().isNotModified()).andExpect(content().string("")); } @@ -159,6 +231,9 @@ static void database(DynamicPropertyRegistry registry) { assertEquals(1, jdbc.queryForObject("SELECT count(*) FROM pg_index WHERE indexrelid='article_public_published_idx'::regclass AND indpred IS NOT NULL", Integer.class)); String predicate = jdbc.queryForObject("SELECT pg_get_expr(indpred, indrelid) FROM pg_index WHERE indexrelid='article_public_published_idx'::regclass", String.class); assertTrue(predicate.contains("status") && predicate.contains("PUBLISHED") && predicate.contains("published_at")); + assertEquals(1, jdbc.queryForObject("SELECT count(*) FROM pg_indexes WHERE indexname='article_revision_search_trgm_idx'", Integer.class)); + String searchIndex = jdbc.queryForObject("SELECT indexdef FROM pg_indexes WHERE indexname='article_revision_search_trgm_idx'", String.class); + assertTrue(searchIndex.contains("gin") && searchIndex.contains("gin_trgm_ops")); } @Test void applicationPersistsUuidV7WithoutDatabaseUuidDefault() { diff --git a/apps/api/src/test/java/io/haoblog/PublicSearchTest.java b/apps/api/src/test/java/io/haoblog/PublicSearchTest.java new file mode 100644 index 0000000..1f4cdd3 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/PublicSearchTest.java @@ -0,0 +1,75 @@ +package io.haoblog; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.content.web.PublicSearchController; +import io.haoblog.shared.web.GlobalExceptionHandler; +import io.haoblog.shared.web.ProblemResponseWriter; +import io.haoblog.shared.web.TraceIdFilter; +import io.haoblog.site.application.SiteService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(PublicSearchController.class) +@Import({TraceIdFilter.class, GlobalExceptionHandler.class, ProblemResponseWriter.class}) +class PublicSearchTest { + @Autowired MockMvc mvc; + @MockitoBean ArticleService articleService; + @MockitoBean SiteService siteService; + + @Test + void validatesSearchAndReturnsSafeProblemForInvalidInput() throws Exception { + when(articleService.search(any(), any(Integer.class), any(Integer.class))) + .thenThrow(new IllegalArgumentException("query/page/size out of range")); + + for (String query : new String[]{"", "a", "a".repeat(101)}) { + mvc.perform(get("/api/v1/public/search/articles").param("q", query)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")); + } + mvc.perform(get("/api/v1/public/search/articles").param("q", "valid").param("size", "21")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")); + } + + @Test + void returnsSummaryOnlyAndSupportsSharedConditionalCaching() throws Exception { + var article = new ArticleService.PublicSearchArticle( + java.util.UUID.randomUUID(), "visible", "Visible result", "Excerpt", Instant.parse("2026-01-01T00:00:00Z"), null, true); + when(articleService.search("signal", 0, 20)).thenReturn(new ArticleService.SearchPage( + "signal", new PageImpl<>(List.of(article), PageRequest.of(0, 20), 1))); + when(articleService.publicCoverUrls(any())).thenReturn(java.util.Map.of()); + when(siteService.get()).thenReturn(new SiteService.SiteResult("HaoBlog", "Night station", "https://blog.example.test", "Hao")); + + var first = mvc.perform(get("/api/v1/public/search/articles").param("q", "signal")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].title").value("Visible result")) + .andExpect(jsonPath("$.items[0].excerpt").value("Excerpt")) + .andExpect(jsonPath("$.items[0].markdown").doesNotExist()) + .andExpect(header().string("Cache-Control", "public, max-age=0, s-maxage=60, must-revalidate")) + .andExpect(header().exists("ETag")) + .andReturn(); + + mvc.perform(get("/api/v1/public/search/articles").param("q", "signal") + .header("If-None-Match", first.getResponse().getHeader("ETag"))) + .andExpect(status().isNotModified()) + .andExpect(content().string("")); + } +} diff --git a/apps/api/src/test/java/io/haoblog/ToolAdminIT.java b/apps/api/src/test/java/io/haoblog/ToolAdminIT.java new file mode 100644 index 0000000..6523179 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/ToolAdminIT.java @@ -0,0 +1,171 @@ +package io.haoblog; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.junit.jupiter.api.BeforeEach; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +class ToolAdminIT { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("pgvector/pgvector:0.8.6-pg17"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.placeholders.admin_username", () -> "admin"); + registry.add("spring.flyway.placeholders.admin_password_hash", () -> "$2a$10$0V.Xs7CLOUYSekm7RKq3Z.iY76KUan/Xbeu5vjmLpX.sVd4pcFpIu"); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper objectMapper; + @Autowired JdbcTemplate jdbc; + + @BeforeEach + void cleanToolTables() { + jdbc.execute("TRUNCATE tool, tool_category CASCADE"); + } + + @Test + void migrationStartsWithEmptyToolTablesAndWritesNeedAuthAndCsrf() throws Exception { + assertNotNull(jdbc.queryForObject("select to_regclass('tool_category')", String.class)); + assertNotNull(jdbc.queryForObject("select to_regclass('tool')", String.class)); + mvc.perform(get("/api/v1/admin/tool-categories")) + .andExpect(status().isUnauthorized()).andExpect(content().contentType("application/problem+json")); + mvc.perform(post("/api/v1/admin/tool-categories").with(admin()).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"No CSRF\",\"slug\":\"no-csrf\"}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); + } + + @Test + void rejectsUnsafeComponentAndUrlCombinations() throws Exception { + String categoryId = createCategory(); + tool(categoryId, "{\"type\":\"EMBEDDED\",\"componentKey\":\" + + diff --git a/apps/web/app/components/articles/CommentSignalSection.vue b/apps/web/app/components/articles/CommentSignalSection.vue new file mode 100644 index 0000000..59c963c --- /dev/null +++ b/apps/web/app/components/articles/CommentSignalSection.vue @@ -0,0 +1,246 @@ + + +