KB66 Add comment box.

Change-Id: I9bf140ded85045b09997145ed2a9fb421fedc7d4
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java
index c30f3df..06ea299 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java
@@ -1,7 +1,6 @@
 package eu.mulk.mulkcms2.benki.bookmarks;
 
 import eu.mulk.mulkcms2.benki.posts.PostText;
-import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import javax.annotation.CheckForNull;
 import javax.persistence.Column;
 import javax.persistence.Entity;
@@ -22,10 +21,7 @@
 
   @Transient
   @CheckForNull
-  protected String computeDescriptionHtml() {
-    if (description == null) {
-      return null;
-    }
-    return new MarkdownConverter().htmlify(description);
+  protected String getDescriptionMarkup() {
+    return description;
   }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
index 63f4791..7f6ed3b 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
@@ -21,11 +21,7 @@
       joinColumns = {@JoinColumn(name = "referrer")},
       inverseJoinColumns = {@JoinColumn(name = "referee")})
   @JsonbTransient
-  public Collection<LazychatMessage> referees;
-
-  @ManyToMany(mappedBy = "referees")
-  @JsonbTransient
-  public Collection<LazychatMessage> referrers;
+  public Collection<Post<?>> referees;
 
   @CheckForNull
   @Override
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java
index 1a60877..72bb983 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java
@@ -1,7 +1,6 @@
 package eu.mulk.mulkcms2.benki.lazychat;
 
 import eu.mulk.mulkcms2.benki.posts.PostText;
-import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import javax.annotation.CheckForNull;
 import javax.json.bind.annotation.JsonbTransient;
 import javax.persistence.Column;
@@ -19,10 +18,7 @@
   @CheckForNull
   @Override
   @JsonbTransient
-  protected String computeDescriptionHtml() {
-    if (content == null) {
-      return null;
-    }
-    return new MarkdownConverter().htmlify(content);
+  protected String getDescriptionMarkup() {
+    return content;
   }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index 2c56285..d3e7712 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -109,6 +109,10 @@
   @JsonbTransient
   public Set<Role> targets;
 
+  @ManyToMany(mappedBy = "referees")
+  @JsonbTransient
+  public Collection<LazychatMessage> referrers;
+
   @OneToMany(
       mappedBy = "post",
       fetch = FetchType.LAZY,
@@ -389,6 +393,16 @@
     }
   }
 
+  public Collection<LazychatMessage> getComments() {
+    return referrers.stream()
+        .filter(l -> l.scope == Scope.comment)
+        .sorted(
+            Comparator.comparing(
+                    (LazychatMessage l) -> Objects.requireNonNullElse(l.date, OffsetDateTime.MIN))
+                .reversed())
+        .toList();
+  }
+
   public enum Visibility {
     PUBLIC,
     SEMIPRIVATE,
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
index 24a564b..5a38262 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -4,6 +4,7 @@
 import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML;
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.TEXT_HTML;
+import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
 
 import com.blazebit.persistence.CriteriaBuilderFactory;
 import com.rometools.rome.feed.atom.Content;
@@ -15,8 +16,10 @@
 import com.rometools.rome.io.WireFeedOutput;
 import eu.mulk.mulkcms2.benki.accesscontrol.PageKey;
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
+import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
 import eu.mulk.mulkcms2.benki.login.LoginRoles;
 import eu.mulk.mulkcms2.benki.posts.Post.PostPage;
+import eu.mulk.mulkcms2.benki.posts.Post.Scope;
 import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.qute.CheckedTemplate;
 import io.quarkus.qute.TemplateExtension;
@@ -25,6 +28,7 @@
 import java.math.BigInteger;
 import java.net.URI;
 import java.net.URLEncoder;
+import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.time.Instant;
@@ -49,14 +53,20 @@
 import javax.persistence.EntityManager;
 import javax.persistence.PersistenceContext;
 import javax.transaction.Transactional;
+import javax.validation.constraints.NotEmpty;
 import javax.ws.rs.BadRequestException;
 import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.FormParam;
 import javax.ws.rs.GET;
+import javax.ws.rs.POST;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.core.UriBuilder;
 import javax.ws.rs.core.UriInfo;
 import org.eclipse.microprofile.config.inject.ConfigProperty;
 import org.hibernate.Session;
@@ -75,6 +85,8 @@
   private static final DateTimeFormatter humanDateFormatter =
       DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
 
+  private static final String hashcashDigestAlgorithm = "SHA-256";
+
   private static final int pageKeyBytes = 32;
 
   private static final int AUTOTITLE_WORDS = 10;
@@ -283,6 +295,48 @@
     return makeFeed(pageKey, ownerName, owner);
   }
 
