Files
prism/examples/blog/app/controllers/posts_controller.rb
T

118 lines
6.5 KiB
Ruby

require 'csv'
require 'fileutils'
class PostsController < ApplicationController
before_action :require_authentication,
only: %i[create edit update destroy preview publish unpublish trash restore import move_up move_down]
# Same content-aware etag idea as #show below, but over the whole page of
# posts: current_page/total_count catch pagination shifting (a post
# created or destroyed elsewhere moves what belongs on this page even
# when none of the posts actually ON this page changed), and the id/
# updated_at pairs catch edits to any of them.
#
# `.published` is a scope generated by Post's `enum :status` - every post
# starts as a draft (see the model), so a freshly created one is invisible
# here until its author publishes it from posts#show.
def index
# `days` is an optional "created in the last N days" filter (see
# posts.filter_* links in the view) chained onto .published BEFORE
# .order/.page - Post.created_within hits the range-capable secondary
# index on created_at (Store#lookup_by_index_range) the same way
# .published already hits the one on status, so neither filter alone
# nor the two combined ever falls back to a full table scan.
days = params['days'].to_i
# `params['featured']` is the raw query-string String Rack always hands
# a controller ("1" or nil) - cast through the same TypeCasting a form
# POST would use, rather than a bespoke `== '1'` check here, so this
# filter and the checkbox in _form.erb agree on what counts as true.
featured_only = params['featured'] && Prism::Orm::TypeCasting.cast(:boolean, params['featured'])
scope = Post.published
scope = scope.created_within(days) if days.positive?
scope = scope.featured if featured_only
# The featured view is manually curated (Post#acts_as_list, scoped by
# featured - see the model and #move_up/#move_down above), so it sorts
# by that editorial position instead of recency like every other view
# of this same index action.
posts = featured_only ? scope.order(:position, :asc).page(params['page']).per(5)
: scope.order(:created_at, :desc).page(params['page']).per(5)
respond_to do |format|
format.html do
notice = flash[:notice]
# current_user&.id rides along in the etag, same audit fix as #show
# below: this page's "new post" form and the layout's signed-in-as
# banner are both gated on user_signed_in?/current_user, so without
# this a shared cache keying purely on URL+ETag could serve one
# visitor's signed-in body back as a 304 to a different, or
# signed-out, visitor whose If-None-Match happened to match. `days`
# rides along too so switching the filter without the post set
# actually changing (e.g. nothing published in the last 7 days
# either way) still forces a fresh render, not a stale 304 for the
# wrong filter. `featured_only` rides along for the same reason -
# and so does each post's `position` (see Post#acts_as_list): a
# #move_up/#move_down reorder touches ONLY the position column,
# never updated_at, so without it in the etag two requests with the
# exact same set of ids/updated_at pairs but a different editorial
# order would wrongly hash to the same ETag and serve a stale 304.
fresh_when(etag: [posts.current_page, posts.total_count,
posts.to_a.map { |p| [p.id, p.updated_at, p.position] }, notice,
current_user&.id, days, featured_only])
render(:index, locals: { posts: posts, new_post: Post.new, notice: notice, errors: [], days: days,
featured_only: featured_only })
end
format.json do
json(posts.as_json(only: %i[id slug title body cover]))
end
end
end
# `notice` rides along in the etag components (not just post/comments
# state) so a pending flash message - which only exists for the one
# request right after a redirect - always forces a fresh render instead
# of being hidden behind a 304 that matches an older, notice-less copy
# of this same page a client happened to have cached.
def show
post = Post.find_by!(slug: params['slug'])
comments = post.comments.includes(:likes).visible_to(current_user, post.user_id).order(:created_at, :desc).to_a
notice = flash[:notice]
# current_user.id rides along in the etag, not just the post/comments
# data: this page also shows moderator-only controls (edit/publish/
# delete, comment approve/reject) and viewer-only pending comments, so
# two different visitors who happen to see the identical VISIBLE
# comment set must still never be served each other's cached 304 body.
# post.claims_count rides along too - a giveaway claim (see
# ClaimsController#create) changes #giveaway_remaining/
# #giveaway_sold_out? and therefore this page's body without ever
# touching post.updated_at (Claim has no touch: true, on purpose: a
# claim is not an edit to the post itself), so without it here a
# cached 304 would keep serving a stale remaining-slots count and a
# stale claim button after someone had already claimed the last slot.
fresh_when(etag: [post.id, post.updated_at, post.claims_count, current_user&.id,
comments.map { |c| [c.id, c.updated_at] }, notice])
render(:show, locals: { post: post, comments: comments, new_comment: Comment.new, notice: notice })
rescue Prism::Orm::RecordNotFound
respond_not_found(t('posts.not_found'))
end
# Streamed (Prism::Controller#stream, see lib/prism/streaming_body.rb),
# not rendered into one String first - a real Rails app faces the exact
# same shape of problem exporting a large table, and #index already
# proves .published/.created_within hit a secondary index instead of a
# full scan, so this is the same query with the row-by-row cost paid to
# the client incrementally instead of all at once in memory. The header
# row is written (and would reach the client) before the last matching
# post has even been read out of the store.
def export
days = params['days'].to_i
scope = Post.published
scope = scope.created_within(days) if days.positive?
headers('Content-Type' => 'text/csv')
stream do |out|
out.write(CSV.generate_line(%w[id title created_at]))
scope.order(:created_at, :desc).find_each do |post|
out.write(CSV.generate_line([post.id, post.title, post.created_at.utc.iso8601]))
end
end
end
end