118 lines
6.5 KiB
Ruby
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
|