+  @POST
+  @Produces(TEXT_PLAIN)
+  @Path("{id}/comments")
+  @Transactional
+  public Response postComment(
+      @PathParam("id") int postId,
+      @FormParam("message") @NotEmpty String message,
+      @FormParam("hashcash-salt") long hashcashSalt)
+      throws NoSuchAlgorithmException {
+    var hashcashDigest = MessageDigest.getInstance(hashcashDigestAlgorithm);
+    hashcashDigest.update("Hashcash-Salt: ".getBytes(UTF_8));
+    hashcashDigest.update(String.valueOf(hashcashSalt).getBytes(UTF_8));
+    hashcashDigest.update("\n\n".getBytes(UTF_8));
+
+    for (byte b : message.getBytes(UTF_8)) {
+      if (b == '\r') {
+        // Skip CR characters.  The JavaScript side does not include them in its computation.
+        continue;
+      }
+      hashcashDigest.update(b);
+    }
+    var hashcash = hashcashDigest.digest();
+
+    if (hashcash[0] != 0 || hashcash[1] != 0) {
+      throw new BadRequestException(
+          "invalid hashcash",
+          Response.status(Status.BAD_REQUEST).entity("invalid hashcash").build());
+    }
+
+    Post<?> post = Post.findById(postId);
+
+    var comment = new LazychatMessage();
+    comment.date = OffsetDateTime.now();
+    comment.scope = Scope.comment;
+    comment.referees = List.of(post);
+    comment.setContent(message);
+    assignPostTargets(post.getVisibility(), post.owner, comment);
+    comment.persist();
+
+    return Response.seeOther(UriBuilder.fromUri("/posts/{id}").build(postId)).build();
+  }
+
   private String makeFeed(
       @CheckForNull BigInteger pageKey, @CheckForNull String ownerName, @CheckForNull User owner)
       throws FeedException {
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java
index 11ac98a..80971b1 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java
@@ -1,6 +1,9 @@
 package eu.mulk.mulkcms2.benki.posts;
 
 import com.vladmihalcea.hibernate.type.search.PostgreSQLTSVectorType;
+import eu.mulk.mulkcms2.benki.posts.Post.Scope;
+import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
+import eu.mulk.mulkcms2.common.markdown.MarkdownConverter.Mode;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import javax.annotation.CheckForNull;
 import javax.json.bind.annotation.JsonbTransient;
@@ -69,5 +72,15 @@
   }
 
   @CheckForNull
