From Blogsphere to a Static Site (Part 4) - Comment backend
The blog needed a comment function. While there are social options around (Facebook, Disqus etc), I decided I want to roll my own. Partly because I want tighter control and partly, well, because I could. My comment backend would:
- Provide a REST API to create comments in a JSON structure. The comment body will be Markdown. Reading would provide comments in ready to use HTML (I hear howling from the API crowd). No delete or update functionality
- Cleanup content considered harmful (code injection) and optional sport Captcha
- Store all content in a NoSQL database, in my case CouchDB (or Cloudant with its 20G free plan)
- Cache all queries for comment in an online cache to limit calls to the database
- Initially run on Domino, later on liberty or the raw JVM
- Initially also update Domino using a web service - so during transition no comments would get lost
In its initial incarnation the Comment servlet is a OSGi plugin that listens to the /comments
URL implemented as Wink servlet. So the class of interest is the one defining the service. We have one method for post, one for get and a helper function
/** * Wink implementation of Comment service */ @Workspace(workspaceTitle = "Blog Comments", collectionTitle = "Create or display comments") @Path(value = "/comments") @Produces(MediaType.TEXT_HTML) @Consumes(MediaType.APPLICATION_JSON) public class CommentService extends CommentResponse { private final Logger logger = Logger.getLogger(this.getClass().getName()); @POST public Response createComment(@Context HttpServletRequest request) { final Monitor mon = MonitorFactory.start("CommentService#createComment"); String result = "Sorry I can't process your comment at this time"; ResponseBuilder builder = Response.ok(); try { InputStream in = request.getInputStream(); BlogComment comment = BlogComment.load(in); in.close(); if (comment != null) { this.captureSubmissionDetails(request, comment); result = CommentManager.INSTANCE.saveComment(comment, true); } else { builder.status(Status.NOT_ACCEPTABLE); } builder.entity(result).type(MediaType.TEXT_HTML_TYPE); } catch (Exception e) { String errorMessage = e.getMessage(); builder.entity((((errorMessage == null) || errorMessage.equals("")) ? "Undefined error" : errorMessage)).type( MediaType.TEXT_HTML_TYPE); Utils.logError(this.logger, e); } mon.stop(); return builder.build(); } @GET public Response getComments(@QueryParam("parentid") final String parentid) { Response response = null; final Monitor mon = MonitorFactory.start("CommentService#getComments"); final ResponseBuilder builder = Response.ok(); final Collection<BlogComment> bc = CommentManager.INSTANCE.loadComments(parentid); if ((bc == null) || bc.isEmpty()) { builder.status(Status.NO_CONTENT); } else { response = this.renderOutput(bc, "comment.mustache"); } mon.stop(); return (response == null) ? builder.build() : response; } private void captureSubmissionDetails(HttpServletRequest request, BlogComment comment) { final Monitor mon = MonitorFactory.start("CommentService#captureSubmissionDetails"); try { @SuppressWarnings("rawtypes") Enumeration hn = request.getHeaderNames(); if (hn != null) { while (hn.hasMoreElements()) { String key = hn.nextElement().toString(); comment.addParameter(key, request.getHeader(key)); } } @SuppressWarnings("rawtypes") Enumeration pn = request.getParameterNames(); if (pn != null) { while (pn.hasMoreElements()) { String key = pn.nextElement().toString(); String[] values = request.getParameterValues(key); comment.addParameters(key, values); if (key.equals("referer")) { comment.setReferer(values[0]); } else if (key.equals("user-agent")) { comment.setUserAgent(values[0]); } } } @SuppressWarnings("rawtypes") Enumeration an = request.getAttributeNames(); if (an != null) { while (an.hasMoreElements()) { try { String key = an.nextElement().toString(); comment.addAttribute(key, String.valueOf(request.getAttribute(key))); } catch (Exception e) { // No action here } } } comment.addParameter("REMOTE_HOST", request.getRemoteHost()); comment.addParameter("REMOTE_ADDR", request.getRemoteAddr()); comment.addParameter("REMOTE_USER", request.getRemoteUser()); // Needed for Captcha comment.setRemoteAddress(request.getRemoteAddr()); } catch (Exception e) { Utils.logError(this.logger, e); // But no further action here! } mon.stop(); } }
The two interesting lines in the class above are CommentManager.INSTANCE.saveComment(comment, true);
and CommentManager.INSTANCE.loadComments(parentid);
, with the former saving a new comment and the later loading the list of comments. Both use the CommentsManager Singleton to access comments. The key component is a Google Guava cache and the Ektorp CouchDB library
Setting up the cache
class CommentLoader extends CacheLoader<String, Collection<BlogComment>> { private final Logger logger = Logger.getLogger(this.getClass().getName()); @Override public Collection<BlogComment> load(String parentId) throws CommentException { Monitor mon = MonitorFactory.start("CommentManager#CommentLoader#load"); CouchDbConnector db; List<BlogComment> result = null; try { db = CommentManager.this.getDB(); ViewQuery query = new ViewQuery().designDocId("_design/comments").viewName("byParentId").key(parentId); result = db.queryView(query, BlogComment.class); } catch (MalformedURLException e) { Utils.logError(this.logger, e); throw new CommentException("Comment retrieval failed", e); } if (result != null) { Collections.sort(result); } else { throw new CommentException(); } mon.stop(); return result; } } // Get comments from the database CommentLoader loader = new CommentLoader(); this.commentCache = CacheBuilder.newBuilder().maximumSize(1000).recordStats().expireAfterAccess(8, TimeUnit.HOURS) .build(loader);
Save a comment
public String saveComment(BlogComment bc, boolean validate) { String key = this.config.CHECKCAPTCHA ? this.config.RECAPTCHAKEY : null; boolean isValid = bc.isValid(key); String result = bc.getValidationResult(); if (isValid || !validate) { CouchDbConnector db; try { db = this.getDB(); db.create(bc.getId(), bc); // Now save into the cache Collection<BlogComment> cachedComments = null; cachedComments = this.commentCache.getIfPresent(bc.getParentId()); if (cachedComments != null) { cachedComments.add(bc); } } catch (Exception e) { Utils.logError(this.logger, e); result = e.getMessage(); } // Now notify the legacyBlog BackgroundWebServiceCall b = new BackgroundWebServiceCall(bc); this.executor.execute(b); } return result; }
Load comments
public Collection<BlogComment> loadComments(final String parentId) { Collection<BlogComment> result = null; if (parentId != null) { try { result = this.commentCache.get(parentId); } catch (ExecutionException e) { Utils.logError(this.logger, e); } } return result; }
Getting a gravatar image
public String getGravatarURL() { if ((this.gravatarURL == null) || this.gravatarURL.trim().equals("")) { if (this.eMail != null) { String emailHash = DigestUtils.md5Hex(this.eMail.toLowerCase().trim()); this.setGravatarURL(GRAVATAR_URL + emailHash + ".jpg?s=" + GRAVATAR_SIZE); } } return this.gravatarURL; }
Create HTML from Markdown
private void createHtmlBody(String markdownBody) { PegDownProcessor p = new PegDownProcessor(); this.htmlBody = p.markdownToHtml(HTMLFilter.filter(markdownBody)); }
Check for valid Captcha
public static boolean isValidCaptcha(String captchaKey, String remoteAddress, String challenge, String response) { boolean result = true; // We only test if we have a remote address and the captcha switch is on if (remoteAddress != null && captchaKey != null) { ReCaptchaImpl reCaptcha = new ReCaptchaImpl(); reCaptcha.setPrivateKey(captchaKey); ReCaptchaResponse reCaptchaResponse = reCaptcha.checkAnswer(remoteAddress, challenge, response); result = reCaptchaResponse.isValid(); } return result; }
For the full details of the implementation, including the background task talking to Domino, you need to wait for the source release on Github. Next stop: the comment front end
Posted by Stephan H Wissel on 04 May 2017 | Comments (2) | categories: Blog