From f9f7fb678fdc4f16c37d73b0975222b99c6c58a6 Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sat, 22 Aug 2026 22:03:12 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=9B=9B=EF=BC=9A?= =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E8=AF=84=E8=AE=BA=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .../application/CommentChallengeService.java | 60 +++++ .../application/CommentSecurityService.java | 128 ++++++++++ .../io/haoblog/comment/domain/Comment.java | 98 ++++++++ .../haoblog/comment/domain/CommentStatus.java | 9 + .../persistence/CommentRepository.java | 13 + .../web/PublicCommentContextController.java | 54 +++++ .../content/application/ArticleService.java | 12 +- .../io/haoblog/content/domain/Article.java | 4 + .../ArticleRevisionRepository.java | 7 +- .../haoblog/content/web/AdminArticleDtos.java | 8 +- .../content/web/PublicArticleController.java | 10 +- .../haoblog/site/application/SiteService.java | 8 +- .../io/haoblog/site/domain/SiteSetting.java | 4 + .../site/web/PublicSiteController.java | 8 +- apps/api/src/main/resources/application.yml | 2 + .../V10__comment_model_and_flags.sql | 36 +++ .../test/java/io/haoblog/ContentModelIT.java | 8 +- .../CommentChallengeServiceTest.java | 42 ++++ .../CommentSecurityServiceTest.java | 48 ++++ apps/web/app/utils/publicSite.ts | 1 + docs/openapi/public-api.yaml | 39 ++- ...56\346\240\207\344\273\273\345\212\241.md" | 226 ------------------ ...06\345\205\245\346\270\205\345\215\225.md" | 95 -------- infra/compose/.env.ci.example | 1 + packages/api-client/src/generated.ts | 51 ++++ 26 files changed, 628 insertions(+), 345 deletions(-) create mode 100644 apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/domain/Comment.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/domain/CommentStatus.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java create mode 100644 apps/api/src/main/resources/db/migration/V10__comment_model_and_flags.sql create mode 100644 apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java create mode 100644 apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java delete mode 100644 "docs/\347\254\254\344\270\211\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" delete mode 100644 "docs/\351\230\266\346\256\265\344\270\211\345\207\206\345\205\245\346\270\205\345\215\225.md" diff --git a/.env.example b/.env.example index 5430252..171378a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ 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= # 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/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..2f5746e --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java @@ -0,0 +1,60 @@ +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) { + if (token == null || token.isBlank()) return false; + Challenge challenge = challenges.getIfPresent(token); + if (challenge == null) return false; + Instant now = clock.instant(); + if (!challenge.articleId().equals(articleId) + || now.isBefore(challenge.issuedAt().plus(MINIMUM_FILL_TIME)) + || !challenge.expiresAt().isAfter(now)) { + return false; + } + challenges.invalidate(token); + return true; + } + + long cacheSize() { + challenges.cleanUp(); + return challenges.estimatedSize(); + } + + public record IssuedChallenge(String token, Instant expiresAt) {} + + private record Challenge(UUID articleId, Instant issuedAt, Instant expiresAt) {} +} 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..fd396c3 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java @@ -0,0 +1,128 @@ +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 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()) { + byte[] masterKey = new byte[KEY_BYTES]; + randomize(masterKey); + this.emailKey = derive(masterKey, "email-v1"); + this.ipKey = derive(masterKey, "ip-v1"); + this.challengeKey = derive(masterKey, "challenge-v1"); + } else { + byte[] masterKey; + try { + masterKey = Base64.getDecoder().decode(encodedKey); + } 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(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) { + 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 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())); + } + + 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); + } + } + + private static void randomize(byte[] value) { + new SecureRandom().nextBytes(value); + } + + public record EmailCiphertext(byte[] nonce, byte[] ciphertext) { + public EmailCiphertext { + 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/domain/Comment.java b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java new file mode 100644 index 0000000..e640aa3 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java @@ -0,0 +1,98 @@ +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.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(nullable = false, columnDefinition = "text") + private String body; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private CommentStatus status = CommentStatus.PENDING; + @Column(name = "ip_hmac", nullable = false) + private byte[] ipHmac; + @Column(name = "content_fingerprint", nullable = false) + private byte[] contentFingerprint; + @Column(name = "delete_token_digest", nullable = false, unique = true) + private byte[] deleteTokenDigest; + @Column(name = "moderated_by") + private UUID moderatedBy; + @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, String body, byte[] ipHmac, byte[] contentFingerprint, + byte[] deleteTokenDigest, Instant now) { + this.articleId = articleId; + this.parentId = parentId; + this.nickname = nickname; + this.emailCiphertext = copy(emailCiphertext); + this.emailNonce = copy(emailNonce); + this.body = body; + this.ipHmac = copy(ipHmac); + this.contentFingerprint = copy(contentFingerprint); + this.deleteTokenDigest = copy(deleteTokenDigest); + this.createdAt = now; + this.updatedAt = now; + } + + 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 String getBody() { return body; } + public CommentStatus getStatus() { return status; } + public byte[] getIpHmac() { return copy(ipHmac); } + public byte[] getContentFingerprint() { return copy(contentFingerprint); } + public byte[] getDeleteTokenDigest() { return copy(deleteTokenDigest); } + public UUID getModeratedBy() { return moderatedBy; } + 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; } + + 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..15ca80f --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java @@ -0,0 +1,13 @@ +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 java.util.UUID; + +public interface CommentRepository extends JpaRepository { + Page findByArticleIdAndStatusAndParentIdIsNull(UUID articleId, CommentStatus status, Pageable pageable); +} 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..9dfcd1a --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java @@ -0,0 +1,54 @@ +package io.haoblog.comment.web; + +import io.haoblog.comment.application.CommentChallengeService; +import io.haoblog.content.application.ArticleService; +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.ResponseEntity; +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; + +@RestController +@RequestMapping("/api/v1/public/articles") +public class PublicCommentContextController { + private final ArticleService articles; + private final SiteService site; + private final CommentChallengeService challenges; + + public PublicCommentContextController(ArticleService articles, SiteService site, CommentChallengeService challenges) { + this.articles = articles; + this.site = site; + this.challenges = challenges; + } + + @GetMapping("/{slug}/comments/form-context") + public FormContext formContext(@PathVariable String slug, CsrfToken csrfToken) { + var article = articles.findPublicBySlug(slug).orElseThrow(() -> new ArticleNotFoundException(slug)); + var issued = challenges.issue(article.articleId()); + return new FormContext(csrfToken.getToken(), issued.token(), issued.expiresAt(), + site.get().commentsEnabled() && article.commentsEnabled()); + } + + @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/content/application/ArticleService.java b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java index 7a469f0..8a75908 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 @@ -55,7 +55,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,7 +64,15 @@ 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) {} 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..0c78f3f 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 @@ -30,7 +30,7 @@ public interface ArticleRevisionRepository extends JpaRepository findByIdAndArticleId(UUID id, UUID articleId); @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 in (:published, :scheduled) @@ -45,7 +45,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 @@ -59,7 +59,7 @@ Page findPublished(@Param("published") ArticleStatus pu 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 +92,7 @@ boolean existsVisibleSlug(@Param("slug") String slug, @Param("articleId") UUID a interface PublicArticleProjection { ArticleRevision getRevision(); java.time.Instant getPublishedAt(); + boolean getCommentsEnabled(); } interface SummaryProjection { 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..4a7142c 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 @@ -42,11 +42,11 @@ 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 +54,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..14dc08c 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 @@ -73,21 +73,23 @@ private static String representationHash(Object... values) { } } - public record ArticleSummary(UUID id, String slug, String title, String excerpt, Instant publishedAt, String coverImageUrl) { + 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) { ArticleRevision revision = article.revision(); return new ArticleSummary(revision.getArticleId(), revision.getSlug(), revision.getTitle(), revision.getExcerpt(), - article.publishedAt(), coverImageUrl); + article.publishedAt(), coverImageUrl, article.commentsEnabled()); } } 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) { + String markdown, String seoTitle, String seoDescription, String coverImageUrl, + boolean commentsEnabled) { static ArticleResponse from(ArticleService.PublicArticle article, String coverImageUrl) { 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()); } } } 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..f0e9eaf 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,7 @@ 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()); } static String normalizePublicBaseUrl(String raw) { @@ -47,5 +47,9 @@ 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); + } + } } 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..2880402 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,14 @@ 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; } } 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/resources/application.yml b/apps/api/src/main/resources/application.yml index b60b158..c50669f 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -74,3 +74,5 @@ 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:} 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/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index 6e61fa2..795d677 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -51,9 +51,9 @@ static void database(DynamicPropertyRegistry registry) { @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(9, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(10, 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 +62,10 @@ 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)); } @Test diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java new file mode 100644 index 0000000..643eb43 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java @@ -0,0 +1,42 @@ +package io.haoblog.comment.application; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CommentChallengeServiceTest { + @Test + void requiresMinimumAgeAndConsumesOnce() { + var clock = new MutableClock(Instant.parse("2026-08-22T00:00:00Z")); + var security = new CommentSecurityService(Base64.getEncoder().encodeToString( + "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8))); + var challenges = new CommentChallengeService(clock, security); + UUID articleId = UUID.randomUUID(); + var issued = challenges.issue(articleId); + + assertFalse(challenges.consume(articleId, issued.token())); + + clock.advance(Duration.ofSeconds(3)); + assertTrue(challenges.consume(articleId, issued.token())); + assertFalse(challenges.consume(articleId, issued.token())); + } + + private static final class MutableClock extends Clock { + private Instant instant; + + private MutableClock(Instant instant) { this.instant = instant; } + private void advance(Duration duration) { instant = instant.plus(duration); } + @Override public ZoneId getZone() { return ZoneId.of("UTC"); } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return instant; } + } +} diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java new file mode 100644 index 0000000..5d39a53 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java @@ -0,0 +1,48 @@ +package io.haoblog.comment.application; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.Base64; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CommentSecurityServiceTest { + private static final UUID COMMENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + private final CommentSecurityService security = new CommentSecurityService( + Base64.getEncoder().encodeToString("01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8))); + + @Test + void encryptsEmailWithAuthenticatedRandomNonce() { + var first = security.encryptEmail(COMMENT_ID, "author@example.test"); + var second = security.encryptEmail(COMMENT_ID, "author@example.test"); + + assertNotEquals(Base64.getEncoder().encodeToString(first.nonce()), Base64.getEncoder().encodeToString(second.nonce())); + assertEquals("author@example.test", security.decryptEmail(COMMENT_ID, first)); + assertThrows(IllegalArgumentException.class, () -> security.decryptEmail(UUID.randomUUID(), first)); + } + + @Test + void derivesScopedDigests() { + var today = security.dailyIpHmac("192.0.2.1", LocalDate.of(2026, 8, 22)); + var tomorrow = security.dailyIpHmac("192.0.2.1", LocalDate.of(2026, 8, 23)); + + assertFalse(java.util.Arrays.equals(today, tomorrow)); + assertArrayEquals(security.deleteTokenDigest("token"), security.deleteTokenDigest("token")); + assertTrue(security.contentFingerprint(COMMENT_ID, "body").length == 32); + } + + @Test + void rejectsInvalidKey() { + assertThrows(IllegalArgumentException.class, () -> new CommentSecurityService( + Base64.getEncoder().encodeToString(new byte[16]))); + } +} diff --git a/apps/web/app/utils/publicSite.ts b/apps/web/app/utils/publicSite.ts index af31882..a8d5521 100644 --- a/apps/web/app/utils/publicSite.ts +++ b/apps/web/app/utils/publicSite.ts @@ -7,6 +7,7 @@ export const defaultPublicSite: PublicSite = { description: '极夜观测站', siteUrl: 'http://localhost:3000', authorName: 'Hao', + commentsEnabled: true, } export function usePublicSite() { diff --git a/docs/openapi/public-api.yaml b/docs/openapi/public-api.yaml index ac57f11..9931302 100644 --- a/docs/openapi/public-api.yaml +++ b/docs/openapi/public-api.yaml @@ -137,6 +137,22 @@ paths: $ref: '#/components/responses/ArticleNotFound' '500': $ref: '#/components/responses/InternalError' + /api/v1/public/articles/{slug}/comments/form-context: + get: + operationId: getPublicCommentFormContext + parameters: + - name: slug + in: path + required: true + schema: { type: string, minLength: 1, maxLength: 160 } + responses: + '200': + description: CSRF token and one-time comment form challenge + content: + application/json: + schema: { $ref: '#/components/schemas/CommentFormContext' } + '404': + $ref: '#/components/responses/ArticleNotFound' /api/v1/public/article-previews/{token}: get: operationId: getPublicArticlePreview @@ -686,15 +702,16 @@ components: schemas: SiteResponse: type: object - required: [title, description, siteUrl, authorName] + required: [title, description, siteUrl, authorName, commentsEnabled] properties: title: { type: string } description: { type: string } siteUrl: { type: string, format: uri } authorName: { type: string } + commentsEnabled: { type: boolean } ArticleSummary: type: object - required: [id, slug, title, excerpt, publishedAt, coverImageUrl] + required: [id, slug, title, excerpt, publishedAt, coverImageUrl, commentsEnabled] properties: id: { type: string, format: uuid } slug: { type: string } @@ -702,6 +719,7 @@ components: excerpt: { type: [string, 'null'] } publishedAt: { type: string, format: date-time } coverImageUrl: { type: [string, 'null'], format: uri } + commentsEnabled: { type: boolean } ArticleListResponse: type: object required: [items, page, size, total] @@ -712,7 +730,7 @@ components: total: { type: integer, format: int64 } ArticleResponse: type: object - required: [id, slug, title, excerpt, publishedAt, modifiedAt, markdown, seoTitle, seoDescription, coverImageUrl] + required: [id, slug, title, excerpt, publishedAt, modifiedAt, markdown, seoTitle, seoDescription, coverImageUrl, commentsEnabled] properties: id: { type: string, format: uuid } slug: { type: string } @@ -724,6 +742,15 @@ components: seoTitle: { type: [string, 'null'] } seoDescription: { type: [string, 'null'] } coverImageUrl: { type: [string, 'null'], format: uri } + commentsEnabled: { type: boolean } + CommentFormContext: + type: object + required: [csrfToken, challenge, expiresAt, commentsEnabled] + properties: + csrfToken: { type: string, minLength: 1 } + challenge: { type: string, minLength: 43, maxLength: 43 } + expiresAt: { type: string, format: date-time } + commentsEnabled: { type: boolean } ProblemResponse: type: object required: [code, title, detail, traceId] @@ -883,7 +910,7 @@ components: tagIds: { type: array, items: { type: string, format: uuid } } AdminArticleSummary: type: object - required: [id, title, status, categoryId, updatedAt, version] + required: [id, title, status, categoryId, updatedAt, version, commentsEnabled] properties: id: { type: string, format: uuid } slug: { type: [string, 'null'] } @@ -892,6 +919,7 @@ components: categoryId: { type: [string, 'null'], format: uuid } updatedAt: { type: string, format: date-time } version: { type: integer, format: int64, minimum: 0 } + commentsEnabled: { type: boolean } AdminArticleListResponse: type: object required: [items, page, size, total] @@ -902,7 +930,7 @@ components: total: { type: integer, format: int64 } AdminArticleResponse: type: object - required: [id, title, markdown, status, createdAt, updatedAt, version, tagIds] + required: [id, title, markdown, status, createdAt, updatedAt, version, tagIds, commentsEnabled] properties: id: { type: string, format: uuid } slug: { type: [string, 'null'] } @@ -920,6 +948,7 @@ components: createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } version: { type: integer, format: int64, minimum: 0 } + commentsEnabled: { type: boolean } MediaUploadRequest: type: object required: [mimeType, sizeBytes, width, height, sha256] diff --git "a/docs/\347\254\254\344\270\211\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" "b/docs/\347\254\254\344\270\211\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" deleted file mode 100644 index a58910e..0000000 --- "a/docs/\347\254\254\344\270\211\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" +++ /dev/null @@ -1,226 +0,0 @@ -# HaoBlog 阶段 3:公开阅读完整开发计划 - -## 一、目标与边界 - -阶段 3 将 HaoBlog 从“能够 SSR 展示文章”提升为完整的公开阅读产品: - -- 首页、文章时间线和文章详情形成统一的“极夜观测站”静态阅读体验。 -- 标题、正文、TOC、代码、公式和主要 SEO 信息均存在于 SSR HTML。 -- 支持安全的高级 Markdown:Shiki、KaTeX、Mermaid、表格、任务列表、脚注、提示块和代码复制。 -- 完成 Canonical、Open Graph、Twitter、JSON-LD、RSS 和 Sitemap。 -- 满足 360/768/1280/1600px、无 JavaScript、Save-Data、reduced-motion 和键盘访问。 -- 文章初始 JavaScript 小于 180KB gzip,Performance、SEO、Accessibility 均达到 90。 - -明确不做:评论、工具箱功能、AI/RAG、知识星图、Three.js、终端解析器、音乐、404 游戏、真实部署和域名备案。 - -设计主线采用“仪器,而非装饰”和“可读性永远赢”;首页只完成 SSR 校准首屏与近期观测日志,不留下空的星图占位。 - -S3-08 收口状态(2026-08-22): - -- Java 21.0.11 环境已恢复;API `-DskipITs verify` 49 项和 PostgreSQL/pgvector Failsafe 36 项均通过。 -- Web typecheck、66 项 Vitest、生产构建、客户端预算和完整 8 项 Chromium Playwright 均通过。 -- 文章初始客户端静态闭包为 60,932 bytes gzip;Mermaid 位于异步 chunk,未把 Shiki、编辑器或 Three.js 放入初始闭包。 -- Lighthouse 移动端 Performance、Accessibility、SEO 均为 1.00;Best Practices 0.96 的已知项是 `.test` HTTPS 图片不会真实联网。 -- 独立生产 Compose 项目中的 Caddy、Web、API、PostgreSQL/pgvector 全部健康,资源限制与端口隔离已在运行时读取验证。 -- 工作树中的演示稿和既有未跟踪文件未删除、未提交;本轮未执行提交、推送、部署或云资源变更。 -- 仓库没有 `.openai/hosting.json`,继续使用既有 Nuxt、Spring Boot、Caddy、Compose 架构;Sites 不创建项目或部署。 -- 远端 CI、真实 OSS、真实域名 HTTPS、备案、ACR 和自动部署未在本轮执行,不得宣称上线验收通过。 - -## 二、接口与实现决策 - -### 公开契约 - -- `SiteResponse` 增加 `siteUrl`、`authorName`;由 `HAOBLOG_PUBLIC_BASE_URL` 和 `HAOBLOG_AUTHOR_NAME` 提供,生产必须配置,开发默认 `http://localhost:3000` 和 `Hao`。 -- `ArticleSummary` 只保留列表需要的 `id/slug/title/excerpt/publishedAt/coverImageUrl`,删除 Markdown 和 SEO 正文数据。 -- `ArticleResponse` 增加 `modifiedAt`,并修正 `publishedAt` 为文章真实发布时间,不再用版本快照创建时间代替。 -- ETag 必须包含公开版本 ID、真实发布时间、分页信息和影响响应的媒体数据。 -- 前台页码使用 1 起始,API 继续使用 0 起始;`?page=1` 永久重定向到 `/articles`,非法或越界页返回 404。 -- 增加 `GET /rss.xml` 和 `GET /sitemap.xml`,写入 OpenAPI;RSS 返回最近 20 篇摘要,Sitemap 分批读取全部公开文章。 -- 不新增数据库表;公开数据继续来自不可变 `article_revision` 与文章公开状态。 - -### Markdown SSR 数据流 - -```text -Spring 公开文章 API - → Nuxt /_content/articles/{slug} 公开读模型 - → 服务端 Markdown 解析、安全清洗和代码高亮 - → { article, renderedHtml, toc, hasMermaid } - → 文章页 SSR - → 可选的轻量客户端增强 -``` - -- 使用 `markdown-it`,设置 `html=false`、`linkify=false`、`typographer=false`;选择已修复相关性能安全问题的兼容稳定版本。[markdown-it 官方仓库](https://github.com/markdown-it/markdown-it) -- 使用 `@mdit/plugin-footnote`、`@mdit/plugin-tasklist`、`@mdit/plugin-container` 和 `@mdit/plugin-katex`,不引入整套预设。[Markdown It Plugins](https://mdit-plugins.github.io/) -- Shiki 只存在于 Nitro 服务端,采用细粒度语言/主题包、JavaScript 正则引擎和单例高亮器;未知语言回退为纯文本,不进入文章客户端包。[Shiki 性能建议](https://shiki.style/guide/best-performance) -- 支持 Java、Kotlin、Vue、TypeScript、JavaScript、HTML/XML、CSS、JSON、YAML、SQL、Shell、PowerShell、Dockerfile、Markdown 和纯文本。 -- 代码围栏元数据固定为 ```` ```lang [file.ext] {1,3-5} ````,支持文件名、行号、聚焦行和复制按钮;非法元数据忽略并转义。 -- 文章标题是页面唯一 `h1`;正文标题整体下移一级。TOC 收集渲染后的 `h2/h3`。 -- 标题 ID 使用 NFKC、Unicode 字母数字、小写化和连字符规范化;空标题回退 `section`,重复标题追加 `-2/-3`。 -- 链接只允许站内绝对路径、`https:` 和 `mailto:`;图片只允许 `https:`;禁止 `javascript:`、`data:`、原始 HTML 和任意 Vue 组件。 -- 提示块仅支持 `note/tip/warning/danger`;任务复选框只读;脚注回链必须键盘可达。 -- KaTeX 使用美元与括号两套分隔符,`trust=false`、严格模式、有限 `maxSize/maxExpand`;错误公式回退为可读源码。[KaTeX 安全选项](https://katex.org/docs/options) -- Mermaid 首次进入视口后动态加载,配置 `startOnLoad=false`、`securityLevel=strict`、`maxTextSize=50000`、`maxEdges=500`;无 JS 或渲染失败时保留源码。[Mermaid 安全级别](https://mermaid.js.org/config/schema-docs/config-properties-securitylevel.html) -- 最终 HTML 再经服务端标签、属性、协议和 Shiki/KaTeX 样式白名单清洗。 - -### SEO 与索引策略 - -- 使用可信配置的站点根地址生成 Canonical,不信任转发 Host。 -- 文章 JSON-LD 使用 `BlogPosting`,包含标题、摘要、作者、站点、Canonical、封面、`datePublished`、`dateModified` 和 `inLanguage=zh-CN`;不嵌入 Markdown 正文。 -- `/`、`/articles`、有效分页、`/about` 和公开文章允许索引。 -- `/studio/**`、预览链接、404、尚未实现内容的 `/garden`、`/tools` 设置 `noindex,nofollow`。 -- Sitemap 只包含 `/`、`/articles`、`/about` 和公开文章,不包含分页、预览、Studio 或占位页。 -- RSS/Sitemap 由 Spring Boot 使用 JDK XML API生成,不新增 Java Feed 依赖;Caddy 将两个精确路径转发至 API并保留条件缓存。 -- 保留现有 `og-default.svg`,文章有 HTTPS 封面时优先使用封面,不生成新的社交预览图。 - -## 三、任务划分 - -```mermaid -flowchart LR - S300["S3-00 准入"] --> S301["S3-01 公开读模型"] - S301 --> S302["S3-02 Markdown SSR"] - S302 --> S303["S3-03 TOC 与阅读增强"] - S301 --> S304["S3-04 公共站视觉"] - S301 --> S305["S3-05 SEO 与索引"] - S301 --> S306["S3-06 RSS/Sitemap"] - S303 --> S307["S3-07 响应式与性能"] - S304 --> S307 - S305 --> S307 - S306 --> S307 - S307 --> S308["S3-08 综合验收"] -``` - -### S3-00:冻结阶段 3 基线,2–4 小时 - -- 记录当前分支、HEAD、远端 CI 结果和所有用户改动。 -- 删除 `PublicSiteController` 中明显异常的无关注释;演示稿和无关未跟踪资产不纳入阶段 3,用户明确要求更新的阶段文档按 S3-08 收口范围处理。 -- 恢复 Java 21,并先运行 API 单测确认真实基线。 -- 保存阶段 3 起始 Commit,不提交、不推送,除非用户另行授权。 - -验收:阶段 3 文件与用户资产边界清晰,Java 21、Web 基线和 `git diff --check` 可执行。 - -### S3-01:公开文章读模型与缓存契约,8–12 小时 - -- 修正公开发布时间、分页、投影查询和列表过量字段。 -- 增加 `modifiedAt/siteUrl/authorName` 契约并重新生成客户端。 -- 首页读取最近 5 篇文章;列表增加 1 起始的上一页/下一页导航。 -- 统一 API/Nuxt 的 404、ETag、304 和 60 秒共享缓存行为。 - -验收:草稿、未到期定时文、归档文不可见;列表不返回 Markdown;发布修改在 60 秒内可见;API 类型无手写副本。 - -### S3-02:安全高级 Markdown SSR 引擎,18–26 小时 - -- 建立服务端读模型和单一 Markdown 解析/清洗管线。 -- 实现基础语法、表格、任务列表、脚注、提示块、KaTeX、Shiki 和 Mermaid 源码回退。 -- 对渲染结果设置版本化 ETag;解析或高亮失败只降级相关块,不使文章整体 500。 -- 限制输入大小、公式展开、Mermaid 文本/边数及高亮语言集合。 - -验收:恶意 HTML、协议、公式和图表配置不能越过白名单;Shiki/KaTeX 不进入文章客户端初始包;未知语言和错误公式仍可阅读。 - -### S3-03:文章结构、TOC 与轻量增强,10–14 小时 - -- 桌面采用左信号进度、中间 720px 正文、右侧 TOC 的仪器布局。 -- 移动端将进度压缩为细线,TOC 使用原生 `
` 抽屉。 -- TOC、正文锚点由同一 token 流生成;支持重复中文标题、键盘导航和深链接。 -- 复用一个滚动监听器驱动 Dock 与左侧进度;IntersectionObserver 仅增强当前章节状态。 -- 增加代码复制、Mermaid 懒加载和失败提示;无 JS 时正文、源码与锚点仍完整。 - -验收:页面只有一个 `h1`;TOC/正文 ID 一致;复制和图表增强失败不影响阅读。 - -### S3-04:公共站完整静态视觉,10–14 小时 - -- 完善全局发丝线、正文舞台、48px Dock 和 CSS 径向 INDEX;Dock 仍只有 INDEX、⌘K、AI。 -- 首页实现“信号校准”SSR 首屏和近期观测日志,不加载 Three.js 或空星图占位。 -- 列表实现带封面、时间戳和摘要的单列观测时间线,不转成卡片网格。 -- 文章页保证正文对比度、代码/表格横向滚动、封面尺寸稳定和图片懒加载。 -- ⌘K 与 AI 继续保持明确禁用状态,不提前实现后续功能。 - -验收:360px 无横向页面溢出;触摸不依赖 Hover;设计保持极夜观测站而非通用博客模板。 - -### S3-05:完整 SEO 与索引边界,8–12 小时 - -- 为首页、列表、About 和文章生成 title、description、Canonical、Open Graph、Twitter。 -- 文章输出安全的 `BlogPosting` JSON-LD。 -- 添加全站 RSS ``。 -- 固化分页、旧/非法 slug、404、占位页、Studio 和预览链接的索引策略。 - -验收:SSR 源码中即可看到全部 Meta、Canonical 和 JSON-LD;JSON-LD 可解析且不含不可信脚本终止序列。 - -### S3-06:RSS、Sitemap 与网关路由,8–12 小时 - -- Spring Boot 生成 RSS 2.0 和 Sitemap XML。 -- RSS 固定最近 20 篇,只有标题、摘要、绝对链接、稳定 GUID 和发布时间。 -- Sitemap 使用 500 条一批读取,最多 50,000 URL,正确 XML 转义。 -- 支持 ETag、304 和短时共享缓存;Caddy 精确转发两个 XML 路径。 -- 本地 Nitro 提供轻量代理,使 `localhost:3000/rss.xml` 与生产入口一致。 - -验收:草稿、预览、归档和未来文章不出现在 XML;响应 Content-Type、绝对 URL 和缓存头正确。 - -### S3-07:响应式、无障碍与性能预算,8–12 小时 - -- 覆盖 360/768/1280/1600px、键盘、可见焦点和触摸路径。 -- SSR 根据 `Save-Data` 请求头设置降级状态,客户端再同步 `navigator.connection.saveData`。 -- reduced-motion 禁用扫描、平滑滚动和 TOC 过渡;Save-Data 下 Mermaid 只在用户主动请求后加载。 -- 增加构建产物预算脚本,递归统计文章路由初始 gzip 资源并拒绝 Three.js、编辑器、Shiki、KaTeX JS 和 Mermaid 初始加载。 -- 使用开发依赖中的固定版本 Lighthouse CI 验证文章移动端 Performance、SEO、Accessibility 均不低于 0.90。 - -验收:文章初始 JS ≤180KB gzip;SSR 首屏无布局阻塞;高级渲染没有进入 Studio 或其他无关包。 - -### S3-08:契约、E2E、文档与阶段验收,8–12 小时 - -- 增加高级 Markdown 固定样例,覆盖重复标题、代码元数据、公式、图表、脚注、提示块、表格、任务和恶意输入。 -- Playwright 验证首页、分页、文章、TOC、Meta、JSON-LD、RSS、Sitemap、图片与 404。 -- 使用禁用 JavaScript的浏览器上下文确认正文、TOC、公式、代码和 Mermaid 源码存在。 -- 在生产 Compose 中验证 Caddy、Web、API 路由及缓存。 -- 更新 README、本地启动指南、CI 和 `docs/第三阶段目标任务.md`;只有真实脚本存在后才更新命令清单。 - -验收:本节所有自动化和手工性能门槛通过,阶段 3 之外的功能没有提前实现。 - -实际结果:固定文章、分页、SEO/XML、四档视口、无 JavaScript、Save-Data、reduced-motion、键盘焦点、生产拓扑、客户端预算和 Lighthouse 均已通过本地自动化;修复了客户端分页查询复用、HTML 语言、进度条名称、代码复制名称、INDEX 名称和脚注链接辨识度。阶段三之外的评论、工具箱功能、AI、RAG、Three.js、终端和部署均未实现。 - -总工作量约 80–118 个理想小时;按每周 15–20 小时估算约 4–8 周。选择“完整高级 Markdown”后,不再沿用原计划的一周估算。 - -## 四、测试与完成定义 - -执行顺序: - -1. Java 21 环境确认与 API 单测。 -2. API `-DskipITs verify`。 -3. Web typecheck、Vitest、build。 -4. OpenAPI 重新生成与一致性检查。 -5. PostgreSQL Failsafe 集成测试。 -6. Compose dev/prod 配置与资源边界检查。 -7. 生产 Compose Smoke、Playwright、构建预算和 Lighthouse。 -8. `git diff --check`。 - -关键场景: - -- 实际发布时间与版本修改时间不同。 -- 空站点、单页、最后一页、非法页码和越界分页。 -- 两个相同中文标题、纯符号标题和深链接刷新。 -- 未知代码语言、超长代码、非法聚焦行和复制失败。 -- KaTeX 非法命令、超量展开和 HTML 扩展攻击。 -- Mermaid 指令覆盖配置、超边数、解析错误、Save-Data、无 JS。 -- Markdown 原始 HTML、恶意 URL、图片协议、脚本闭合序列。 -- 草稿、归档、未来定时文和预览链接不进入 SEO/RSS/Sitemap。 -- 360px 长 URL、宽表格、长代码、超长标题和大封面。 -- 文章路由初始包不包含编辑器、Shiki、Mermaid、Three.js。 - -阶段 3 只有在以下条件同时满足时完成: - -- 公共阅读页面形成完整静态极夜体验。 -- 高级 Markdown 安全且 SSR 可读。 -- TOC、SEO、JSON-LD、RSS、Sitemap 全部可验证。 -- 无 JS、Save-Data、reduced-motion、键盘和四档视口通过。 -- JS、Lighthouse 和生产 Compose 门槛通过。 -- API/OpenAPI/生成客户端一致,README 与真实命令同步。 -- 用户未授权的文件未被提交、删除、推送或部署。 - -## 五、假设与默认值 - -- 继续使用阿里云 OSS;阶段 2 文档中的腾讯云 COS 描述视为历史方案。 -- `Hao` 仅是本地默认作者名,生产必须显式设置。 -- `/garden` 和 `/tools` 保留可访问占位页但不索引,等对应阶段完成后再加入 Sitemap。 -- 不支持原始 HTML、任意组件、动态图、SVG Markdown 图片或通用嵌入。 -- 不建立 HTML 数据库缓存;先使用 Nuxt 60 秒有界缓存,只有观测到渲染瓶颈后再持久化。 -- 不引入动画库;本阶段使用 CSS、原生 `
`、IntersectionObserver 和现有 Vue 能力。 -- 建议提交信息均使用中文,但本计划不授权自动提交或推送。 diff --git "a/docs/\351\230\266\346\256\265\344\270\211\345\207\206\345\205\245\346\270\205\345\215\225.md" "b/docs/\351\230\266\346\256\265\344\270\211\345\207\206\345\205\245\346\270\205\345\215\225.md" deleted file mode 100644 index 6c66602..0000000 --- "a/docs/\351\230\266\346\256\265\344\270\211\345\207\206\345\205\245\346\270\205\345\215\225.md" +++ /dev/null @@ -1,95 +0,0 @@ -# 阶段三准入清单:公开阅读 - -阶段三只处理公开阅读体验,不提前实现评论、工具箱、AI、RAG、Three.js、终端或部署功能。 - -## S3-08 本地收口状态 - -2026-08-22 在 Windows 11、Java 21.0.11、Node.js 24.14.0、pnpm 11.16.0、Docker Engine 29.7.2 和 Compose 5.3.1 下完成本地验收。远端 CI、真实 OSS、真实域名 HTTPS、备案、ACR 和自动部署未在本轮执行,不能据本清单宣称通过。 - -### S3-01 公开数据与页面基线 - -- [x] `/api/v1/public/site`、文章列表和文章详情 DTO 的缓存头、ETag 和分页边界可验证。 -- [x] 首页在 SSR HTML 中展示站点信息和最近公开文章入口。 -- [x] 列表页展示发布时间、摘要、HTTPS 封面、空状态和真实分页。 -- [x] 文章页读取不可变发布快照;草稿、归档和未来定时文不可见。 - -### S3-02 高级 Markdown 与阅读结构 - -- [x] S3-08 固定验收文章覆盖重复中文标题、多语言代码与围栏元数据、KaTeX、Mermaid、表格、任务列表、脚注和四种提示块。 -- [x] 固定文章同时覆盖 HTTPS 图片、原始恶意 HTML、`javascript:` URL、HTTP URL 和 HTTP 图片。 -- [x] h2/h3 TOC 从安全渲染结果生成,重复标题锚点稳定去重。 -- [x] Shiki 与 KaTeX 在 SSR 阶段生成可读 HTML;Mermaid 仅交互异步加载,失败时保留源码。 -- [x] 无 JavaScript 时标题、正文、TOC、公式、代码和 Mermaid 源码可读。 - -### S3-03 SEO 与订阅入口 - -- [x] 输出 title、description、Canonical、Open Graph 和 Twitter Meta。 -- [x] 输出可解析的 `BlogPosting` JSON-LD,且正文不依赖客户端执行。 -- [x] `/rss.xml` 与 `/sitemap.xml` 返回正确 XML 类型、绝对 URL、ETag 和 304。 -- [x] 分页 canonical、非法页码、越界页、文章 404、草稿和预览链接索引边界可验证。 - -### S3-04 响应式、无障碍与性能 - -- [x] 360/768/1280/1600px 公共路由无页面横向溢出。 -- [x] 无 JavaScript、`Save-Data: on`、`prefers-reduced-motion` 和键盘可见焦点通过 Playwright。 -- [x] 文章初始客户端静态闭包为 60,932 bytes gzip,小于 180 KB;Mermaid 位于异步 chunk,Shiki/编辑器/Three.js 不在初始闭包。 -- [x] Lighthouse 移动端:Performance 1.00、Accessibility 1.00、SEO 1.00,均不低于 0.90。 -- [x] Lighthouse Best Practices 为 0.96;唯一已知项是固定验收用 `https://cdn.example.test/...` 图片不会真实联网,不将其冒充为已上线 CDN。 - -### S3-05 生产拓扑 - -- [x] 生产 Compose 的 Caddy、Web、API、PostgreSQL/pgvector 均为 `healthy`。 -- [x] 运行时内存限制分别为 64/320/640/480 MiB,CPU 分别为 0.2/0.6/1.25/0.8,PID 上限分别为 64/128/256/128。 -- [x] 只有 Caddy 暴露宿主机 80/443;Web 3000、API 8080、PostgreSQL 5432 仅在 Compose 网络内可见。 -- [x] Smoke 与完整 Chromium Playwright E2E 通过。 - -## 阶段三验收命令 - -依赖、代码、契约和配置: - -```powershell -corepack pnpm install --frozen-lockfile -git diff --check -Push-Location apps/api -.\mvnw.cmd -DskipITs verify -.\mvnw.cmd failsafe:integration-test failsafe:verify -Pop-Location -corepack pnpm --dir apps/web typecheck -corepack pnpm --dir apps/web test -corepack pnpm --dir apps/web build -corepack pnpm --filter @haoblog/api-client generate -corepack pnpm --filter @haoblog/api-client check -git diff --exit-code -- packages/api-client/src/generated.ts -docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.dev.yml config --quiet -docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml config --quiet -corepack pnpm compose:verify -corepack pnpm web:budget -``` - -生产编排浏览器验收使用测试专用项目名,避免覆盖开发 Compose: - -```powershell -docker build -f apps/api/Dockerfile -t haoblog-api:ci . -docker build -f apps/web/Dockerfile -t haoblog-web:ci . -$env:HAOBLOG_PUBLIC_BASE_URL = 'http://localhost' -docker compose -p haoblog-s3-acceptance --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml up -d --wait --wait-timeout 180 -$env:HAOBLOG_BASE_URL = 'http://localhost' -corepack pnpm smoke -corepack pnpm --dir apps/web exec playwright install chromium -$env:CI = 'true' -corepack pnpm --dir apps/web e2e -$env:LHCI_EXISTING_SERVER = 'true' -$env:LHCI_BASE_URL = 'http://localhost' -corepack pnpm web:lighthouse -docker compose -p haoblog-s3-acceptance --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml down -v --remove-orphans -``` - -最后一条命令会删除测试项目的 PostgreSQL 与 Caddy volumes;不要把项目名改成正在使用的开发或生产项目。仓库示例凭据仅用于离线、本地和 CI 验收。 - -## 阶段边界与阶段四准入 - -- 评论、审核和限频:阶段四。 -- 工具箱的功能实现:后续独立切片,不在阶段三提前实现。 -- 百炼 Chat、Embedding、SSE 和 RAG:阶段五及以后。 -- 备案、真实域名 HTTPS、真实 OSS、ACR 和自动部署:部署阶段另行验收。 -- 阶段三的本地准入条件已经满足;阶段四可以开始,但应先等待当前改动经正常代码审查和远端 CI 通过。公网部署事项不是阶段四业务开发的阻塞条件,也不能据此标记为已上线。 diff --git a/infra/compose/.env.ci.example b/infra/compose/.env.ci.example index c3ee038..101007e 100644 --- a/infra/compose/.env.ci.example +++ b/infra/compose/.env.ci.example @@ -11,6 +11,7 @@ HAOBLOG_ADMIN_USERNAME=admin HAOBLOG_ADMIN_PASSWORD_HASH='$2a$10$0V.Xs7CLOUYSekm7RKq3Z.iY76KUan/Xbeu5vjmLpX.sVd4pcFpIu' HAOBLOG_PUBLIC_BASE_URL=http://localhost:3000 HAOBLOG_AUTHOR_NAME=Hao +HAOBLOG_COMMENT_SECURITY_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= SPRING_PROFILES_ACTIVE=dev HAOBLOG_SEED_ENABLED=true HAOBLOG_CONTENT_SCHEDULING_FIXED_DELAY_MS=1000 diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 56924ab..75745cc 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -84,6 +84,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/public/articles/{slug}/comments/form-context": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicCommentFormContext"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/public/article-previews/{token}": { parameters: { query?: never; @@ -444,6 +460,7 @@ export interface components { /** Format: uri */ siteUrl: string; authorName: string; + commentsEnabled: boolean; }; ArticleSummary: { /** Format: uuid */ @@ -455,6 +472,7 @@ export interface components { publishedAt: string; /** Format: uri */ coverImageUrl: string | null; + commentsEnabled: boolean; }; ArticleListResponse: { items: components["schemas"]["ArticleSummary"][]; @@ -478,6 +496,14 @@ export interface components { seoDescription: string | null; /** Format: uri */ coverImageUrl: string | null; + commentsEnabled: boolean; + }; + CommentFormContext: { + csrfToken: string; + challenge: string; + /** Format: date-time */ + expiresAt: string; + commentsEnabled: boolean; }; ProblemResponse: { code: string; @@ -648,6 +674,7 @@ export interface components { updatedAt: string; /** Format: int64 */ version: number; + commentsEnabled: boolean; }; AdminArticleListResponse: { items: components["schemas"]["AdminArticleSummary"][]; @@ -681,6 +708,7 @@ export interface components { updatedAt: string; /** Format: int64 */ version: number; + commentsEnabled: boolean; }; MediaUploadRequest: { /** @enum {string} */ @@ -1078,6 +1106,29 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + getPublicCommentFormContext: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSRF token and one-time comment form challenge */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentFormContext"]; + }; + }; + 404: components["responses"]["ArticleNotFound"]; + }; + }; getPublicArticlePreview: { parameters: { query?: never; From a51e09fe3a08e5ad7b52f1340770fa36d44adab0 Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sat, 22 Aug 2026 22:35:10 +0800 Subject: [PATCH 02/15] =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=9B=9B=EF=BC=9A?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E8=AF=84=E8=AE=BA=E5=AE=89=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=20Outbox=20=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + apps/api/pom.xml | 14 ++- .../application/CommentSecurityService.java | 47 ++++--- .../io/haoblog/comment/domain/Comment.java | 35 ++++-- .../web/PublicCommentContextController.java | 8 +- .../application/ArticleCommentLookup.java | 11 ++ .../content/application/ArticleService.java | 8 +- ...__comment_security_and_outbox_payloads.sql | 49 ++++++++ .../java/io/haoblog/ArchitectureTest.java | 8 +- .../test/java/io/haoblog/ContentModelIT.java | 116 +++++++++++++++++- .../CommentSecurityServiceTest.java | 10 ++ 11 files changed, 265 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/main/java/io/haoblog/content/application/ArticleCommentLookup.java create mode 100644 apps/api/src/main/resources/db/migration/V11__comment_security_and_outbox_payloads.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab8b3ef..ffce40e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/api/pom.xml b/apps/api/pom.xml index 1d54881..ca40e68 100644 --- a/apps/api/pom.xml +++ b/apps/api/pom.xml @@ -49,12 +49,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/CommentSecurityService.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java index fd396c3..75eb104 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java @@ -19,6 +19,7 @@ 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; @@ -26,25 +27,20 @@ public class CommentSecurityService { public CommentSecurityService(@Value("${haoblog.comment.security-key:}") String encodedKey) { if (encodedKey == null || encodedKey.isBlank()) { - byte[] masterKey = new byte[KEY_BYTES]; - randomize(masterKey); - this.emailKey = derive(masterKey, "email-v1"); - this.ipKey = derive(masterKey, "ip-v1"); - this.challengeKey = derive(masterKey, "challenge-v1"); - } else { - byte[] masterKey; - try { - masterKey = Base64.getDecoder().decode(encodedKey); - } 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"); + 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) { @@ -55,13 +51,17 @@ public EmailCiphertext encryptEmail(UUID commentId, String email) { 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(nonce, cipher.doFinal(email.trim().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"), @@ -112,12 +112,11 @@ private static byte[] digest(String value) { } } - private static void randomize(byte[] value) { - new SecureRandom().nextBytes(value); - } - - public record EmailCiphertext(byte[] nonce, byte[] ciphertext) { + 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(); } 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 index e640aa3..43647ad 100644 --- a/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java +++ b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java @@ -10,6 +10,7 @@ import jakarta.persistence.Version; import java.time.Instant; +import java.time.LocalDate; import java.util.Arrays; import java.util.UUID; @@ -28,19 +29,23 @@ public class Comment { 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 body; + 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 = "moderated_by") - private UUID moderatedBy; + @Column(name = "moderator_id") + private UUID moderatorId; @Column(name = "moderation_reason", length = 600) private String moderationReason; @Column(name = "created_at", nullable = false) @@ -58,15 +63,18 @@ public class Comment { protected Comment() {} public Comment(UUID articleId, UUID parentId, String nickname, byte[] emailCiphertext, - byte[] emailNonce, String body, byte[] ipHmac, byte[] contentFingerprint, - byte[] deleteTokenDigest, Instant now) { + 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.body = body; + this.emailKeyVersion = emailKeyVersion; + this.content = content; this.ipHmac = copy(ipHmac); + this.ipHmacDate = ipHmacDate; this.contentFingerprint = copy(contentFingerprint); this.deleteTokenDigest = copy(deleteTokenDigest); this.createdAt = now; @@ -79,12 +87,14 @@ public Comment(UUID articleId, UUID parentId, String nickname, byte[] emailCiphe public String getNickname() { return nickname; } public byte[] getEmailCiphertext() { return copy(emailCiphertext); } public byte[] getEmailNonce() { return copy(emailNonce); } - public String getBody() { return body; } + 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 getModeratedBy() { return moderatedBy; } + public UUID getModeratorId() { return moderatorId; } public String getModerationReason() { return moderationReason; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } @@ -92,6 +102,15 @@ public Comment(UUID articleId, UUID parentId, String nickname, byte[] emailCiphe 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; + } + 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/web/PublicCommentContextController.java b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java index 9dfcd1a..8e140b2 100644 --- a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java @@ -1,7 +1,7 @@ package io.haoblog.comment.web; import io.haoblog.comment.application.CommentChallengeService; -import io.haoblog.content.application.ArticleService; +import io.haoblog.content.application.ArticleCommentLookup; import io.haoblog.shared.web.ProblemResponse; import io.haoblog.site.application.SiteService; import org.slf4j.MDC; @@ -19,11 +19,11 @@ @RestController @RequestMapping("/api/v1/public/articles") public class PublicCommentContextController { - private final ArticleService articles; + private final ArticleCommentLookup articles; private final SiteService site; private final CommentChallengeService challenges; - public PublicCommentContextController(ArticleService articles, SiteService site, CommentChallengeService challenges) { + public PublicCommentContextController(ArticleCommentLookup articles, SiteService site, CommentChallengeService challenges) { this.articles = articles; this.site = site; this.challenges = challenges; @@ -31,7 +31,7 @@ public PublicCommentContextController(ArticleService articles, SiteService site, @GetMapping("/{slug}/comments/form-context") public FormContext formContext(@PathVariable String slug, CsrfToken csrfToken) { - var article = articles.findPublicBySlug(slug).orElseThrow(() -> new ArticleNotFoundException(slug)); + var article = articles.findPublicCommentTarget(slug).orElseThrow(() -> new ArticleNotFoundException(slug)); var issued = challenges.issue(article.articleId()); return new FormContext(csrfToken.getToken(), issued.token(), issued.expiresAt(), site.get().commentsEnabled() && article.commentsEnabled()); 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..87d4c5d --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/content/application/ArticleCommentLookup.java @@ -0,0 +1,11 @@ +package io.haoblog.content.application; + +import java.util.Optional; +import java.util.UUID; + +/** 为 comment 模块公开文章身份和评论开关,不暴露 content 实体或仓储。 */ +public interface ArticleCommentLookup { + Optional findPublicCommentTarget(String slug); + + record Target(UUID articleId, boolean commentsEnabled) {} +} 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 8a75908..202597f 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 @@ -19,7 +19,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,6 +42,12 @@ 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())); + } + 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)); 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/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 795d677..53ea8c6 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,11 +52,12 @@ static void database(DynamicPropertyRegistry registry) { @Autowired JdbcTemplate jdbc; @Autowired ArticleRepository articles; + @Autowired CommentRepository comments; @Autowired EntityManagerFactory entityManagerFactory; @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(10, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(11, 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", "comment")) { assertEquals(1, jdbc.queryForObject( @@ -66,6 +72,24 @@ void migratesAllVersionsAndCreatesContentTables() { "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", "outbox_comment_created_uq")) { + 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 @@ -170,7 +194,7 @@ void articleRevisionCannotBeUpdatedOrDeleted() { } @Test - void outboxPayloadIsRestrictedToPublicationEnvelope() { + void outboxPayloadsAreRestrictedAndDeduplicatedByEventType() { Instant now = Instant.now(); Timestamp timestamp = Timestamp.from(now); UUID eventId = UUID.randomUUID(); @@ -190,6 +214,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/comment/application/CommentSecurityServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java index 5d39a53..4b82463 100644 --- a/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentSecurityServiceTest.java @@ -25,14 +25,23 @@ void encryptsEmailWithAuthenticatedRandomNonce() { var first = security.encryptEmail(COMMENT_ID, "author@example.test"); var second = security.encryptEmail(COMMENT_ID, "author@example.test"); + assertEquals(1, first.keyVersion()); assertNotEquals(Base64.getEncoder().encodeToString(first.nonce()), Base64.getEncoder().encodeToString(second.nonce())); + assertNotEquals(Base64.getEncoder().encodeToString(first.ciphertext()), Base64.getEncoder().encodeToString(second.ciphertext())); + assertFalse(new String(first.ciphertext(), StandardCharsets.UTF_8).contains("author@example.test")); assertEquals("author@example.test", security.decryptEmail(COMMENT_ID, first)); assertThrows(IllegalArgumentException.class, () -> security.decryptEmail(UUID.randomUUID(), first)); + + byte[] tampered = first.ciphertext(); + tampered[tampered.length - 1] ^= 1; + assertThrows(IllegalArgumentException.class, () -> security.decryptEmail( + COMMENT_ID, new CommentSecurityService.EmailCiphertext(1, first.nonce(), tampered))); } @Test void derivesScopedDigests() { var today = security.dailyIpHmac("192.0.2.1", LocalDate.of(2026, 8, 22)); + assertArrayEquals(today, security.dailyIpHmac("192.0.2.1", LocalDate.of(2026, 8, 22))); var tomorrow = security.dailyIpHmac("192.0.2.1", LocalDate.of(2026, 8, 23)); assertFalse(java.util.Arrays.equals(today, tomorrow)); @@ -42,6 +51,7 @@ void derivesScopedDigests() { @Test void rejectsInvalidKey() { + assertThrows(IllegalArgumentException.class, () -> new CommentSecurityService(" ")); assertThrows(IllegalArgumentException.class, () -> new CommentSecurityService( Base64.getEncoder().encodeToString(new byte[16]))); } From d9dac8b2648049f7962b5e40bbd8ee60bfaea18c Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sat, 22 Aug 2026 23:17:53 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=9B=9B=EF=BC=9A?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=85=AC=E5=85=B1=E8=AF=84=E8=AE=BA=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CommentChallengeService.java | 32 ++- .../CommentRateLimitException.java | 12 + .../application/CommentRateLimiter.java | 72 +++++ .../application/CommentSecurityService.java | 12 + .../comment/application/CommentService.java | 248 ++++++++++++++++++ .../io/haoblog/comment/domain/Comment.java | 23 ++ .../persistence/CommentRepository.java | 15 +- .../web/PublicCommentContextController.java | 26 +- .../comment/web/PublicCommentController.java | 94 +++++++ .../shared/web/GlobalExceptionHandler.java | 10 +- .../V12__comment_fingerprint_uniqueness.sql | 2 + .../test/java/io/haoblog/ContentModelIT.java | 5 +- .../CommentChallengeServiceTest.java | 13 + .../application/CommentRateLimiterTest.java | 49 ++++ .../application/CommentServiceTest.java | 183 +++++++++++++ docs/openapi/public-api.yaml | 116 ++++++++ infra/compose/compose.prod.yml | 1 + packages/api-client/src/generated.ts | 184 +++++++++++++ 18 files changed, 1079 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimitException.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/application/CommentRateLimiter.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/application/CommentService.java create mode 100644 apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java create mode 100644 apps/api/src/main/resources/db/migration/V12__comment_fingerprint_uniqueness.sql create mode 100644 apps/api/src/test/java/io/haoblog/comment/application/CommentRateLimiterTest.java create mode 100644 apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java 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 index 2f5746e..8fd22d2 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentChallengeService.java @@ -36,17 +36,27 @@ public IssuedChallenge issue(UUID articleId) { } public boolean consume(UUID articleId, String token) { - if (token == null || token.isBlank()) return false; - Challenge challenge = challenges.getIfPresent(token); - if (challenge == null) return false; + 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(); - if (!challenge.articleId().equals(articleId) - || now.isBefore(challenge.issuedAt().plus(MINIMUM_FILL_TIME)) - || !challenge.expiresAt().isAfter(now)) { - return false; - } - challenges.invalidate(token); - return true; + 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() { @@ -56,5 +66,7 @@ long cacheSize() { 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/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 index 75eb104..2288653 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentSecurityService.java @@ -90,6 +90,18 @@ public String challengeToken(UUID articleId, java.time.Instant issuedAt) { 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); } 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..38f368f --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java @@ -0,0 +1,248 @@ +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.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.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.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 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 Clock clock; + + public CommentService(CommentRepository comments, OutboxEventRepository outbox, + ArticleCommentLookup articles, SiteService site, + CommentSecurityService security, CommentChallengeService challenges, + CommentRateLimiter rateLimiter, Clock clock) { + this.comments = comments; + this.outbox = outbox; + this.articles = articles; + this.site = site; + this.security = security; + this.challenges = challenges; + this.rateLimiter = rateLimiter; + this.clock = clock; + } + + @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) {} + + 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 index 43647ad..126fd8a 100644 --- a/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java +++ b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java @@ -81,6 +81,16 @@ public Comment(UUID articleId, UUID parentId, String nickname, byte[] emailCiphe 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; } @@ -111,6 +121,19 @@ public void moderate(CommentStatus status, UUID moderatorId, String reason, Inst 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/persistence/CommentRepository.java b/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java index 15ca80f..28114f6 100644 --- a/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java +++ b/apps/api/src/main/java/io/haoblog/comment/persistence/CommentRepository.java @@ -6,8 +6,21 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; import java.util.UUID; public interface CommentRepository extends JpaRepository { - Page findByArticleIdAndStatusAndParentIdIsNull(UUID articleId, CommentStatus status, 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/PublicCommentContextController.java b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java index 8e140b2..1c11d6d 100644 --- a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentContextController.java @@ -6,7 +6,11 @@ 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; @@ -15,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController; import java.time.Instant; +import java.time.Duration; @RestController @RequestMapping("/api/v1/public/articles") @@ -22,21 +27,38 @@ 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) { + 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) { + 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) 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/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/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/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index 53ea8c6..cfc63b8 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -57,7 +57,7 @@ static void database(DynamicPropertyRegistry registry) { @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(11, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(12, 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", "comment")) { assertEquals(1, jdbc.queryForObject( @@ -81,7 +81,8 @@ void migratesAllVersionsAndCreatesContentTables() { "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", "outbox_comment_created_uq")) { + for (String index : List.of("comment_article_status_created_idx", "comment_parent_created_idx", + "comment_article_fingerprint_uq", "outbox_comment_created_uq")) { assertEquals(1, jdbc.queryForObject( "SELECT count(*) FROM pg_indexes WHERE schemaname='public' AND indexname=?", Integer.class, index)); } diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java index 643eb43..e108cd1 100644 --- a/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentChallengeServiceTest.java @@ -30,6 +30,19 @@ void requiresMinimumAgeAndConsumesOnce() { assertFalse(challenges.consume(articleId, issued.token())); } + @Test + void rejectsExpiredChallenge() { + var clock = new MutableClock(Instant.parse("2026-08-22T00:00:00Z")); + var security = new CommentSecurityService(Base64.getEncoder().encodeToString( + "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8))); + var challenges = new CommentChallengeService(clock, security); + UUID articleId = UUID.randomUUID(); + var issued = challenges.issue(articleId); + + clock.advance(Duration.ofHours(2).plusSeconds(1)); + assertFalse(challenges.consume(articleId, issued.token())); + } + private static final class MutableClock extends Clock { private Instant instant; diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentRateLimiterTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentRateLimiterTest.java new file mode 100644 index 0000000..ac651f5 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentRateLimiterTest.java @@ -0,0 +1,49 @@ +package io.haoblog.comment.application; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class CommentRateLimiterTest { + private final AtomicReference now = new AtomicReference<>(Instant.parse("2026-08-22T00:00:00Z")); + private final Clock clock = new Clock() { + @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId zone) { return this; } + @Override public Instant instant() { return now.get(); } + }; + + @Test + void limitsThreeInTenMinutesThenAllowsAfterWindow() { + CommentRateLimiter limiter = new CommentRateLimiter(clock); + assertTrue(limiter.checkAndRecord("visitor:a").allowed()); + assertTrue(limiter.checkAndRecord("visitor:a").allowed()); + assertTrue(limiter.checkAndRecord("visitor:a").allowed()); + var limited = limiter.checkAndRecord("visitor:a"); + assertFalse(limited.allowed()); + assertTrue(limited.retryAfterSeconds() > 0); + + now.updateAndGet(value -> value.plus(Duration.ofMinutes(10).plusSeconds(1))); + assertTrue(limiter.checkAndRecord("visitor:a").allowed()); + } + + @Test + void limitsTenPerUtcDayAndBoundsCache() { + CommentRateLimiter limiter = new CommentRateLimiter(clock); + for (int i = 0; i < 10; i++) { + assertTrue(limiter.checkAndRecord("ip:a").allowed()); + now.updateAndGet(value -> value.plus(Duration.ofMinutes(11))); + } + var limited = limiter.checkAndRecord("ip:a"); + assertFalse(limited.allowed()); + assertTrue(limited.retryAfterSeconds() > 0); + + for (int i = 0; i < 4097; i++) limiter.checkAndRecord("source-" + i); + assertEquals(4096, limiter.cacheSize()); + } +} diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java new file mode 100644 index 0000000..f8c9939 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java @@ -0,0 +1,183 @@ +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.shared.outbox.OutboxEventRepository; +import io.haoblog.shared.web.ProblemException; +import io.haoblog.site.application.SiteService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + private static final UUID ARTICLE_ID = UUID.randomUUID(); + + @Mock CommentRepository comments; + @Mock OutboxEventRepository outbox; + @Mock ArticleCommentLookup articles; + @Mock SiteService site; + private MutableClock clock; + private CommentSecurityService security; + private CommentChallengeService challenges; + private CommentRateLimiter limiter; + private CommentService service; + + @BeforeEach + void setUp() { + clock = new MutableClock(Instant.parse("2026-08-22T00:00:00Z")); + security = new CommentSecurityService(Base64.getEncoder().encodeToString( + "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8))); + challenges = new CommentChallengeService(clock, security); + limiter = new CommentRateLimiter(clock); + service = new CommentService(comments, outbox, articles, site, security, challenges, limiter, clock); + lenient().when(articles.findPublicCommentTarget("post")) + .thenReturn(Optional.of(new ArticleCommentLookup.Target(ARTICLE_ID, true))); + lenient().when(site.get()).thenReturn(new SiteService.SiteResult("HaoBlog", "", "https://example.test", "Hao", true)); + } + + @Test + void createsPendingCommentAndOutboxInOneApplicationFlow() { + var challenge = challenges.issue(ARTICLE_ID); + clock.advance(Duration.ofSeconds(3)); + var result = service.create("post", command(challenge.token(), "hello"), "A".repeat(43), "192.0.2.1"); + + assertEquals(CommentStatus.PENDING, result.status()); + assertNotNull(result.id()); + assertNotNull(result.deleteToken()); + verify(comments).save(any(Comment.class)); + verify(outbox).save(any()); + } + + @Test + void rejectsUnsafeContentAndFakeParent() { + var htmlChallenge = challenges.issue(ARTICLE_ID); + assertThrows(ProblemException.class, () -> service.create("post", command(htmlChallenge.token(), "x"), null, "192.0.2.1")); + + var protocolChallenge = challenges.issue(ARTICLE_ID); + var protocol = assertThrows(ProblemException.class, () -> service.create("post", + command(protocolChallenge.token(), "see javascript:alert(1)"), null, "192.0.2.1")); + assertEquals("COMMENT_LINK_PROTOCOL_INVALID", protocol.getCode()); + + var longChallenge = challenges.issue(ARTICLE_ID); + var longContent = "x".repeat(2001); + var tooLong = assertThrows(ProblemException.class, () -> service.create("post", + command(longChallenge.token(), longContent), null, "192.0.2.1")); + assertEquals("COMMENT_CONTENT_INVALID", tooLong.getCode()); + + var parentChallenge = challenges.issue(ARTICLE_ID); + clock.advance(Duration.ofSeconds(3)); + var exception = assertThrows(ProblemException.class, () -> service.create("post", + new CommentService.CreateCommand("Hao", null, "reply", UUID.randomUUID(), parentChallenge.token(), null, null), + null, "192.0.2.1")); + assertEquals("COMMENT_PARENT_INVALID", exception.getCode()); + } + + @Test + void honeypotIsAcceptedWithoutPersistenceAndDuplicateIsAConflict() { + var honeypotChallenge = challenges.issue(ARTICLE_ID); + clock.advance(Duration.ofSeconds(3)); + var accepted = service.create("post", new CommentService.CreateCommand( + "Hao", null, "normal text", null, honeypotChallenge.token(), "filled", null), null, "192.0.2.1"); + assertEquals(CommentStatus.PENDING, accepted.status()); + assertNull(accepted.id()); + verifyNoInteractions(comments, outbox); + + var duplicateChallenge = challenges.issue(ARTICLE_ID); + clock.advance(Duration.ofSeconds(3)); + when(comments.existsByArticleIdAndContentFingerprint(eq(ARTICLE_ID), any(byte[].class))).thenReturn(true); + var duplicate = assertThrows(ProblemException.class, () -> service.create("post", + command(duplicateChallenge.token(), "duplicate"), null, "192.0.2.1")); + assertEquals("COMMENT_DUPLICATE", duplicate.getCode()); + } + + @Test + void globalAndArticleSwitchesHideOrRejectComments() { + when(site.get()).thenReturn(new SiteService.SiteResult("HaoBlog", "", "https://example.test", "Hao", false)); + var page = service.list("post", 0, 20); + assertTrue(page.items().isEmpty()); + verifyNoInteractions(comments); + + var challenge = challenges.issue(ARTICLE_ID); + clock.advance(Duration.ofSeconds(3)); + var disabled = assertThrows(ProblemException.class, () -> service.create("post", command(challenge.token(), "nope"), null, "192.0.2.1")); + assertEquals("COMMENTING_DISABLED", disabled.getCode()); + } + + @Test + void listsTopLevelCommentsAndLoadsRepliesWithOneBatchQuery() { + Comment top = comment("top", null); + top.moderate(CommentStatus.APPROVED, null, null, clock.instant()); + Comment reply = comment("reply", top.getId()); + reply.moderate(CommentStatus.APPROVED, null, null, clock.instant()); + when(comments.findByArticleIdAndStatusAndParentIdIsNullOrderByCreatedAtAscIdAsc( + eq(ARTICLE_ID), eq(CommentStatus.APPROVED), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(top), PageRequest.of(0, 20), 1)); + when(comments.findByArticleIdAndStatusAndParentIdInOrderByCreatedAtAscIdAsc( + eq(ARTICLE_ID), eq(CommentStatus.APPROVED), eq(List.of(top.getId())))) + .thenReturn(List.of(reply)); + + var page = service.list("post", 0, 20); + assertEquals(1, page.items().size()); + assertEquals(1, page.items().getFirst().replies().size()); + verify(comments, times(1)).findByArticleIdAndStatusAndParentIdInOrderByCreatedAtAscIdAsc( + eq(ARTICLE_ID), eq(CommentStatus.APPROVED), eq(List.of(top.getId()))); + } + + @Test + void deletesWithTokenAndClearsUserContentButKeepsAuditStatus() { + String token = security.newDeleteToken(); + Instant now = clock.instant(); + Comment comment = new Comment(ARTICLE_ID, null, "Hao", security.encryptEmail(UUID.randomUUID(), "a@b.test").ciphertext(), + null, null, "body", new byte[32], now.atZone(ZoneId.of("UTC")).toLocalDate(), + new byte[32], security.deleteTokenDigest(token), now); + // 仅验证删除路径的擦除;实体保存时邮箱 nonce/key version 由创建路径提供。 + when(comments.findById(comment.getId())).thenReturn(Optional.of(comment)); + assertThrows(ProblemException.class, () -> service.delete(comment.getId(), "wrong-token")); + service.delete(comment.getId(), token); + assertEquals(CommentStatus.USER_DELETED, comment.getStatus()); + assertEquals("", comment.getNickname()); + assertEquals("", comment.getContent()); + assertNull(comment.getEmailCiphertext()); + assertThrows(ProblemException.class, () -> service.delete(comment.getId(), token)); + } + + private CommentService.CreateCommand command(String challenge, String content) { + return new CommentService.CreateCommand("Hao", null, content, null, challenge, null, null); + } + + private Comment comment(String content, UUID parentId) { + return new Comment(ARTICLE_ID, parentId, "Hao", null, null, null, content, new byte[32], + clock.instant().atZone(ZoneId.of("UTC")).toLocalDate(), new byte[32], new byte[32], clock.instant()); + } + + private static final class MutableClock extends Clock { + private Instant current; + private MutableClock(Instant current) { this.current = current; } + private void advance(Duration duration) { current = current.plus(duration); } + @Override public ZoneId getZone() { return ZoneId.of("UTC"); } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return current; } + } +} diff --git a/docs/openapi/public-api.yaml b/docs/openapi/public-api.yaml index 9931302..df53701 100644 --- a/docs/openapi/public-api.yaml +++ b/docs/openapi/public-api.yaml @@ -137,6 +137,55 @@ paths: $ref: '#/components/responses/ArticleNotFound' '500': $ref: '#/components/responses/InternalError' + /api/v1/public/articles/{slug}/comments: + get: + operationId: listPublicArticleComments + parameters: + - name: slug + in: path + required: true + schema: { type: string, minLength: 1, maxLength: 160 } + - name: page + in: query + schema: { type: integer, minimum: 0, default: 0 } + - name: size + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 20 } + responses: + '200': + description: Approved top-level comments and first-level replies + content: + application/json: + schema: { $ref: '#/components/schemas/CommentPageResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/ArticleNotFound' } + '500': { $ref: '#/components/responses/InternalError' } + post: + operationId: createPublicArticleComment + parameters: + - name: slug + in: path + required: true + schema: { type: string, minLength: 1, maxLength: 160 } + - $ref: '#/components/parameters/CsrfHeader' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CommentCreateRequest' } + responses: + '202': + description: Comment accepted for moderation + content: + application/json: + schema: { $ref: '#/components/schemas/CommentSubmissionResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '403': + description: Invalid CSRF token or delete credential + content: { application/problem+json: { schema: { $ref: '#/components/schemas/ProblemResponse' } } } + '404': { $ref: '#/components/responses/ArticleNotFound' } + '409': { $ref: '#/components/responses/CommentConflict' } + '429': { $ref: '#/components/responses/CommentRateLimited' } /api/v1/public/articles/{slug}/comments/form-context: get: operationId: getPublicCommentFormContext @@ -153,6 +202,18 @@ paths: schema: { $ref: '#/components/schemas/CommentFormContext' } '404': $ref: '#/components/responses/ArticleNotFound' + /api/v1/public/comments/{id}: + delete: + operationId: deletePublicComment + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + - $ref: '#/components/parameters/CsrfHeader' + - $ref: '#/components/parameters/CommentDeleteToken' + responses: + '204': { description: Comment content deleted } + '403': { $ref: '#/components/responses/CsrfInvalid' } + '404': { $ref: '#/components/responses/ResourceNotFound' } + '409': { $ref: '#/components/responses/CommentConflict' } /api/v1/public/article-previews/{token}: get: operationId: getPublicArticlePreview @@ -619,6 +680,11 @@ components: in: header required: true schema: { type: string } + CommentDeleteToken: + name: X-Comment-Delete-Token + in: header + required: true + schema: { type: string, minLength: 43, maxLength: 43 } IfNoneMatch: name: If-None-Match in: header @@ -655,6 +721,20 @@ components: content: application/problem+json: schema: { $ref: '#/components/schemas/ProblemResponse' } + CommentConflict: + description: Comment submission conflicts with current state or duplicate content + content: + application/problem+json: + schema: { $ref: '#/components/schemas/ProblemResponse' } + CommentRateLimited: + description: Comment rate limit exceeded + headers: + Retry-After: + description: Seconds until another comment may be submitted + schema: { type: integer, minimum: 1 } + content: + application/problem+json: + schema: { $ref: '#/components/schemas/ProblemResponse' } ArticleVersionConflict: description: Article working copy version is stale content: @@ -751,6 +831,42 @@ components: challenge: { type: string, minLength: 43, maxLength: 43 } expiresAt: { type: string, format: date-time } commentsEnabled: { type: boolean } + CommentCreateRequest: + type: object + required: [nickname, content, challenge] + properties: + nickname: { type: string, minLength: 2, maxLength: 40 } + email: { type: [string, 'null'], maxLength: 254, format: email } + content: { type: string, minLength: 2, maxLength: 2000 } + parentId: { type: [string, 'null'], format: uuid } + challenge: { type: string, minLength: 43, maxLength: 43 } + honeypot: { type: string, maxLength: 200 } + website: { type: string, maxLength: 200 } + CommentView: + type: object + required: [id, nickname, content, createdAt, replies] + properties: + id: { type: string, format: uuid } + nickname: { type: string } + content: { type: string } + createdAt: { type: string, format: date-time } + replies: { type: array, items: { $ref: '#/components/schemas/CommentView' } } + CommentPageResponse: + type: object + required: [items, page, size, total] + properties: + items: { type: array, items: { $ref: '#/components/schemas/CommentView' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + CommentSubmissionResponse: + type: object + required: [id, status, createdAt] + properties: + id: { type: [string, 'null'], format: uuid } + status: { type: string, enum: [PENDING] } + createdAt: { type: string, format: date-time } + deleteToken: { type: [string, 'null'], minLength: 43, maxLength: 43 } ProblemResponse: type: object required: [code, title, detail, traceId] diff --git a/infra/compose/compose.prod.yml b/infra/compose/compose.prod.yml index 20dd058..11cca62 100644 --- a/infra/compose/compose.prod.yml +++ b/infra/compose/compose.prod.yml @@ -60,6 +60,7 @@ services: HAOBLOG_ADMIN_PASSWORD_HASH: ${HAOBLOG_ADMIN_PASSWORD_HASH:?HAOBLOG_ADMIN_PASSWORD_HASH is required} HAOBLOG_PUBLIC_BASE_URL: ${HAOBLOG_PUBLIC_BASE_URL:?HAOBLOG_PUBLIC_BASE_URL is required} HAOBLOG_AUTHOR_NAME: ${HAOBLOG_AUTHOR_NAME:-Hao} + HAOBLOG_COMMENT_SECURITY_KEY: ${HAOBLOG_COMMENT_SECURITY_KEY:?HAOBLOG_COMMENT_SECURITY_KEY is required} JDBC_DATABASE_URL: jdbc:postgresql://postgres-pgvector:5432/${POSTGRES_DB:?POSTGRES_DB is required} JDBC_DATABASE_USERNAME: ${POSTGRES_USER:?POSTGRES_USER is required} JDBC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 75745cc..0e5a394 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -84,6 +84,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/public/articles/{slug}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPublicArticleComments"]; + put?: never; + post: operations["createPublicArticleComment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/public/articles/{slug}/comments/form-context": { parameters: { query?: never; @@ -100,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/public/comments/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deletePublicComment"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/public/article-previews/{token}": { parameters: { query?: never; @@ -505,6 +537,42 @@ export interface components { expiresAt: string; commentsEnabled: boolean; }; + CommentCreateRequest: { + nickname: string; + /** Format: email */ + email?: string | null; + content: string; + /** Format: uuid */ + parentId?: string | null; + challenge: string; + honeypot?: string; + website?: string; + }; + CommentView: { + /** Format: uuid */ + id: string; + nickname: string; + content: string; + /** Format: date-time */ + createdAt: string; + replies: components["schemas"]["CommentView"][]; + }; + CommentPageResponse: { + items: components["schemas"]["CommentView"][]; + page: number; + size: number; + /** Format: int64 */ + total: number; + }; + CommentSubmissionResponse: { + /** Format: uuid */ + id: string | null; + /** @enum {string} */ + status: "PENDING"; + /** Format: date-time */ + createdAt: string; + deleteToken?: string | null; + }; ProblemResponse: { code: string; title: string; @@ -839,6 +907,26 @@ export interface components { "application/problem+json": components["schemas"]["ProblemResponse"]; }; }; + /** @description Comment submission conflicts with current state or duplicate content */ + CommentConflict: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemResponse"]; + }; + }; + /** @description Comment rate limit exceeded */ + CommentRateLimited: { + headers: { + /** @description Seconds until another comment may be submitted */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemResponse"]; + }; + }; /** @description Article working copy version is stale */ ArticleVersionConflict: { headers: { @@ -916,6 +1004,7 @@ export interface components { }; parameters: { CsrfHeader: string; + CommentDeleteToken: string; IfNoneMatch: string; }; requestBodies: never; @@ -1106,6 +1195,75 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + listPublicArticleComments: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Approved top-level comments and first-level replies */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentPageResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["ArticleNotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + createPublicArticleComment: { + parameters: { + query?: never; + header: { + "X-CSRF-TOKEN": components["parameters"]["CsrfHeader"]; + }; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommentCreateRequest"]; + }; + }; + responses: { + /** @description Comment accepted for moderation */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentSubmissionResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description Invalid CSRF token or delete credential */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemResponse"]; + }; + }; + 404: components["responses"]["ArticleNotFound"]; + 409: components["responses"]["CommentConflict"]; + 429: components["responses"]["CommentRateLimited"]; + }; + }; getPublicCommentFormContext: { parameters: { query?: never; @@ -1129,6 +1287,32 @@ export interface operations { 404: components["responses"]["ArticleNotFound"]; }; }; + deletePublicComment: { + parameters: { + query?: never; + header: { + "X-CSRF-TOKEN": components["parameters"]["CsrfHeader"]; + "X-Comment-Delete-Token": components["parameters"]["CommentDeleteToken"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Comment content deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["CsrfInvalid"]; + 404: components["responses"]["ResourceNotFound"]; + 409: components["responses"]["CommentConflict"]; + }; + }; getPublicArticlePreview: { parameters: { query?: never; From f860ae2b2d82c1ec3b6b916f7f497b527af6d930 Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sat, 22 Aug 2026 23:45:23 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=9B=9B=EF=BC=9A?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=96=87=E7=AB=A0=E8=AF=84=E8=AE=BA=E5=8C=BA?= =?UTF-8?q?=20SSR=20=E4=B8=8E=E8=AE=BF=E5=AE=A2=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/assets/styles/articles.css | 44 ++++ .../components/articles/CommentSignalItem.vue | 54 ++++ .../articles/CommentSignalSection.vue | 246 ++++++++++++++++++ .../components/articles/CommentSignalText.vue | 14 + apps/web/app/pages/articles/[slug].vue | 18 +- apps/web/app/utils/commentSignal.ts | 42 +++ apps/web/e2e/stage4.spec.ts | 46 ++++ apps/web/test/comment-signal.test.ts | 144 ++++++++++ 8 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/components/articles/CommentSignalItem.vue create mode 100644 apps/web/app/components/articles/CommentSignalSection.vue create mode 100644 apps/web/app/components/articles/CommentSignalText.vue create mode 100644 apps/web/app/utils/commentSignal.ts create mode 100644 apps/web/e2e/stage4.spec.ts create mode 100644 apps/web/test/comment-signal.test.ts diff --git a/apps/web/app/assets/styles/articles.css b/apps/web/app/assets/styles/articles.css index c446083..0e69f11 100644 --- a/apps/web/app/assets/styles/articles.css +++ b/apps/web/app/assets/styles/articles.css @@ -47,6 +47,7 @@ .article-pagination a { color: var(--color-accent); text-decoration: none; } .article-pagination a:hover, .article-pagination a:focus-visible { text-decoration: underline; } .article-instrument { display: grid; grid-template-columns: minmax(3rem, 8rem) minmax(0, 45rem) minmax(10rem, 15rem); gap: clamp(1rem, 3vw, 2.5rem); max-width: 90rem; margin: 8vh auto 0; } +.article-reading-column { min-width: 0; } .article-reading { min-width: 0; } .article-reading h1 { max-width: none; font-size: clamp(2.5rem, 7vw, 6rem); line-height: .98; } .article-excerpt { color: var(--color-text-muted); font-size: var(--text-lg); } @@ -87,6 +88,49 @@ html[data-theme='blueprint'] .safe-markdown .shiki span { color: var(--shiki-lig .article-toc a { display: block; color: var(--color-text-muted); text-decoration: none; } .article-toc a:hover, .article-toc a:focus-visible, .article-toc a.is-current { color: var(--color-accent); } .article-toc a.is-current { border-left: 2px solid var(--color-accent); padding-left: .5rem; } +.comment-signal { margin-top: clamp(4rem, 10vw, 8rem); padding-top: 1.5rem; border-top: 1px solid var(--color-border); } +.comment-signal-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } +.comment-signal-heading h2 { margin: .65rem 0 0; font-family: var(--font-display); font-size: clamp(1.8rem, 5vw, 3.5rem); line-height: 1; } +.comment-signal-count, .comment-signal-form-label, .comment-signal-safety, .comment-signal-counter { color: var(--color-text-muted); font: var(--text-xs)/1.5 var(--font-mono); letter-spacing: .06em; } +.comment-signal-count { white-space: nowrap; } +.comment-signal-note { margin: 1.5rem 0 0; color: var(--color-text-muted); } +.comment-signal-list { margin-top: 2rem; border-left: 1px solid var(--color-border); } +.comment-signal-entry { position: relative; display: grid; grid-template-columns: .75rem minmax(0, 1fr); gap: .9rem; margin-left: -.4rem; padding: 0 0 1.75rem; } +.comment-signal-entry[data-depth='1'] { margin-left: 1.2rem; padding-bottom: 1.25rem; } +.comment-signal-point { width: .7rem; height: .7rem; margin-top: .35rem; border: 1px solid var(--color-accent); background: var(--color-bg-base); } +.comment-signal-entry[data-depth='1'] .comment-signal-point { width: .5rem; height: .5rem; margin-top: .42rem; border-color: var(--color-text-muted); } +.comment-signal-entry-body { min-width: 0; } +.comment-signal-entry-header { display: flex; align-items: baseline; gap: .75rem; flex-wrap: wrap; } +.comment-signal-author { color: var(--color-text-main); font-weight: 650; } +.comment-signal-entry-header time { color: var(--color-text-muted); font: var(--text-xs)/1.5 var(--font-mono); } +.comment-signal-content { margin: .55rem 0 0; color: var(--color-text-main); line-height: 1.75; overflow-wrap: anywhere; white-space: pre-wrap; } +.comment-signal-content a { color: var(--color-accent); text-decoration-thickness: 1px; text-underline-offset: .16em; overflow-wrap: anywhere; } +.comment-signal-actions { display: flex; gap: .85rem; flex-wrap: wrap; margin-top: .65rem; } +.comment-signal-actions button, .comment-signal-cancel { padding: 0; border: 0; color: var(--color-text-muted); background: transparent; font: var(--text-xs)/1.5 var(--font-mono); text-decoration: underline; text-underline-offset: .2em; cursor: pointer; } +.comment-signal-actions button:hover, .comment-signal-actions button:focus-visible, .comment-signal-cancel:hover, .comment-signal-cancel:focus-visible { color: var(--color-accent); } +.comment-signal button, .comment-signal input, .comment-signal textarea { touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.comment-signal-replies { margin-top: 1rem; border-left: 1px solid var(--color-border-soft); } +.comment-signal-compose { margin-top: 1.75rem; } +.comment-signal-expand { width: 100%; padding: .85rem 0; border: 1px solid var(--color-border); color: var(--color-accent); background: transparent; font: var(--text-xs)/1.5 var(--font-mono); letter-spacing: .06em; text-align: left; cursor: pointer; } +.comment-signal-expand span { float: right; font-size: 1.1rem; line-height: 1; } +.comment-signal-expand:hover, .comment-signal-expand:focus-visible { border-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); } +.comment-signal-form { position: relative; margin-top: 1rem; padding: 1rem; border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-bg-sub) 55%, transparent); } +.comment-signal-form-label { margin: 0; color: var(--color-accent); } +.comment-signal-cancel { position: absolute; top: 1rem; right: 1rem; } +.comment-signal-safety { margin: .85rem 0 1rem; } +.comment-signal-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; } +.comment-signal-form label { display: grid; gap: .35rem; margin-top: .8rem; color: var(--color-text-muted); font: var(--text-xs)/1.5 var(--font-mono); } +.comment-signal-form input, .comment-signal-form textarea { width: 100%; min-width: 0; padding: .65rem .7rem; border: 1px solid var(--color-border); border-radius: 0; color: var(--color-text-main); background: var(--color-bg-base); font: var(--text-base)/1.5 var(--font-body); } +.comment-signal-form textarea { resize: vertical; } +.comment-signal-form input:focus, .comment-signal-form textarea:focus { border-color: var(--color-accent); outline: 2px solid var(--color-accent); outline-offset: 1px; } +.comment-signal-form-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: .8rem; } +.comment-signal-form-footer button { padding: .6rem .8rem; border: 1px solid var(--color-accent); color: var(--color-accent-ink); background: var(--color-accent); font: var(--text-xs)/1.5 var(--font-mono); cursor: pointer; } +.comment-signal-form-footer button:disabled { cursor: wait; opacity: .55; } +.comment-signal-counter { letter-spacing: normal; } +.comment-signal-feedback { min-height: 1.5em; margin: .8rem 0 0; color: var(--color-accent); font: var(--text-xs)/1.5 var(--font-mono); } +.comment-signal-feedback[data-status='error'], .comment-signal-feedback[data-status='rate-limited'] { color: var(--color-warn); } +.comment-signal-honeypot { position: absolute !important; width: 1px !important; height: 1px !important; margin: -1px !important; padding: 0 !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; border: 0 !important; } +@media (max-width: 480px) { .comment-signal-heading { align-items: start; flex-direction: column; gap: .5rem; }.comment-signal-fields { grid-template-columns: 1fr; gap: 0; }.comment-signal-form { padding: .8rem; }.comment-signal-cancel { top: .8rem; right: .8rem; }.comment-signal-safety { padding-right: 4.5rem; } } @media (max-width: 1023px) { .article-instrument { display: block; max-width: 45rem; margin-top: 6vh; }.article-signal { position: sticky; top: 0; z-index: 2; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .75rem; height: auto; margin: 0 0 2rem; padding: .5rem 0; background: var(--color-bg-base); }.article-signal-track { width: 100%; height: 2px; }.article-signal-fill { width: var(--signal-progress); height: 100%; }.article-toc { position: static; margin-top: 3rem; padding: 1rem 0 0; border-left: 0; border-top: 1px solid var(--color-border); }.article-toc ol { padding-bottom: .25rem; } } @media (max-width: 767px) { .observation-entry { grid-template-columns: 1fr; gap: .4rem; padding-left: 1rem; }.article-pagination { padding-left: 1rem; }.recent-list li { grid-template-columns: 2rem 1fr; gap: .35rem .75rem; }.recent-list time { grid-column: 2; }.recent-list a { grid-column: 2; }.recent-list .recent-index { grid-row: 1 / span 2; }.home-calibration, .recent-heading { align-items: start; flex-direction: column; gap: .75rem; }.home-coordinate { text-align: left; }.observation-entry-heading { grid-template-columns: 2rem minmax(0, 1fr); }.article-cover-frame { grid-column: 2; width: min(12rem, 100%); }.observation-entry-content p { margin-top: .9rem; } } @media (max-width: 360px) { .home-overview h1 { font-size: clamp(3rem, 18vw, 4.5rem); } .home-overview::before { display: none; } .safe-markdown { font-size: 1rem; line-height: 1.8; } .observation-entry-heading { grid-template-columns: 1.5rem minmax(0, 1fr); gap: .5rem; } .observation-index { font-size: .68rem; } } diff --git a/apps/web/app/components/articles/CommentSignalItem.vue b/apps/web/app/components/articles/CommentSignalItem.vue new file mode 100644 index 0000000..98b26e3 --- /dev/null +++ b/apps/web/app/components/articles/CommentSignalItem.vue @@ -0,0 +1,54 @@ + + + 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 @@ + + +