-  protected abstract String computeDescriptionHtml();
+  protected abstract String getDescriptionMarkup();
+
+  @CheckForNull
+  private String computeDescriptionHtml() {
+    var markup = getDescriptionMarkup();
+    if (markup == null) {
+      return null;
+    }
+    return new MarkdownConverter(post.scope == Scope.top_level ? Mode.POST : Mode.COMMENT)
+        .htmlify(markup);
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/common/markdown/MarkdownConverter.java b/src/main/java/eu/mulk/mulkcms2/common/markdown/MarkdownConverter.java
index 859fd71..2a144c5 100644
--- a/src/main/java/eu/mulk/mulkcms2/common/markdown/MarkdownConverter.java
+++ b/src/main/java/eu/mulk/mulkcms2/common/markdown/MarkdownConverter.java
@@ -11,18 +11,22 @@
 import com.vladsch.flexmark.parser.Parser;
 import com.vladsch.flexmark.util.data.MutableDataSet;
 import java.util.Arrays;
-import javax.enterprise.context.ApplicationScoped;
 import org.jsoup.Jsoup;
 import org.jsoup.safety.Cleaner;
 import org.jsoup.safety.Safelist;
 
-@ApplicationScoped
 public class MarkdownConverter {
 
+  public enum Mode {
+    POST,
+    COMMENT,
+  }
+
   private final Parser parser;
   private final HtmlRenderer renderer;
+  private final Mode mode;
 
-  public MarkdownConverter() {
+  public MarkdownConverter(Mode mode) {
     var options = new MutableDataSet();
     options.set(
         Parser.EXTENSIONS,
@@ -42,6 +46,7 @@
     options.set(TypographicExtension.ENABLE_QUOTES, true);
     options.set(FootnoteExtension.FOOTNOTE_BACK_REF_STRING, "");
 
+    this.mode = mode;
     this.parser = Parser.builder(options).build();
     this.renderer = HtmlRenderer.builder(options).build();
   }
@@ -49,14 +54,42 @@
   public String htmlify(String markdown) {
     var parsedDocument = parser.parse(markdown);
     var uncleanHtml = renderer.render(parsedDocument);
-    var cleaner =
-        new Cleaner(
-            Safelist.relaxed()
-                .addTags("abbr", "acronym")
-                .addAttributes("abbr", "title")
-                .addAttributes("acronym", "title"));
+    var cleaner = makeCleaner();
     var cleanedDocument = cleaner.clean(Jsoup.parseBodyFragment(uncleanHtml));
     cleanedDocument.select("table").addClass("pure-table").addClass("pure-table-horizontal");
     return cleanedDocument.body().html();
   }
+
+  private Cleaner makeCleaner() {
+    var safelist =
+        switch (mode) {
+          case POST -> Safelist.relaxed()
+              .addTags("abbr", "acronym")
+              .addAttributes("abbr", "title")
+              .addAttributes("acronym", "title");
+          case COMMENT -> Safelist.simpleText()
+              .addTags(
+                  "p",
+                  "blockquote",
+                  "cite",
+                  "code",
+                  "pre",
+                  "dd",
+                  "dl",
+                  "dt",
+                  "s",
+                  "sub",
+                  "sup",
+                  "ol",
+                  "ul",
+                  "li",
+                  "abbr",
+                  "acronym",
+                  "ins",
+                  "del")
+              .addAttributes("abbr", "title")
+              .addAttributes("acronym", "title");
+        };
+    return new Cleaner(safelist);
+  }
 }
diff --git a/src/main/resources/META-INF/resources/cms2/base.css b/src/main/resources/META-INF/resources/cms2/base.css
index e11f295..d68e07e 100644
--- a/src/main/resources/META-INF/resources/cms2/base.css
+++ b/src/main/resources/META-INF/resources/cms2/base.css
@@ -323,7 +323,7 @@
   color: #555;
 }
 
-.post-self-link {
+.post-self-link, .comment-self-link {
   padding-left: 5px;
   padding-right: 5px;
 }
@@ -355,27 +355,31 @@
   float: left;
 }
 
-.lazychat-message-info {
+.comment {
+  margin: 5px;
+}
+
+.lazychat-message-info, .comment-info {
   font-style: italic;
   margin: 0;
   padding: 0;
   flex: auto;
 }
 
-article.lazychat-message {
+article.lazychat-message, .comment {
   border: 1px solid #a0c0c0;
   padding: 0.3em;
   background: #f0f8f0;
 }
 
-article.lazychat-message > header {
+article.lazychat-message > header, .comment-info {
   display: flex;
   float: left;
   margin-right: 5px;
   line-height: 1em;
 }
 
-a.post-link {
+a.post-link, a.comment-link {
   text-decoration: none;
 }
 
diff --git a/src/main/resources/templates/benki/posts/postList.html b/src/main/resources/templates/benki/posts/postList.html
index 0fc8a6d..2f932ec 100644
--- a/src/main/resources/templates/benki/posts/postList.html
+++ b/src/main/resources/templates/benki/posts/postList.html
@@ -84,7 +84,7 @@
             </div>
 
             {#if showCommentBox}
-              {#commentBox postId=post.id /}
+              {#commentBox postId=post.id comments=post.comments /}
             {/if}
           </article>
         {#else}
@@ -117,7 +117,7 @@
             </div>
 
             {#if showCommentBox}
-              {#commentBox postId=post.id /}
+              {#commentBox postId=post.id comments=post.comments /}
             {/if}
           </article>
         {/if}
diff --git a/src/main/resources/templates/tags/commentBox.html b/src/main/resources/templates/tags/commentBox.html
index 3049687..40cec4a 100644
--- a/src/main/resources/templates/tags/commentBox.html
+++ b/src/main/resources/templates/tags/commentBox.html
@@ -1,4 +1,5 @@
 {@java.lang.Integer postId}
+{@java.util.List<eu.mulk.mulkcms2.benki.lazychat.LazychatMessage> comments}
 
 <div class="comment-box">
   <script type="module" src="/lib.js"></script>
@@ -10,9 +11,6 @@
     <fieldset>
       <legend>Post Comment</legend>
 
-      <label for="comment-form-author-{postId}">Author (optional)</label>
-      <input name="author" id="comment-form-author-{postId}" type="text" placeholder="Anonymous Coward"/>
-
       <label for="comment-form-message-{postId}">Message</label>
       <textarea name="message" id="comment-form-message-{postId}" placeholder="Great article!" required></textarea>
 
@@ -23,4 +21,20 @@
       </div>
     </fieldset>
   </form>
+
+  {#for comment in comments}
+  <div class="comment" id="comment-{comment.id}">
+    {#if comment.owner != null}<span class="comment-owner post-owner">{comment.owner.firstName}</span>{/if}
+
+    <div class="comment-info">
+      <a class="comment-link" href="/posts/{postId}#comment-{comment.id}">
+        <span class="comment-self-link">#</span>
+      </a>
+    </div>
+
+    <div class="comment-content post-content">
+      {comment.descriptionHtml.raw}
+    </div>
+  </div>
+  {/for}
 </div>