352 lines
16 KiB
Ruby
352 lines
16 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
|
|
|
|
# The reverse of #export above: bulk-creates posts from an uploaded CSV
|
|
# (title,body columns). All the actual work - the per-row unique-slug
|
|
# retry and the outer transaction wrapping the whole batch - lives in
|
|
# Post.import_csv; this action is just multipart plumbing and reporting.
|
|
def import
|
|
file = params['file']
|
|
unless file.is_a?(Prism::UploadedFile) && !file.original_filename.to_s.empty?
|
|
flash[:notice] = t('posts.import_missing_file')
|
|
redirect url_for(:posts)
|
|
return
|
|
end
|
|
result = Post.import_csv(file.read, author: current_user)
|
|
flash[:notice] = t('posts.import_notice', imported: result.imported.size, skipped: result.skipped.size)
|
|
redirect url_for(:posts)
|
|
end
|
|
|
|
# The create form's submit button says "Publish" (see posts.submit in the
|
|
# locale files), so a post made here goes live immediately - status only
|
|
# ever starts at the enum's draft default for a row nothing else besides
|
|
# this action ever inserts. #unpublish/#publish (below) are how an
|
|
# existing post is pulled back to draft and put up again.
|
|
def create
|
|
attrs = params.require('post').permit(:title, :body, :featured, :giveaway_slots).to_h
|
|
attrs['status'] = :published
|
|
attrs['cover'] = store_cover(params['post']['cover'])
|
|
attrs['user_id'] = current_user.id
|
|
# Post.create_with_unique_slug (see the model), not a plain Post.new+#save
|
|
# - every post needs a slug for the unique index the add_slug_to_posts
|
|
# migration put on posts.slug, and this is the only place besides
|
|
# Post.import_csv that inserts a post at all.
|
|
post = Post.create_with_unique_slug(attrs)
|
|
|
|
if post.persisted?
|
|
respond_to do |format|
|
|
format.html do
|
|
flash[:notice] = t('posts.created_notice', id: post.id)
|
|
redirect url_for(:post, slug: post.to_param)
|
|
end
|
|
format.json { json(post.as_json(only: %i[id slug title body cover]), 201) }
|
|
end
|
|
else
|
|
respond_to do |format|
|
|
format.html do
|
|
status(422)
|
|
render(:index, locals: { posts: Post.published.order(:created_at, :desc).page(1).per(5), new_post: post, notice: nil,
|
|
errors: post.errors.full_messages, days: 0, featured_only: false })
|
|
end
|
|
format.json { render_errors(post) }
|
|
end
|
|
end
|
|
end
|
|
|
|
def edit
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
render(:edit, locals: { post: post, errors: [] })
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
# Diffs the submitted title/body against the persisted row WITHOUT
|
|
# saving anything - assign() dirty-tracks the attempt, #changes captures
|
|
# the old/new pair for each touched field, then Dirty#restore_attributes
|
|
# reverts `post` back to exactly what #save last loaded so the rest of
|
|
# this action (and the view it renders) can keep using the same instance
|
|
# as a clean, still-persisted record instead of hitting the store again.
|
|
# `attempted` is kept separately (not read back off `post`, which is now
|
|
# clean again) so the confirm form below can still resubmit what the
|
|
# visitor actually typed.
|
|
def preview
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
attempted = params.require('post').permit(:title, :body, :lock_version).to_h
|
|
post.assign(attempted)
|
|
post.valid?
|
|
diff = post.changes.reject { |field, _| field == 'lock_version' }
|
|
errors = post.errors.full_messages
|
|
post.restore_attributes
|
|
render(:preview, locals: { post: post, diff: diff, errors: errors, attempted: attempted })
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
# Only assigns the attributes the edit form actually submits (not cover/
|
|
# user_id) so Post#saved_changes below can only ever list fields the
|
|
# visitor meant to touch, then uses that dirty-tracking snapshot to tell
|
|
# them exactly what took effect instead of a generic "post updated".
|
|
# `lock_version` rides along as a permitted attribute holding whatever
|
|
# version the edit form was rendered with (not a fresh read) - see
|
|
# Post#save's optimistic-locking check, which compares that submitted
|
|
# value against the row's ACTUAL current version, not the just-loaded one.
|
|
def update
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post)
|
|
permitted = params.require('post').permit(
|
|
:title, :body, :featured, :giveaway_slots, :lock_version, comments_attributes: %i[id body _destroy]
|
|
).to_h
|
|
post.assign(permitted)
|
|
|
|
if post.save
|
|
changed_fields = post.saved_changes.keys - ['updated_at', 'lock_version']
|
|
respond_to do |format|
|
|
format.html do
|
|
flash[:notice] = changed_fields.empty? ? t('posts.update_noop_notice') : t('posts.updated_notice', fields: changed_fields.join(', '))
|
|
redirect url_for(:post, slug: post.to_param)
|
|
end
|
|
format.json { json(post.as_json(only: %i[id slug title body cover])) }
|
|
end
|
|
else
|
|
respond_to do |format|
|
|
format.html do
|
|
status(422)
|
|
render(:edit, locals: { post: post, errors: post.errors.full_messages })
|
|
end
|
|
format.json { render_errors(post) }
|
|
end
|
|
end
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
rescue Prism::Orm::StaleObjectError
|
|
respond_to do |format|
|
|
format.html do
|
|
status(409)
|
|
render(:edit, locals: { post: Post.find_by!(slug: params['slug']), errors: [t('posts.stale_notice')] })
|
|
end
|
|
format.json do
|
|
json({ errors: [jsonapi_error(status: 409, pointer: '/data/attributes/lock_version',
|
|
title: 'is stale', detail: t('posts.stale_notice'))] }, 409)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Post#acts_as_paranoid means this call moves the post to the trash
|
|
# instead of removing it for real - #trash/#restore below are how its
|
|
# own author gets it back.
|
|
def destroy
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post)
|
|
post.destroy
|
|
flash[:notice] = t('posts.deleted_notice', id: post.id)
|
|
redirect url_for(:posts)
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
# Only the signed-in author's own trashed posts - Post.only_deleted (see
|
|
# acts_as_paranoid) is the one place in this controller that deliberately
|
|
# reaches past the default paranoid scope every other action relies on
|
|
# to hide soft-deleted rows.
|
|
def trash
|
|
posts = Post.only_deleted.where(user_id: current_user.id).order(:updated_at, :desc).to_a
|
|
render(:trash, locals: { posts: posts, notice: flash[:notice] })
|
|
end
|
|
|
|
# find_by! against only_deleted (not the default Post.find, which hides
|
|
# trashed rows) - restoring a post that was never trashed, or someone
|
|
# else's, both come back as the same 404 a stranger's post id would.
|
|
def restore
|
|
# slug survives a soft delete unchanged (it's just a column, untouched
|
|
# by deleted_at) - no numeric coercion needed here the way the old
|
|
# id-keyed lookup required, since a String slug compares correctly
|
|
# against row['slug'] (also a String) with plain ==.
|
|
post = Post.only_deleted.find_by!(slug: params['slug'])
|
|
authorize(post, :restore?)
|
|
# recursive: true - a plain post.restore would only bring the post's
|
|
# own row back and leave its comments/likes trashed forever, since
|
|
# they were soft-deleted as a side effect of dependent: :destroy when
|
|
# the post itself was trashed (see Post#acts_as_paranoid).
|
|
post.restore(recursive: true)
|
|
flash[:notice] = t('posts.restored_notice', id: post.id)
|
|
redirect url_for(:trash_posts)
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
# `published!`/`draft!` come straight from Post's `enum :status` (see the
|
|
# model) - they set the column and save() in one call, so this action is
|
|
# nothing but "find, authorize like an edit, flip the status".
|
|
def publish
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
post.published!
|
|
flash[:notice] = t('posts.published_notice', id: post.id)
|
|
redirect url_for(:post, slug: post.to_param)
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
def unpublish
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
post.draft!
|
|
flash[:notice] = t('posts.unpublished_notice', id: post.id)
|
|
redirect url_for(:post, slug: post.to_param)
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
# Post#acts_as_list is scoped by `featured` (see the model) - moving a
|
|
# post that ISN'T currently featured is harmless (it just reorders it
|
|
# inside the unused non-featured group) but pointless, so both actions
|
|
# redirect back to the SAME featured=1 view the reorder links only ever
|
|
# appear on, regardless of which group the post actually belongs to.
|
|
def move_up
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
post.move_higher
|
|
redirect "#{url_for(:posts)}?featured=1"
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
def move_down
|
|
post = Post.find_by!(slug: params['slug'])
|
|
authorize(post, :update?)
|
|
post.move_lower
|
|
redirect "#{url_for(:posts)}?featured=1"
|
|
rescue Prism::Orm::RecordNotFound
|
|
respond_not_found(t('posts.not_found'))
|
|
end
|
|
|
|
private
|
|
|
|
def store_cover(upload)
|
|
return nil unless upload.is_a?(Prism::UploadedFile)
|
|
return nil if upload.original_filename.to_s.empty?
|
|
|
|
uploads_dir = File.join(APP_ROOT, 'public', 'uploads')
|
|
FileUtils.mkdir_p(uploads_dir)
|
|
safe_name = "#{Time.now.strftime('%Y%m%d%H%M%S')}-#{upload.original_filename.gsub(/[^a-zA-Z0-9_.-]/, '_')}"
|
|
File.binwrite(File.join(uploads_dir, safe_name), upload.read)
|
|
safe_name
|
|
end
|
|
end
|