class ApplicationController < Prism::Controller include Prism::Authenticatable include Prism::Authorizable layout :application protect_from_forgery authenticate_with model: User, unauthenticated_redirect_path: '/session/new', remember_sessions: LoginSession, max_remember_sessions: 3 rescue_from Prism::Policy::NotAuthorizedError, with: :forbidden # How many likes a post or comment has, and the current visitor's own # Like on it (if any) - used by views to decide between a "Like" and an # "Unlike" button without querying the polymorphic association twice. def likes_count(record) record.likes_count end def current_user_like(record) return nil unless current_user record.likes.find { |like| like.user_id == current_user.id } end # Same shape as current_user_like above, for posts#show's giveaway # section - whether the current visitor already holds a claim decides # between showing a "claim a slot" button and a "you already claimed # one" notice (see Post#giveaway?/#giveaway_sold_out? for the two other # states that section can be in). def current_user_claim(post) return nil unless current_user post.claims.find { |claim| claim.user_id == current_user.id } end # Display label for the post a comment hangs off of, robust to the post # being trashed independently of the comment itself (its own trash/restore # cycle - see Post#acts_as_paranoid). Reaches past the default paranoid # scope (Post.with_deleted) because a plain Comment#post/Post.find would # raise Prism::Orm::RecordNotFound instead of returning the trashed row. # Shared by CommentsController#trash and AccountController#comments - both # list comments detached from their normal post-scoped page, where a # comment can legitimately outlive its post being in the trash. def comment_post_label(comment) post = comment_post(comment) return t('comments.orphaned_post') if post.nil? post.deleted? ? t('comments.post_also_trashed', title: post.title) : post.title end def comment_post_linkable?(comment) post = comment_post(comment) post && !post.deleted? end # The slug for comment_post_linkable?'s url_for(:post, ...) - only ever # called from inside that guard, so the post here is always the live # (not-deleted) one, but reuses the same with_deleted lookup/memoization # as the other two helpers above rather than assuming that. def comment_post_slug(comment) comment_post(comment)&.to_param end private # comment_post_label/#comment_post_linkable?/#comment_post_slug all need # the SAME with_deleted Post lookup for a given comment - memoized per # post_id so a comment list page (moderation/trash/account#comments) # costs one query per distinct post, not up to three per comment. def comment_post(comment) @comment_post_cache ||= {} @comment_post_cache.fetch(comment.post_id) { @comment_post_cache[comment.post_id] = Post.with_deleted.find_by(id: comment.post_id) } end # One-off JSON:API error object (see Prism::Orm::Errors#to_jsonapi) for # failures that do not come from a model's own validations, such as a # stale optimistic-locking conflict - keeps the wire shape identical to # render_errors so a JSON client only has to understand one error format. def jsonapi_error(status:, pointer:, title:, detail:) { status: status.to_s, source: { pointer: pointer }, title: title, detail: detail } end # Content-negotiated 404 for a Prism::Orm::RecordNotFound rescue: plain # text/HTML for browser requests, a JSON:API error object (via # Renderable#render_not_found) for .json ones - the same halt(404, ...) # every controller used to call regardless of what the client asked for, # which meant a `.json` 404 came back as a bare HTML-ish string instead # of an `errors` array a JSON client could actually parse. def respond_not_found(detail) respond_to do |format| format.html { halt(404, detail) } format.json { render_not_found(detail) } end end # Sends the visitor back to the post they were looking at either way - # whether it was the post itself, one of its comments, or a like on # either - instead of a bare 403, so a signed-in user who simply clicked # someone else's delete/unlike button lands somewhere useful. def forbidden(exception) flash[:notice] = t('errors.forbidden') redirect url_for(:post, slug: post_slug_for(exception.record)) end # The record a NotAuthorizedError carries is always live (Comment.find/ # Post.find already hide soft-deleted rows before a policy ever runs), # so `record.post` here is a plain association read, not the with_deleted # lookup comment_post/#comment_post_label above need for the trash pages. def post_slug_for(record) case record when Comment then record.post.slug when Like then post_slug_for(record.likeable) else record.to_param end end end