Merge pull request #1 from dualfroz/feat/pending-work-2026-09
feat: land pending September work
This commit is contained in:
360 files changed
+34522
-148
No files matched your search
@@ -10,6 +10,13 @@ vendor/bundle/
|
||||
tmp/
|
||||
*.log
|
||||
|
||||
# demo user uploads
|
||||
examples/blog/public/uploads/*
|
||||
!examples/blog/public/uploads/.gitkeep
|
||||
|
||||
# compiled asset pipeline output (regenerated by Application#enable_assets on boot)
|
||||
examples/blog/public/assets/
|
||||
|
||||
# os / editor / env
|
||||
.DS_Store
|
||||
.env
|
||||
+1969
-6
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,215 @@
|
||||
# End-to-end proof of EmailConfirmationsController (this session's business
|
||||
# use of Prism::Orm::TokenFor, already generic and tested in isolation by
|
||||
# test_orm_token_for.rb/test_message_verifier.rb since sessions 100/101) -
|
||||
# through real HTTP against the actual blog app, not just User#confirmed?
|
||||
# read in a REPL. Own scratch database directory and own rate limiter
|
||||
# store, same pattern as blog_password_reset_demo.rb, so this never shares
|
||||
# data or a throttle budget with blog_demo.rb or any other standalone demo.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'fileutils'
|
||||
|
||||
ROOT = __dir__
|
||||
SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/email_confirmation_demo')
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
puts "blog_email_confirmation_demo server on port #{port}"
|
||||
puts '-' * 40
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1] || html[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
def confirmation_path_from(delivery)
|
||||
path = delivery.html_body[%r{href="http://[^"]*(/email_confirmations/[^"]+)"}, 1]
|
||||
raise 'expected an absolute confirmation link in the delivered email body' unless path
|
||||
path
|
||||
end
|
||||
|
||||
CAROL_EMAIL = 'carol@example.com'
|
||||
CAROL_NEW_EMAIL = 'carol.new@example.com'
|
||||
CAROL_PASSWORD = 'correct horse battery staple'
|
||||
worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue)
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
new_session_page = http.get('/session/new')
|
||||
cookie = cookie_from(new_session_page)
|
||||
token = csrf_token_from(new_session_page.body)
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => CAROL_EMAIL, 'user[password]' => CAROL_PASSWORD,
|
||||
'user[password_confirmation]' => CAROL_PASSWORD, 'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
||||
cookie = cookie_from(signup) unless cookie_from(signup).empty?
|
||||
puts "POST /users (sign up #{CAROL_EMAIL}) -> #{signup.code}"
|
||||
|
||||
worker.work_off
|
||||
raise 'expected exactly 2 emails from signup (welcome + confirmation instructions)' unless Prism::Mailer.deliveries.size == 2
|
||||
raise 'expected the freshly signed-up account to start out unconfirmed' if User.find_by(email: CAROL_EMAIL).confirmed?
|
||||
puts "worker drained signup's 2 emails; User#confirmed? -> false right after signup"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'requesting a fresh confirmation link via the resend form (own account is unconfirmed) - POST #1/3 of the ' \
|
||||
'email_confirmations/ip budget'
|
||||
Prism::Mailer.deliveries.clear
|
||||
resend_page = http.get('/email_confirmations/new')
|
||||
raise "expected GET /email_confirmations/new to render, got #{resend_page.code}" unless resend_page.code == '200'
|
||||
resend_cookie = cookie_from(resend_page)
|
||||
resend_token = csrf_token_from(resend_page.body)
|
||||
|
||||
resend_body = URI.encode_www_form('email_confirmation[email]' => CAROL_EMAIL, 'authenticity_token' => resend_token)
|
||||
resent = http.post('/email_confirmations', resend_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => resend_cookie)
|
||||
raise "expected POST /email_confirmations to redirect, got #{resent.code}" unless resent.code == '302'
|
||||
raise 'expected the redirect to land on the sign-in page' unless resent['location'] == '/session/new'
|
||||
puts "POST /email_confirmations (email=#{CAROL_EMAIL}) -> #{resent.code}, redirected to #{resent['location']}"
|
||||
|
||||
worker.work_off
|
||||
raise 'expected exactly one confirmation email from the resend' unless Prism::Mailer.deliveries.size == 1
|
||||
delivery = Prism::Mailer.deliveries.last
|
||||
raise "expected the delivery to go to #{CAROL_EMAIL}, got #{delivery.to.inspect}" unless delivery.to == [CAROL_EMAIL]
|
||||
confirmation_path = confirmation_path_from(delivery)
|
||||
# The path segment itself is URL-encoded (the token's own base64 padding
|
||||
# bytes need escaping in an href) - decode it before comparing against a
|
||||
# freshly minted token below, which comes back from Ruby unencoded.
|
||||
mailed_token = URI.decode_www_form_component(confirmation_path.split('/').last)
|
||||
puts "worker processed the resend job; mailed confirmation link -> #{confirmation_path}"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'the envelope is a pure function of id+fingerprint(email)+purpose with no expires_in set (unlike ' \
|
||||
'password_reset), so minting a token for the SAME still-unconfirmed account a second time - here done ' \
|
||||
'directly through the model, not by spending another throttled HTTP request - is byte-identical to the ' \
|
||||
'one that was just mailed, not a fresh, different-looking token'
|
||||
regenerated_token = User.find_by(email: CAROL_EMAIL).generate_token_for(:email_confirmation)
|
||||
raise 'expected re-minting the token for the same unconfirmed account to be byte-identical' unless regenerated_token == mailed_token
|
||||
puts ' User#generate_token_for(:email_confirmation) again -> identical token bytes'
|
||||
|
||||
puts '-' * 40
|
||||
puts 'following the mailed link confirms the account'
|
||||
confirm_page = http.get(confirmation_path)
|
||||
raise "expected the confirmation link to redirect, got #{confirm_page.code}" unless confirm_page.code == '302'
|
||||
raise 'expected confirming to redirect to the homepage' unless confirm_page['location'] == '/'
|
||||
puts "GET #{confirmation_path} -> #{confirm_page.code} Location=#{confirm_page['location']}"
|
||||
raise 'expected User#confirmed? to flip to true' unless User.find_by(email: CAROL_EMAIL).confirmed?
|
||||
puts ' User#confirmed? -> true'
|
||||
|
||||
puts '-' * 40
|
||||
puts 'revisiting the EXACT SAME confirmation link a second time is harmless (302 to the homepage again) - the ' \
|
||||
'defining difference from PasswordResetsController: that token fingerprints password_digest, which the ' \
|
||||
'reset action itself changes, so a reset link dies after one use; this token fingerprints email, which ' \
|
||||
'confirming does not touch, so the same link keeps confirming (a no-op past the first time) instead of dying'
|
||||
replayed_confirm = http.get(confirmation_path)
|
||||
raise "expected the replayed link to still redirect to '/', got #{replayed_confirm.code}/#{replayed_confirm['location']}" \
|
||||
unless replayed_confirm.code == '302' && replayed_confirm['location'] == '/'
|
||||
puts " GET #{confirmation_path} again -> #{replayed_confirm.code} Location=#{replayed_confirm['location']} " \
|
||||
'(still alive, not a dead link)'
|
||||
|
||||
puts '-' * 40
|
||||
puts "resending for #{CAROL_EMAIL} once ALREADY confirmed sends no mail at all - same enumeration-safe redirect " \
|
||||
'either way, exactly like PasswordResetsController#create for an unknown address, but the branch here is ' \
|
||||
'"already confirmed" instead of "no such account". POST #2/3 of the budget.'
|
||||
no_mail_resend_body = URI.encode_www_form('email_confirmation[email]' => CAROL_EMAIL, 'authenticity_token' => resend_token)
|
||||
no_mail_resend = http.post('/email_confirmations', no_mail_resend_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => resend_cookie)
|
||||
raise "expected the resend-while-confirmed to still redirect, got #{no_mail_resend.code}" unless no_mail_resend.code == '302'
|
||||
worker.work_off
|
||||
raise 'expected zero emails for an already-confirmed resend' unless Prism::Mailer.deliveries.size == 1
|
||||
puts "POST /email_confirmations (email=#{CAROL_EMAIL}, already confirmed) -> #{no_mail_resend.code}, no new mail sent"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'requesting a resend for an email that has NO account - identical response, no enumeration oracle. ' \
|
||||
'POST #3/3 of the budget.'
|
||||
unknown_body = URI.encode_www_form('email_confirmation[email]' => 'nobody@example.com', 'authenticity_token' => resend_token)
|
||||
unknown = http.post('/email_confirmations', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => resend_cookie)
|
||||
raise "expected the SAME 302/location for an unknown email, got #{unknown.code}/#{unknown['location']}" unless
|
||||
unknown.code == '302' && unknown['location'] == resent['location']
|
||||
worker.work_off
|
||||
raise 'expected zero emails for the unknown address' unless Prism::Mailer.deliveries.size == 1
|
||||
puts "POST /email_confirmations (unknown email) -> #{unknown.code}, identical redirect, no email actually sent"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'a garbage/unknown token redirects to the resend form instead of confirming anything (a GET, not throttled)'
|
||||
garbage = http.get('/email_confirmations/not-a-real-token')
|
||||
raise "expected a garbage token to redirect, got #{garbage.code}" unless garbage.code == '302'
|
||||
raise 'expected a garbage token to redirect to the resend form' unless garbage['location'] == '/email_confirmations/new'
|
||||
puts "GET /email_confirmations/not-a-real-token -> #{garbage.code} Location=#{garbage['location']}"
|
||||
|
||||
puts '-' * 40
|
||||
puts "checking the email_confirmations/ip throttle (app.rb, limit 3/60s) - can't be hammered into an email-bomb. " \
|
||||
'The 3 POSTs above (real resend, already-confirmed resend, unknown-email resend) already spent the whole ' \
|
||||
'budget, so this 4th one from the same IP must be rejected outright.'
|
||||
throttled = http.post('/email_confirmations', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => resend_cookie)
|
||||
raise "expected this 4th request to be throttled (429), got #{throttled.code}" unless throttled.code == '429'
|
||||
raise 'the throttled attempt should carry a Retry-After header' if throttled['retry-after'].nil?
|
||||
puts " POST /email_confirmations (4th, budget exhausted) -> #{throttled.code} (throttled, Retry-After: #{throttled['retry-after']})"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'reconfirmable: signing in and changing to a new address un-confirms the account (Devise-style ' \
|
||||
'reconfirmable), and the OLD confirmation link stops working even though it is otherwise still well-formed'
|
||||
sign_in_page = http.get('/session/new')
|
||||
sign_in_cookie = cookie_from(sign_in_page)
|
||||
sign_in_token = csrf_token_from(sign_in_page.body)
|
||||
sign_in_body = URI.encode_www_form('user[email]' => CAROL_EMAIL, 'user[password]' => CAROL_PASSWORD,
|
||||
'authenticity_token' => sign_in_token)
|
||||
signed_in = http.post('/session', sign_in_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => sign_in_cookie)
|
||||
raise "expected sign-in to succeed, got #{signed_in.code}" unless signed_in.code == '302'
|
||||
session_cookie = cookie_from(signed_in)
|
||||
session_cookie = sign_in_cookie if session_cookie.empty?
|
||||
puts "POST /session (#{CAROL_EMAIL}) -> #{signed_in.code}, signed in"
|
||||
|
||||
edit_account_page = http.get('/account/edit', 'Cookie' => session_cookie)
|
||||
raise "expected GET /account/edit to render, got #{edit_account_page.code}" unless edit_account_page.code == '200'
|
||||
account_cookie = cookie_from(edit_account_page)
|
||||
account_cookie = session_cookie if account_cookie.empty?
|
||||
account_token = csrf_token_from(edit_account_page.body)
|
||||
|
||||
change_email_body = URI.encode_www_form('_method' => 'PATCH', 'user[email]' => CAROL_NEW_EMAIL,
|
||||
'authenticity_token' => account_token)
|
||||
changed = http.post('/account', change_email_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => account_cookie)
|
||||
raise "expected the email change to redirect, got #{changed.code}" unless changed.code == '302'
|
||||
puts "PATCH /account (email -> #{CAROL_NEW_EMAIL}) -> #{changed.code}"
|
||||
raise 'expected changing email to un-confirm the account' if User.find_by(email: CAROL_NEW_EMAIL).confirmed?
|
||||
puts ' User#confirmed? -> false again (email changed, reset_confirmation_if_email_changed fired)'
|
||||
|
||||
stale_confirm = http.get(confirmation_path)
|
||||
raise "expected the OLD confirmation link to now be rejected, got #{stale_confirm.code}/#{stale_confirm['location']}" \
|
||||
unless stale_confirm.code == '302' && stale_confirm['location'] == '/email_confirmations/new'
|
||||
puts " GET #{confirmation_path} (minted for the OLD address) -> #{stale_confirm.code} " \
|
||||
"Location=#{stale_confirm['location']} (fingerprint mismatch: token carries the old email, account now " \
|
||||
'holds the new one)'
|
||||
|
||||
# Minted directly through the model, not via another throttled POST
|
||||
# /email_confirmations - the budget above is already spent for this demo
|
||||
# process' one IP, and minting is exactly what that endpoint does
|
||||
# internally anyway (see EmailConfirmationsController#create).
|
||||
fresh_token = User.find_by(email: CAROL_NEW_EMAIL).generate_token_for(:email_confirmation)
|
||||
fresh_path = "/email_confirmations/#{fresh_token}"
|
||||
fresh_confirm = http.get(fresh_path)
|
||||
raise "expected a token minted for the NEW address to confirm, got #{fresh_confirm.code}" \
|
||||
unless fresh_confirm.code == '302' && fresh_confirm['location'] == '/'
|
||||
raise 'expected User#confirmed? to be true again under the new address' unless User.find_by(email: CAROL_NEW_EMAIL).confirmed?
|
||||
puts " GET #{fresh_path} (minted directly for #{CAROL_NEW_EMAIL}) -> #{fresh_confirm.code}, confirmed again"
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'blog_email_confirmation_demo done: confirmation instructions are mailed via the real background job queue ' \
|
||||
'right after sign-up, the resend form is enumeration-safe (no mail for an already-confirmed or unknown ' \
|
||||
'address) and throttled, the mailed link confirms the account and stays valid on repeat visits (unlike a ' \
|
||||
'password reset link), and changing to a new address un-confirms the account and invalidates the OLD ' \
|
||||
"address's token while a freshly minted one for the new address still works."
|
||||
@@ -0,0 +1,153 @@
|
||||
# Proves Post#with_lock (Prism::Orm::Pessimistic, see ClaimsController#create)
|
||||
# actually prevents overselling a limited giveaway under real concurrency -
|
||||
# not threads inside one process, but several genuinely independent
|
||||
# `Prism::Server` OS processes (see blog_giveaway_worker.rb) all pointed at
|
||||
# the SAME shared database file, racing to POST /posts/:slug/claims on the
|
||||
# same post at (as close to) the same instant as a spin-wait barrier on a
|
||||
# shared "go" file can arrange.
|
||||
#
|
||||
# A post with giveaway_slots: 3 and 8 distinct entrants means at most 3 can
|
||||
# ever legitimately win a slot - if the read-count-then-decide in
|
||||
# ClaimsController#create were not held under one lock for its entire
|
||||
# critical section, several of those 8 requests could all read the same
|
||||
# "2 of 3 claimed" snapshot and all decide a slot was still free, oversold.
|
||||
require 'fileutils'
|
||||
require 'rbconfig'
|
||||
require 'net/http'
|
||||
require 'tmpdir'
|
||||
|
||||
ROOT = __dir__
|
||||
SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/giveaway_demo')
|
||||
RUBY = RbConfig.ruby
|
||||
GIVEAWAY_SLOTS = 3
|
||||
# Capped by examples/blog/app.rb's own 'logins/ip' throttle
|
||||
# (limit: 5, period: 60) - every entrant below signs in over a real POST
|
||||
# /session from this same machine's loopback address, exactly like every
|
||||
# other real visitor would, so this demo has to live within the same
|
||||
# limit a real attacker would be stopped by rather than special-casing
|
||||
# itself around it. 5 entrants racing 3 slots is still more than enough
|
||||
# to prove an oversell is impossible (2 must lose).
|
||||
ENTRANTS = 5
|
||||
|
||||
# Fresh scratch database every run, isolated from examples/blog/db/
|
||||
# development.pstore (blog_demo.rb's own data).
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
|
||||
$LOAD_PATH.unshift(File.join(ROOT, 'lib'))
|
||||
require 'prism'
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
def cookie_from(response)
|
||||
(response.get_fields('set-cookie') || []).map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
setup_server = Prism::Server.new(Blog, port: 0).start
|
||||
slug = nil
|
||||
|
||||
Net::HTTP.start('127.0.0.1', setup_server.port) do |http|
|
||||
# The host: creates the giveaway post. Entrants never author anything,
|
||||
# they only ever race to claim a slot on this one post.
|
||||
new_session = http.get('/session/new')
|
||||
cookie = cookie_from(new_session)
|
||||
token = csrf_token_from(new_session.body)
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => 'host@example.com', 'user[password]' => 'secret123',
|
||||
'user[password_confirmation]' => 'secret123', 'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "host signup failed (#{signup.code})" unless signup.code == '302'
|
||||
cookie = cookie_from(signup)
|
||||
|
||||
home = http.get('/', 'Cookie' => cookie)
|
||||
token = csrf_token_from(home.body)
|
||||
create_body = URI.encode_www_form(
|
||||
'post[title]' => 'Signed copy giveaway', 'post[body]' => 'Three signed copies, first come first served.',
|
||||
'post[giveaway_slots]' => GIVEAWAY_SLOTS.to_s, 'authenticity_token' => token
|
||||
)
|
||||
created = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "post creation failed (#{created.code})" unless created.code == '302'
|
||||
slug = created['location'][%r{/posts/([^/?]+)}, 1]
|
||||
raise 'could not extract slug from Location header' unless slug
|
||||
end
|
||||
|
||||
# ENTRANTS distinct accounts, created directly against the store rather
|
||||
# than through POST /users - each entrant still authenticates over a real
|
||||
# POST /session in blog_giveaway_worker.rb (that is the request that has
|
||||
# to survive examples/blog's real login throttle), but their signups
|
||||
# happening over HTTP too would trip the SEPARATE 'signups/ip' throttle
|
||||
# (limit: 3) with only 1 (host) + ENTRANTS of them all coming from this
|
||||
# same demo process' loopback address - a constraint about this demo
|
||||
# script issuing many signups back to back, not about the giveaway locking
|
||||
# this file exists to prove.
|
||||
ENTRANTS.times do |i|
|
||||
User.create!(
|
||||
'email' => "entrant#{i}@example.com", 'password' => 'secret123', 'password_confirmation' => 'secret123'
|
||||
)
|
||||
end
|
||||
|
||||
puts "setup done: post '#{slug}' has #{GIVEAWAY_SLOTS} giveaway slots, #{ENTRANTS} entrants about to race for them"
|
||||
|
||||
Dir.mktmpdir('giveaway_demo') do |scratch|
|
||||
go_file = File.join(scratch, 'go')
|
||||
pids_and_outputs = ENTRANTS.times.map do |i|
|
||||
label = "entrant#{i}"
|
||||
out_path = File.join(scratch, "#{label}.out")
|
||||
pid = Process.spawn(
|
||||
{ 'PRISM_BLOG_DB_DIR' => SCRATCH_DB_DIR }, RUBY, '-Ilib', 'blog_giveaway_worker.rb',
|
||||
label, "#{label}@example.com", 'secret123', slug, go_file,
|
||||
chdir: ROOT, out: out_path, err: File.join(scratch, "#{label}.err")
|
||||
)
|
||||
[pid, out_path]
|
||||
end
|
||||
|
||||
# Generous: a cold `ruby` boot of a fresh Prism::Server (migrations
|
||||
# re-checked, sign-in, a page load for a CSRF token) per worker, times
|
||||
# ENTRANTS of them all starting around the same moment, comfortably
|
||||
# finishes well under this - every worker should already be parked on
|
||||
# its spin-wait by the time this file appears.
|
||||
sleep 3
|
||||
File.write(go_file, 'go')
|
||||
|
||||
results = pids_and_outputs.map do |pid, out_path|
|
||||
_, status = Process.wait2(pid)
|
||||
raise "worker exited with #{status}, see #{out_path}" unless status.success?
|
||||
File.read(out_path).strip
|
||||
end
|
||||
|
||||
outcomes = results.each_with_object(Hash.new(0)) do |line, tally|
|
||||
_label, outcome = line.split(':')
|
||||
tally[outcome] += 1
|
||||
end
|
||||
|
||||
puts "outcomes across #{ENTRANTS} racing, fully independent server processes: #{outcomes}"
|
||||
raise "expected already_claimed count 0, got #{outcomes['already_claimed']}" unless outcomes['already_claimed'].zero?
|
||||
raise "expected unknown count 0, got #{outcomes['unknown']}" unless outcomes['unknown'].zero?
|
||||
unless outcomes['claimed'] == GIVEAWAY_SLOTS
|
||||
raise "expected exactly #{GIVEAWAY_SLOTS} claimed slots, got #{outcomes['claimed']} - Post#with_lock did not " \
|
||||
'prevent an oversell'
|
||||
end
|
||||
unless outcomes['sold_out'] == ENTRANTS - GIVEAWAY_SLOTS
|
||||
raise "expected #{ENTRANTS - GIVEAWAY_SLOTS} sold_out responses, got #{outcomes['sold_out']}"
|
||||
end
|
||||
puts "exactly #{GIVEAWAY_SLOTS} of #{ENTRANTS} racing requests won a slot, the rest correctly saw sold_out - no oversell"
|
||||
end
|
||||
|
||||
# Independent confirmation straight from the store, not just trusting the
|
||||
# workers' own HTTP responses: a brand new transaction against the same
|
||||
# file, opened fresh by THIS process, after every worker process has
|
||||
# already exited and released its own file lock.
|
||||
post = Post.find(Post.find_by!(slug: slug).id)
|
||||
claim_count = Claim.where(post_id: post.id).count
|
||||
if claim_count != GIVEAWAY_SLOTS
|
||||
raise "Claim.count on disk is #{claim_count}, expected exactly #{GIVEAWAY_SLOTS}"
|
||||
end
|
||||
if post.claims_count != GIVEAWAY_SLOTS
|
||||
raise "Post#claims_count is #{post.claims_count}, expected exactly #{GIVEAWAY_SLOTS} (counter cache drifted from the real row count)"
|
||||
end
|
||||
puts "storage-level check: Claim.where(post_id: ...).count == #{claim_count}, Post#claims_count == #{post.claims_count}, both exactly #{GIVEAWAY_SLOTS}"
|
||||
puts 'blog_giveaway_demo done: a hard-capped giveaway held its cap under real cross-process concurrency'
|
||||
@@ -0,0 +1,76 @@
|
||||
# One entrant's attempt to claim a limited giveaway slot, run as its own,
|
||||
# completely independent `ruby` process - a real second (third, fourth...)
|
||||
# `Prism::Server` instance booted against the SAME shared database file as
|
||||
# every other worker (see blog_giveaway_demo.rb, which spawns several of
|
||||
# these at once and only then releases them all together), not just
|
||||
# another thread inside one process. This is the scenario
|
||||
# ClaimsController#create's `post.with_lock` (Prism::Orm::Pessimistic) has
|
||||
# to hold up against for real: several independent app-server processes -
|
||||
# think several Puma/Unicorn workers behind a load balancer, all pointed
|
||||
# at the same database - racing the same "claim now" button on the same
|
||||
# post at (as close to) the same instant as this demo can arrange.
|
||||
#
|
||||
# ARGV: label email password slug go_file
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
label, email, password, slug, go_file = ARGV
|
||||
|
||||
def cookie_from(response)
|
||||
(response.get_fields('set-cookie') || []).map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
sign_in_page = http.get('/session/new')
|
||||
cookie = cookie_from(sign_in_page)
|
||||
token = csrf_token_from(sign_in_page.body)
|
||||
login_body = URI.encode_www_form('user[email]' => email, 'user[password]' => password, 'authenticity_token' => token)
|
||||
signed_in = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "#{label}: sign-in failed (#{signed_in.code})" unless signed_in.code == '302'
|
||||
new_cookie = cookie_from(signed_in)
|
||||
cookie = new_cookie unless new_cookie.empty?
|
||||
|
||||
show_page = http.get("/posts/#{slug}", 'Cookie' => cookie)
|
||||
raise "#{label}: could not load post page (#{show_page.code})" unless show_page.code == '200'
|
||||
claim_token = csrf_token_from(show_page.body)
|
||||
|
||||
# Every worker reaches this point at its own pace (fresh Ruby boot, fresh
|
||||
# Prism::Server, its own sign-in round trip) - spin-waiting on a file the
|
||||
# demo only creates once ALL workers have been spawned turns "started at
|
||||
# roughly the same time" into "fires the actual claim request at
|
||||
# (approximately) the same instant", which is the moment that actually
|
||||
# needs to race for this demo to mean anything.
|
||||
sleep(0.01) until File.exist?(go_file)
|
||||
|
||||
claim_body = URI.encode_www_form('authenticity_token' => claim_token)
|
||||
response = http.post("/posts/#{slug}/claims", claim_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "#{label}: claim request failed (#{response.code})" unless response.code == '302'
|
||||
# The flash notice this redirect just set lives in the session cookie
|
||||
# itself (see Prism::Session) - the Set-Cookie on THIS response, not the
|
||||
# one from the earlier sign-in, is what carries it forward.
|
||||
post_claim_cookie = cookie_from(response)
|
||||
cookie = post_claim_cookie unless post_claim_cookie.empty?
|
||||
|
||||
landed = http.get(response['location'], 'Cookie' => cookie)
|
||||
outcome =
|
||||
if landed.body.include?('You claimed a giveaway slot!')
|
||||
'claimed'
|
||||
elsif landed.body.include?('Sorry, this giveaway is sold out')
|
||||
'sold_out'
|
||||
elsif landed.body.include?('You already claimed a slot for this giveaway')
|
||||
'already_claimed'
|
||||
else
|
||||
'unknown'
|
||||
end
|
||||
puts "#{label}:#{outcome}"
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
# End-to-end proof of PasswordResetsController (session 99: Prism::Orm::
|
||||
# TokenFor, generic ActiveRecord::TokenFor-lite built on MessageVerifier's
|
||||
# new expires_in:/purpose: envelope) through real HTTP against the actual
|
||||
# blog app - not just the isolated unit coverage in test_message_verifier.rb
|
||||
# and test_orm_token_for.rb. Own scratch database directory and own
|
||||
# in-memory rate limiter store, same pattern as
|
||||
# blog_remember_sessions_demo.rb, so this never shares data or a throttle
|
||||
# budget with blog_demo.rb or any other standalone demo script.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'fileutils'
|
||||
|
||||
ROOT = __dir__
|
||||
SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/password_reset_demo')
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
puts "blog_password_reset_demo server on port #{port}"
|
||||
puts '-' * 40
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1] || html[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
EMAIL = 'forgetful@example.com'
|
||||
OLD_PASSWORD = 'correct horse battery staple'
|
||||
NEW_PASSWORD = 'a totally different passphrase'
|
||||
|
||||
def sign_in(http, password)
|
||||
page = http.get('/session/new')
|
||||
cookie = cookie_from(page)
|
||||
token = csrf_token_from(page.body)
|
||||
body = URI.encode_www_form('user[email]' => EMAIL, 'user[password]' => password, 'authenticity_token' => token)
|
||||
http.post('/session', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
end
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
new_session_page = http.get('/session/new')
|
||||
cookie = cookie_from(new_session_page)
|
||||
token = csrf_token_from(new_session_page.body)
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => EMAIL, 'user[password]' => OLD_PASSWORD, 'user[password_confirmation]' => OLD_PASSWORD,
|
||||
'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
||||
puts "POST /users (sign up #{EMAIL}) -> #{signup.code}"
|
||||
|
||||
worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue)
|
||||
worker.work_off
|
||||
Prism::Mailer.deliveries.clear
|
||||
|
||||
puts '-' * 40
|
||||
puts 'requesting a reset link for a real account'
|
||||
request_page = http.get('/password_resets/new')
|
||||
raise "expected GET /password_resets/new to render, got #{request_page.code}" unless request_page.code == '200'
|
||||
request_cookie = cookie_from(request_page)
|
||||
request_token = csrf_token_from(request_page.body)
|
||||
|
||||
request_body = URI.encode_www_form('password_reset[email]' => EMAIL, 'authenticity_token' => request_token)
|
||||
requested = http.post('/password_resets', request_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => request_cookie)
|
||||
raise "expected POST /password_resets to redirect, got #{requested.code}" unless requested.code == '302'
|
||||
raise 'expected the redirect to land on the sign-in page' unless requested['location'] == '/session/new'
|
||||
puts "POST /password_resets (email=#{EMAIL}) -> #{requested.code}, redirected to #{requested['location']}"
|
||||
|
||||
processed = worker.work_off
|
||||
raise 'expected exactly one password_reset email to have been delivered' unless Prism::Mailer.deliveries.size == 1
|
||||
delivery = Prism::Mailer.deliveries.last
|
||||
raise "expected the delivery to go to #{EMAIL}, got #{delivery.to.inspect}" unless delivery.to == [EMAIL]
|
||||
reset_path = delivery.html_body[%r{href="http://[^"]*(/password_resets/[^"]+)"}, 1]
|
||||
raise 'expected an absolute reset link in the delivered email body' unless reset_path
|
||||
reset_token = reset_path[%r{/password_resets/([^/]+)/edit}, 1]
|
||||
raise 'expected to be able to pull the raw token out of the mailed reset link' unless reset_token
|
||||
puts "worker processed #{processed} job(s); mailed reset link -> #{reset_path}"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'requesting a reset link for an email that has NO account - same response, no enumeration oracle'
|
||||
unknown_body = URI.encode_www_form('password_reset[email]' => 'nobody@example.com', 'authenticity_token' => request_token)
|
||||
unknown = http.post('/password_resets', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => request_cookie)
|
||||
raise "expected the SAME 302/location for an unknown email, got #{unknown.code}/#{unknown['location']}" unless
|
||||
unknown.code == '302' && unknown['location'] == requested['location']
|
||||
worker.work_off
|
||||
raise 'expected zero emails for the unknown address' unless Prism::Mailer.deliveries.size == 1
|
||||
puts "POST /password_resets (unknown email) -> #{unknown.code}, identical redirect, no email actually sent"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'following the real mailed link to set a new password'
|
||||
edit_page = http.get(reset_path)
|
||||
raise "expected the mailed link to render the reset form, got #{edit_page.code}" unless edit_page.code == '200'
|
||||
edit_cookie = cookie_from(edit_page)
|
||||
edit_token = csrf_token_from(edit_page.body)
|
||||
puts "GET #{reset_path} -> #{edit_page.code}"
|
||||
|
||||
update_body = URI.encode_www_form(
|
||||
'_method' => 'PATCH', 'password_reset[password]' => NEW_PASSWORD,
|
||||
'password_reset[password_confirmation]' => NEW_PASSWORD, 'authenticity_token' => edit_token
|
||||
)
|
||||
updated = http.post(reset_path.sub('/edit', ''), update_body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => edit_cookie)
|
||||
raise "expected the password update to redirect, got #{updated.code}" unless updated.code == '302'
|
||||
raise 'expected the update to redirect to sign-in' unless updated['location'] == '/session/new'
|
||||
puts "PATCH #{reset_path.sub('/edit', '')} (new password) -> #{updated.code}"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'the OLD password no longer authenticates; the NEW one does'
|
||||
old_attempt = sign_in(http, OLD_PASSWORD)
|
||||
raise "expected the old password to be rejected (422), got #{old_attempt.code}" unless old_attempt.code == '422'
|
||||
puts " POST /session with the OLD password -> #{old_attempt.code} (rejected)"
|
||||
|
||||
new_attempt = sign_in(http, NEW_PASSWORD)
|
||||
raise "expected the new password to sign in (302), got #{new_attempt.code}" unless new_attempt.code == '302'
|
||||
puts " POST /session with the NEW password -> #{new_attempt.code} (signed in)"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'the SAME reset link cannot be replayed - resetting a password changes password_digest, which is the ' \
|
||||
"fingerprint User.generates_token_for(:password_reset) baked into the token, so it's a one-time link " \
|
||||
'without any separate "used" column'
|
||||
replayed_edit = http.get(reset_path)
|
||||
raise "expected the already-used link to redirect away instead of rendering, got #{replayed_edit.code}" unless
|
||||
replayed_edit.code == '302'
|
||||
raise 'expected the replay to land back on the request-a-new-link page' unless
|
||||
replayed_edit['location'] == '/password_resets/new'
|
||||
puts " GET #{reset_path} again -> #{replayed_edit.code}, redirected to #{replayed_edit['location']} (dead link)"
|
||||
|
||||
puts '-' * 40
|
||||
puts "checking the password_resets/ip throttle (app.rb, limit 3/60s) - can't be hammered into an email-bomb"
|
||||
# The "real account" and "unknown email" requests above already spent 2 of
|
||||
# the 3-per-60s budget on this same IP, so only ONE more request here
|
||||
# still succeeds before every following one is throttled.
|
||||
3.times do |i|
|
||||
body = URI.encode_www_form('password_reset[email]' => EMAIL, 'authenticity_token' => request_token)
|
||||
attempt = http.post('/password_resets', body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => request_cookie)
|
||||
expected = i.zero? ? '302' : '429'
|
||||
raise "attempt #{i + 1}: expected #{expected}, got #{attempt.code}" unless attempt.code == expected
|
||||
raise 'the throttled attempt should carry a Retry-After header' if i == 1 && attempt['retry-after'].nil?
|
||||
puts " attempt #{i + 1}/3 (5th/6th/7th POST /password_resets overall) -> " \
|
||||
"#{attempt.code}#{i.zero? ? '' : " (throttled, Retry-After: #{attempt['retry-after']})"}"
|
||||
end
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'blog_password_reset_demo done: forgot-password link is mailed via the real background job queue, resolves ' \
|
||||
'through real HTTP to a working password change, the old password stops working and the new one starts, ' \
|
||||
'the exact same link cannot be replayed afterwards, an unknown email gets an identical non-committal ' \
|
||||
'response, and the request endpoint itself is rate-limited.'
|
||||
@@ -0,0 +1,45 @@
|
||||
# Exercises Prism::RateLimiter::PStoreStore (wired into examples/blog in
|
||||
# this session, see app.rb) as a real, multi-process lifecycle: trip the
|
||||
# logins/ip throttle from one OS process, kill it, then boot a completely
|
||||
# independent second process and prove its very first request already
|
||||
# comes back throttled - the durability MemoryStore could never give,
|
||||
# proven the same way blog_rotation_demo.rb proves key rotation: each
|
||||
# phase is a genuinely separate `ruby` invocation (see
|
||||
# blog_rate_limit_worker.rb), not just a different in-process call.
|
||||
#
|
||||
# Repeats the same lifecycle for signups/ip, the OTHER throttle rule
|
||||
# sharing this PStoreStore file - a prior session proved only logins/ip
|
||||
# survives a restart; nothing had shown the two rules do not somehow
|
||||
# clobber each other's window in the same file (they are keyed by rule
|
||||
# name, not just by IP, but that was only true by reading the code).
|
||||
require 'fileutils'
|
||||
require 'rbconfig'
|
||||
|
||||
ROOT = __dir__
|
||||
DB_DIR = File.join(ROOT, 'examples/blog/db/rate_limit_demo')
|
||||
RUBY = RbConfig.ruby
|
||||
|
||||
# Fresh scratch database every run, isolated from examples/blog/db/
|
||||
# development.pstore (blog_demo.rb's own data) - re-running this file is
|
||||
# always deterministic and never fights blog_demo.rb for the same throttle
|
||||
# window.
|
||||
FileUtils.rm_rf(DB_DIR)
|
||||
|
||||
def run_phase(phase)
|
||||
puts '-' * 60
|
||||
puts "phase: #{phase}"
|
||||
pid = Process.spawn({ 'PRISM_BLOG_DB_DIR' => DB_DIR }, RUBY, '-Ilib', 'blog_rate_limit_worker.rb', phase, chdir: ROOT)
|
||||
_, status = Process.wait2(pid)
|
||||
raise "phase #{phase} failed with #{status}" unless status.success?
|
||||
end
|
||||
|
||||
run_phase('trip')
|
||||
run_phase('confirm_still_blocked')
|
||||
run_phase('trip_signup')
|
||||
run_phase('confirm_signup_still_blocked')
|
||||
|
||||
puts '-' * 60
|
||||
puts 'blog_rate_limit_persistence_demo done: both the login throttle and the independent signup throttle ' \
|
||||
'tripped by one process were still enforced by a completely independent later process against the ' \
|
||||
'same PStoreStore file - a plain process restart (deploy, crash, worker respawn) does not hand an ' \
|
||||
'attacker a fresh throttle window for either rule, and the two rules did not clobber each other.'
|
||||
@@ -0,0 +1,101 @@
|
||||
# Runs ONE phase of the rate-limiter persistence lifecycle against
|
||||
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
||||
# new `ruby` process by blog_rate_limit_persistence_demo.rb (never required
|
||||
# directly) - the whole point is that Prism::RateLimiter.store is now a
|
||||
# Prism::RateLimiter::PStoreStore (see examples/blog/app.rb), so a ban or a
|
||||
# throttle window tripped by one process must still be in effect for a
|
||||
# completely fresh process that never made any of the earlier requests
|
||||
# itself, exactly like a real deploy restarting the app mid-attack.
|
||||
#
|
||||
# Covers both throttles examples/blog declares against the same
|
||||
# PStoreStore file (logins/ip and signups/ip) - session 85 only proved
|
||||
# logins/ip durability; signups/ip shares the store but was never
|
||||
# exercised across a real restart of its own.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
phase = ARGV[0]
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
login_body = URI.encode_www_form(
|
||||
'user[email]' => 'nobody@example.com', 'user[password]' => 'wrong password', 'authenticity_token' => token
|
||||
)
|
||||
# Mismatched confirmation is a validation failure (422) that never
|
||||
# touches the uniqueness index, so the same body can be POSTed
|
||||
# repeatedly without tripping User's unique-email constraint instead of
|
||||
# the throttle we actually want to exercise.
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => 'nobody@example.com', 'user[password]' => 'secret123',
|
||||
'user[password_confirmation]' => 'does not match', 'authenticity_token' => token
|
||||
)
|
||||
|
||||
case phase
|
||||
when 'trip'
|
||||
# logins/ip is throttle(limit: 5, period: 60) in examples/blog/app.rb -
|
||||
# attempts 1..5 are the credentials check itself (422, wrong password),
|
||||
# attempt 6 never reaches the controller at all.
|
||||
5.times do |i|
|
||||
attempt = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "trip attempt #{i + 1}: expected 422 (invalid credentials), got #{attempt.code}" unless attempt.code == '422'
|
||||
end
|
||||
sixth = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "trip attempt 6: expected 429 (throttled), got #{sixth.code}" unless sixth.code == '429'
|
||||
puts 'trip: 5 attempts allowed through to the controller, the 6th throttled with 429 - process now exiting'
|
||||
when 'confirm_still_blocked'
|
||||
# Brand new process, brand new Ruby-level RateLimiter::Rule registry
|
||||
# (throttles are re-declared by requiring app.rb again) and a brand new
|
||||
# in-memory window if the store were MemoryStore - the ONLY way this
|
||||
# first request can already be 429, with zero attempts made by THIS
|
||||
# process, is if the PStoreStore file from the 'trip' phase is still
|
||||
# being read.
|
||||
immediate = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
unless immediate.code == '429'
|
||||
raise "expected the very first request from a fresh process to already be throttled (durable state), got #{immediate.code}"
|
||||
end
|
||||
raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil?
|
||||
puts 'confirm_still_blocked: a fresh process inherited the ban/window from the previous process via PStoreStore - ' \
|
||||
"first request already 429, Retry-After=#{immediate['retry-after']}"
|
||||
when 'trip_signup'
|
||||
# signups/ip is throttle(limit: 3, period: 60) in examples/blog/app.rb -
|
||||
# attempts 1..3 are the validation check itself (422, password
|
||||
# mismatch), attempt 4 never reaches the controller at all.
|
||||
3.times do |i|
|
||||
attempt = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "trip_signup attempt #{i + 1}: expected 422 (mismatched confirmation), got #{attempt.code}" unless attempt.code == '422'
|
||||
end
|
||||
fourth = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "trip_signup attempt 4: expected 429 (throttled), got #{fourth.code}" unless fourth.code == '429'
|
||||
puts 'trip_signup: 3 attempts allowed through to the controller, the 4th throttled with 429 - process now exiting'
|
||||
when 'confirm_signup_still_blocked'
|
||||
# Same durability claim as confirm_still_blocked above, for the
|
||||
# independent signups/ip throttle rule.
|
||||
immediate = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
unless immediate.code == '429'
|
||||
raise "expected the very first signup request from a fresh process to already be throttled (durable state), got #{immediate.code}"
|
||||
end
|
||||
raise 'the still-blocked response should carry a Retry-After header' if immediate['retry-after'].nil?
|
||||
puts 'confirm_signup_still_blocked: a fresh process inherited the signups/ip window from the previous process via ' \
|
||||
"PStoreStore - first request already 429, Retry-After=#{immediate['retry-after']}"
|
||||
else
|
||||
raise "unknown phase #{phase.inspect} (expected trip, confirm_still_blocked, trip_signup or confirm_signup_still_blocked)"
|
||||
end
|
||||
end
|
||||
|
||||
server.stop
|
||||
@@ -0,0 +1,149 @@
|
||||
# End-to-end proof of Prism::Authenticatable's max_remember_sessions:
|
||||
# pruning (see lib/prism/authenticatable.rb#prune_oldest_login_sessions),
|
||||
# which ApplicationController configures for examples/blog as
|
||||
# `max_remember_sessions: 3` alongside `remember_sessions: LoginSession`.
|
||||
# blog_demo.rb's own multi-device scene only ever gets to exactly 3
|
||||
# remembered devices (at the limit, never over it), so it never actually
|
||||
# exercises the prune - and cannot, without recalculating every expected
|
||||
# status code in its later `logins/ip` throttle-hammering loop (limit 5
|
||||
# per 60s; 4 more real POST /session attempts here would blow that budget
|
||||
# before the loop even starts). This is a separate process instead, with
|
||||
# its own fresh in-memory Prism::RateLimiter store and its own scratch
|
||||
# database directory (PRISM_BLOG_DB_DIR), so it never has to share a
|
||||
# throttle budget - or any data - with blog_demo.rb at all.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'fileutils'
|
||||
|
||||
ROOT = __dir__
|
||||
SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/remember_sessions_demo')
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
puts "blog_remember_sessions_demo server on port #{port}"
|
||||
puts '-' * 40
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def cookie_value_from(response, name)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
line = lines.find { |l| l.start_with?("#{name}=") }
|
||||
line&.split(';')&.first
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
EMAIL = 'devices@example.com'
|
||||
PASSWORD = 'correct horse battery staple'
|
||||
|
||||
# Signs in as EMAIL from a brand new, independent cookie jar (own session
|
||||
# cookie, own CSRF token, own User-Agent) with remember_me=1 - a real
|
||||
# separate browser/device. Returns [session_cookie, remember_cookie] so
|
||||
# the caller can act as "this device" afterwards (session_cookie, for
|
||||
# GET /login_sessions with the right current_session) as well as prove
|
||||
# the persistent cookie alone still authenticates later on
|
||||
# (remember_cookie, with no session cookie at all).
|
||||
def sign_in_device(http, label, user_agent:)
|
||||
page = http.get('/session/new', 'User-Agent' => user_agent)
|
||||
device_cookie = cookie_from(page)
|
||||
device_token = csrf_token_from(page.body)
|
||||
body = URI.encode_www_form(
|
||||
'user[email]' => EMAIL, 'user[password]' => PASSWORD, 'user[remember_me]' => '1',
|
||||
'authenticity_token' => device_token
|
||||
)
|
||||
signed_in = http.post('/session', body, 'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Cookie' => device_cookie, 'User-Agent' => user_agent)
|
||||
raise "expected #{label} to sign in, got #{signed_in.code}" unless signed_in.code == '302'
|
||||
fresh_session_cookie = cookie_from(signed_in)
|
||||
session_cookie = fresh_session_cookie.empty? ? device_cookie : fresh_session_cookie
|
||||
remember_cookie = cookie_value_from(signed_in, 'remember_session_token')
|
||||
raise "expected #{label} to get its own remember_session_token cookie" unless remember_cookie
|
||||
puts " #{label} signed in (remember_me=1) -> #{signed_in.code}, own remember-me cookie set"
|
||||
[session_cookie, remember_cookie]
|
||||
end
|
||||
|
||||
def other_session_ids(http, session_cookie)
|
||||
page = http.get('/login_sessions', 'Cookie' => session_cookie)
|
||||
raise "expected GET /login_sessions to succeed, got #{page.code}" unless page.code == '200'
|
||||
page.body.scan(/class="login-session" id="login-session-(\d+)"/).flatten.map(&:to_i)
|
||||
end
|
||||
|
||||
def alive?(http, remember_cookie)
|
||||
http.get('/login_sessions', 'Cookie' => remember_cookie).code == '200'
|
||||
end
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
new_session_page = http.get('/session/new')
|
||||
cookie = cookie_from(new_session_page)
|
||||
token = csrf_token_from(new_session_page.body)
|
||||
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => EMAIL, 'user[password]' => PASSWORD, 'user[password_confirmation]' => PASSWORD,
|
||||
'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
||||
puts "POST /users (sign up #{EMAIL}) -> #{signup.code}"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'three independent devices sign in with remember_me=1 - exactly at max_remember_sessions: 3, ' \
|
||||
'nothing should be pruned yet'
|
||||
_p_session, p_remember = sign_in_device(http, 'device P', user_agent: 'DeviceP-Desktop/1.0')
|
||||
_q_session, q_remember = sign_in_device(http, 'device Q', user_agent: 'DeviceQ-Mobile/1.0')
|
||||
r_session, r_remember = sign_in_device(http, 'device R', user_agent: 'DeviceR-Tablet/1.0')
|
||||
|
||||
at_limit_others = other_session_ids(http, r_session)
|
||||
unless at_limit_others.size == 2
|
||||
raise "expected exactly 2 OTHER remembered devices (P and Q) alongside R itself at the limit, " \
|
||||
"got #{at_limit_others.inspect}"
|
||||
end
|
||||
puts "GET /login_sessions as device R -> 200, itself plus #{at_limit_others.size} others (P, Q) - " \
|
||||
'at the limit, no pruning yet'
|
||||
raise 'expected device P to still be alive exactly at the limit' unless alive?(http, p_remember)
|
||||
raise 'expected device Q to still be alive exactly at the limit' unless alive?(http, q_remember)
|
||||
puts ' both P and Q still authenticate via their remember-me cookie alone'
|
||||
|
||||
puts '-' * 40
|
||||
puts 'a FOURTH device signs in, pushing the account one over max_remember_sessions: 3 - ' \
|
||||
'Prism::Authenticatable#prune_oldest_login_sessions should destroy the OLDEST row (device P)'
|
||||
s_session, s_remember = sign_in_device(http, 'device S', user_agent: 'DeviceS-Laptop/1.0')
|
||||
|
||||
over_limit_others = other_session_ids(http, s_session)
|
||||
unless over_limit_others.size == 2
|
||||
raise "expected pruning to keep the count at 2 OTHER devices (Q and R) alongside S itself, " \
|
||||
"got #{over_limit_others.inspect}"
|
||||
end
|
||||
puts "GET /login_sessions as device S -> 200, itself plus #{over_limit_others.size} others - " \
|
||||
'still exactly at the limit after the 4th sign-in, not 4'
|
||||
|
||||
raise 'expected device P (the oldest) to have been pruned' if alive?(http, p_remember)
|
||||
puts " device P's remember-me cookie is now dead (its LoginSession row was destroyed) -> " \
|
||||
'redirected to sign-in instead of 200'
|
||||
|
||||
raise 'expected device Q to survive the prune (only the OLDEST row is destroyed)' unless alive?(http, q_remember)
|
||||
raise 'expected device R to survive the prune' unless alive?(http, r_remember)
|
||||
raise 'expected the newly created device S to be alive' unless alive?(http, s_remember)
|
||||
puts ' devices Q, R and S (everything except the pruned oldest one) all still authenticate via their own ' \
|
||||
'remember-me cookie'
|
||||
|
||||
pruned_id = (at_limit_others - over_limit_others).first
|
||||
raise 'expected exactly one id to have dropped out of the device list after the prune' unless pruned_id
|
||||
raise "expected R's own device list to also no longer show the pruned id" if other_session_ids(http, r_session).include?(pruned_id)
|
||||
puts " device R's own list agrees: login-session-#{pruned_id} (device P) is gone from EVERY account member's view, " \
|
||||
'not just S\'s'
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'blog_remember_sessions_demo done: max_remember_sessions: 3 stays at exactly 3 remembered devices per ' \
|
||||
'account - signing in a 4th prunes the single oldest row (never the newest, never more than one), and ' \
|
||||
'every survivor keeps authenticating on its own remember-me cookie afterwards.'
|
||||
@@ -0,0 +1,46 @@
|
||||
# Exercises Prism::Orm::Encryption key rotation as a real, multi-process
|
||||
# lifecycle against examples/blog: provision a user under secret v1 -> boot
|
||||
# the app with v2 as current and v1 as a previous_keys fallback (the
|
||||
# "transition period" of a real rotation) -> retire v1 completely and prove
|
||||
# the migrated row still works. Each phase is a genuinely separate `ruby`
|
||||
# process (see blog_rotation_worker.rb) because Prism::Orm::Encryption
|
||||
# derives and memoizes each model's encryption key once per class per
|
||||
# process - rotating the configured secret only matters if something
|
||||
# actually re-reads Application#enable_attribute_encryption's config from
|
||||
# scratch, which means a fresh boot, not an in-process reconfiguration.
|
||||
require 'fileutils'
|
||||
require 'rbconfig'
|
||||
|
||||
ROOT = __dir__
|
||||
DB_DIR = File.join(ROOT, 'examples/blog/db/rotation_demo')
|
||||
RUBY = RbConfig.ruby
|
||||
|
||||
# Fresh scratch database every run, isolated from examples/blog/db/
|
||||
# development.pstore (blog_demo.rb's own data) - re-running this file is
|
||||
# always deterministic and never fights blog_demo.rb for the same rows.
|
||||
FileUtils.rm_rf(DB_DIR)
|
||||
|
||||
V1_SECRET = 'blog-demo-encryption-secret-v1'.freeze
|
||||
V2_SECRET = 'blog-demo-encryption-secret-v2'.freeze
|
||||
|
||||
def run_phase(phase, env)
|
||||
puts '-' * 60
|
||||
puts "phase: #{phase}"
|
||||
base_env = { 'PRISM_BLOG_DB_DIR' => DB_DIR, 'PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS' => '' }
|
||||
pid = Process.spawn(base_env.merge(env), RUBY, '-Ilib', 'blog_rotation_worker.rb', phase, chdir: ROOT)
|
||||
_, status = Process.wait2(pid)
|
||||
raise "phase #{phase} failed with #{status}" unless status.success?
|
||||
end
|
||||
|
||||
run_phase('signup', 'PRISM_BLOG_ENCRYPTION_SECRET' => V1_SECRET)
|
||||
|
||||
run_phase('rotate_and_login',
|
||||
'PRISM_BLOG_ENCRYPTION_SECRET' => V2_SECRET,
|
||||
'PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS' => V1_SECRET)
|
||||
|
||||
run_phase('post_rotation_final', 'PRISM_BLOG_ENCRYPTION_SECRET' => V2_SECRET)
|
||||
|
||||
puts '-' * 60
|
||||
puts 'blog_rotation_demo done: v1-provisioned user survived rotation to v2 (fallback login while ' \
|
||||
'unmigrated, re-save migrated the row, v1 fully retired afterwards); a second, never-migrated ' \
|
||||
'user was correctly locked out (not crashed) once v1 was fully retired - all over real HTTP.'
|
||||
@@ -0,0 +1,204 @@
|
||||
# Runs ONE phase of the encryption key rotation lifecycle against
|
||||
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
||||
# new `ruby` process by blog_rotation_demo.rb (never required directly) -
|
||||
# Prism::Orm::Encryption derives and caches each model's encryption key
|
||||
# once per class per process (see encryption_key_for), so genuinely retiring
|
||||
# an old secret and adopting a new one needs a fresh process with fresh ENV,
|
||||
# not just a different in-process config call.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
V1_SECRET = 'blog-demo-encryption-secret-v1'.freeze
|
||||
V2_SECRET = 'blog-demo-encryption-secret-v2'.freeze
|
||||
EMAIL = 'rotation@example.com'.freeze
|
||||
EMAIL_AFTER_UPDATE = 'rotation-updated@example.com'.freeze
|
||||
PASSWORD = 'correct horse battery staple v2'.freeze
|
||||
EMAIL_SALT = 'prism-orm-encryption/users/email'.freeze
|
||||
|
||||
# A second row provisioned alongside the first but NEVER re-saved during
|
||||
# the transition window (no login, no account update in 'rotate_and_login')
|
||||
# - the realistic case of a row an operator forgot about, or a user who
|
||||
# simply never signs back in before the cutover deadline. Same password
|
||||
# for both so post_rotation_final's failing login attempt is unambiguously
|
||||
# about the ciphertext, not a typo'd credential.
|
||||
STRANDED_EMAIL = 'stranded@example.com'.freeze
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
# Independent of anything User/Encryption has cached in this process -
|
||||
# derives a key straight from a raw secret and tries to decrypt with it,
|
||||
# so a phase can prove exactly WHICH secret a given row's ciphertext is
|
||||
# really tied to, not just that "some configured key" happens to work.
|
||||
def decrypts_under?(secret, ciphertext)
|
||||
key = Prism::MessageEncryptor.derive_key(secret, salt: EMAIL_SALT)
|
||||
Prism::Orm::Encryption.decrypt(key, ciphertext)
|
||||
rescue Prism::Orm::Encryption::Error
|
||||
nil
|
||||
end
|
||||
|
||||
phase = ARGV[0]
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
|
||||
case phase
|
||||
when 'signup'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
|
||||
body = URI.encode_www_form(
|
||||
'user[email]' => EMAIL, 'user[password]' => PASSWORD,
|
||||
'user[password_confirmation]' => PASSWORD, 'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
||||
|
||||
user = User.find_by(email: EMAIL)
|
||||
raise 'expected to find the freshly signed-up user by plaintext email under the v1 key' unless user
|
||||
|
||||
row = Prism::Orm.store.find(:users, user.id)
|
||||
ciphertext = row['email']
|
||||
raise 'expected users.email to be stored encrypted, not plaintext' if ciphertext == EMAIL
|
||||
|
||||
v1_plaintext = decrypts_under?(V1_SECRET, ciphertext)
|
||||
raise "expected the row to decrypt under v1, got #{v1_plaintext.inspect}" unless v1_plaintext == EMAIL
|
||||
raise 'expected the row NOT to decrypt under v2 yet (never encrypted with it)' if decrypts_under?(V2_SECRET, ciphertext)
|
||||
|
||||
puts 'signup: user provisioned, email ciphertext independently confirmed tied to the v1 secret only'
|
||||
|
||||
# Second row, provisioned the same way, that 'rotate_and_login' will
|
||||
# deliberately leave untouched - the row this whole scenario is about.
|
||||
stranded_body = URI.encode_www_form(
|
||||
'user[email]' => STRANDED_EMAIL, 'user[password]' => PASSWORD,
|
||||
'user[password_confirmation]' => PASSWORD, 'authenticity_token' => token
|
||||
)
|
||||
stranded_signup = http.post('/users', stranded_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the stranded user's sign-up to redirect, got #{stranded_signup.code}" unless stranded_signup.code == '302'
|
||||
raise 'expected to find the stranded user right after signing up' unless User.find_by(email: STRANDED_EMAIL)
|
||||
|
||||
puts 'signup: second user provisioned under v1 too - rotate_and_login will deliberately never touch this one'
|
||||
end
|
||||
when 'rotate_and_login'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
|
||||
# The app is now running with v2 as the CURRENT key and v1 only as a
|
||||
# previous_keys fallback - this row has never been touched since
|
||||
# 'signup' wrote it under v1, so finding it at all here is the real
|
||||
# proof that Encryption.decrypt_with_rotation's fallback (and
|
||||
# encrypt_for_query's multi-candidate IN-list) both work over a real
|
||||
# request cycle, not just against a fixture in a unit test.
|
||||
login_body = URI.encode_www_form('user[email]' => EMAIL, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
||||
signed_in = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-in on a still-v1-encrypted row to succeed via previous_keys, got #{signed_in.code}" unless signed_in.code == '302'
|
||||
cookie = cookie_from(signed_in)
|
||||
|
||||
edit_page = http.get('/account/edit', 'Cookie' => cookie)
|
||||
raise "expected the account edit page to render, got #{edit_page.code}" unless edit_page.code == '200'
|
||||
edit_token = csrf_token_from(edit_page.body)
|
||||
|
||||
# Changing the email is what actually forces a fresh Encryption.encrypt
|
||||
# call (the setter's no-op guard only skips re-encrypting when the
|
||||
# plaintext is unchanged) - the natural way a real user's row gets
|
||||
# migrated onto the new key, exactly as Prism::Orm::Encryption's own
|
||||
# comments describe ("any row that gets explicitly re-saved").
|
||||
update_body = URI.encode_www_form('user[email]' => EMAIL_AFTER_UPDATE, '_method' => 'PATCH', 'authenticity_token' => edit_token)
|
||||
updated = http.post('/account', update_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the account update to redirect, got #{updated.code}" unless updated.code == '302'
|
||||
|
||||
user = User.find_by(email: EMAIL_AFTER_UPDATE)
|
||||
raise 'expected to find the user by their new plaintext email right after updating it' unless user
|
||||
|
||||
row = Prism::Orm.store.find(:users, user.id)
|
||||
ciphertext = row['email']
|
||||
v2_plaintext = decrypts_under?(V2_SECRET, ciphertext)
|
||||
raise "expected the re-saved row to now decrypt under v2, got #{v2_plaintext.inspect}" unless v2_plaintext == EMAIL_AFTER_UPDATE
|
||||
raise 'expected the re-saved row NOT to decrypt under the retired v1 secret anymore' if decrypts_under?(V1_SECRET, ciphertext)
|
||||
|
||||
puts 'rotate_and_login: signed in via the v1 fallback, updated the email, row now migrated onto v2 only'
|
||||
|
||||
# Deliberately NOT touched here: the stranded user's row stays exactly
|
||||
# as 'signup' left it, still encrypted under v1 only, still findable
|
||||
# right now only because this phase still carries v1 in previous_keys.
|
||||
raise 'expected the stranded user to still be reachable during the transition window' unless User.find_by(email: STRANDED_EMAIL)
|
||||
puts 'rotate_and_login: stranded user deliberately left untouched (still v1-only on disk)'
|
||||
end
|
||||
when 'post_rotation_final'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
|
||||
# v1 has been fully retired (PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS is
|
||||
# unset for this phase) - the migrated row from 'rotate_and_login' must
|
||||
# still authenticate on v2 alone, proving the cutover is safe to
|
||||
# complete once every row has actually been re-saved.
|
||||
login_body = URI.encode_www_form('user[email]' => EMAIL_AFTER_UPDATE, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
||||
signed_in = http.post('/session', login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-in on the fully-migrated row to succeed with v1 fully retired, got #{signed_in.code}" unless signed_in.code == '302'
|
||||
|
||||
puts 'post_rotation_final: migrated user still signs in with v1 completely retired (no previous_keys configured)'
|
||||
|
||||
# The risk this whole scenario documents: cutting v1 over BEFORE every
|
||||
# row has actually been re-saved locks out whoever was never migrated.
|
||||
# find_by can't even locate the stranded row anymore - encrypt_for_query
|
||||
# only has v2 to build candidate ciphertext with, so it builds the WRONG
|
||||
# ciphertext for a still-v1 row and the deterministic lookup just misses
|
||||
# (an ordinary index miss, not a raised error) - which surfaces to the
|
||||
# user as an ordinary "invalid credentials" rejection, not a crash.
|
||||
stranded_login_body = URI.encode_www_form('user[email]' => STRANDED_EMAIL, 'user[password]' => PASSWORD, 'authenticity_token' => token)
|
||||
stranded_login = http.post('/session', stranded_login_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the stranded (never-migrated) user's login to be rejected as invalid credentials, got #{stranded_login.code}" unless stranded_login.code == '422'
|
||||
raise 'expected the stranded login rejection page to actually explain why' unless stranded_login.body.include?('Invalid email or password')
|
||||
|
||||
# Independent proof of exactly WHY it failed: the row on disk never
|
||||
# moved off v1 (nobody re-saved it during the transition window), and
|
||||
# v2 alone genuinely cannot read it - scanned directly off the store
|
||||
# (find_by can't be used here, that's precisely the query path that
|
||||
# just failed above for the same reason).
|
||||
stranded_row = Prism::Orm.store.all(:users).find { |row| decrypts_under?(V1_SECRET, row['email']) == STRANDED_EMAIL }
|
||||
raise 'expected the stranded row to still be findable by scanning raw ciphertext under v1' unless stranded_row
|
||||
raise 'expected the stranded row to NOT decrypt under v2 (this is the whole point: it was never re-saved)' if decrypts_under?(V2_SECRET, stranded_row['email'])
|
||||
|
||||
puts 'post_rotation_final: stranded (never-migrated) user correctly locked out after full cutover - ' \
|
||||
'confirmed the row is genuinely still v1-only on disk, not a false negative'
|
||||
|
||||
# The login rejection above is a SILENT miss (encrypt_for_query builds
|
||||
# the wrong ciphertext candidate and the lookup just finds nothing, an
|
||||
# ordinary "invalid credentials" 422). Reading the same row's `.email`
|
||||
# directly - by primary key, not through the encrypted-attribute query
|
||||
# path - is a genuinely different failure shape: `find` never touches
|
||||
# ciphertext (it looks up by plain `id`), so the row loads fine, and it
|
||||
# is only the `encrypts :email` reader itself that has no key left to
|
||||
# try (current v2 fails, and previous_keys is empty with v1 fully
|
||||
# retired) - `decrypt_with_rotation` re-raises the original
|
||||
# Encryption::Error instead of returning nil or garbage. Documents the
|
||||
# contrast on purpose: a query silently sees nothing, a direct read
|
||||
# loudly blows up.
|
||||
stranded_user = User.find(stranded_row['id'])
|
||||
begin
|
||||
stranded_user.email
|
||||
raise 'expected reading .email directly on a fully-retired-key row to raise Encryption::Error, it did not raise at all'
|
||||
rescue Prism::Orm::Encryption::Error
|
||||
puts 'post_rotation_final: direct .email read on the stranded row raised Encryption::Error as expected ' \
|
||||
'(no key left that can decrypt it), unlike the silent find_by miss above'
|
||||
end
|
||||
end
|
||||
else
|
||||
raise "unknown phase #{phase.inspect} (expected signup, rotate_and_login, or post_rotation_final)"
|
||||
end
|
||||
|
||||
server.stop
|
||||
@@ -0,0 +1,82 @@
|
||||
# End-to-end proof of `prism db:seed` (Prism::Cli#db_seed, lib/prism/cli.rb)
|
||||
# against examples/blog: runs the real bin/prism executable as its own OS
|
||||
# process (the actual CLI entrypoint a developer or a deploy script would
|
||||
# invoke, not just the Ruby API) against a scratch database, twice in a row
|
||||
# to prove examples/blog/db/seeds.rb is idempotent (find_by-before-create,
|
||||
# not create!-every-time), then boots the seeded database through the real
|
||||
# app and a real HTTP GET / to prove the seeded posts are what a visitor
|
||||
# actually sees - not just rows sitting in a pstore nobody rendered.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'fileutils'
|
||||
require 'rbconfig'
|
||||
|
||||
ROOT = __dir__
|
||||
BLOG_DIR = File.join(ROOT, 'examples/blog')
|
||||
SCRATCH_DB_DIR = File.join(BLOG_DIR, 'db/seed_demo')
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
|
||||
def run_prism_cli(*args)
|
||||
env = { 'PRISM_BLOG_DB_DIR' => SCRATCH_DB_DIR }
|
||||
out = IO.popen(env, [RbConfig.ruby, File.join(ROOT, 'bin/prism'), *args],
|
||||
chdir: BLOG_DIR, err: %i[child out], &:read)
|
||||
status = $?
|
||||
raise "prism #{args.join(' ')} failed (#{status}):\n#{out}" unless status.success?
|
||||
out
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'running: bin/prism db:seed --require=app.rb (real subprocess, real CLI entrypoint, scratch database)'
|
||||
first_run = run_prism_cli('db:seed', '--require=app.rb')
|
||||
puts first_run
|
||||
raise 'expected the first db:seed run to report success' unless first_run.include?('seeded from db/seeds.rb')
|
||||
unless first_run.include?('users=2 posts=3 comments=2')
|
||||
raise 'expected the seeds file to report 2 users/3 posts/2 comments'
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'running it again to prove examples/blog/db/seeds.rb is idempotent, not just db:seed itself'
|
||||
second_run = run_prism_cli('db:seed', '--require=app.rb')
|
||||
puts second_run
|
||||
unless second_run.include?('users=2 posts=3 comments=2')
|
||||
raise 'expected the second db:seed run to leave the same counts behind, not double them'
|
||||
end
|
||||
|
||||
store = Prism::Orm::Store.new(File.join(SCRATCH_DB_DIR, 'development.pstore'))
|
||||
raise 'expected exactly 2 rows in users after seeding twice' unless store.count('users') == 2
|
||||
raise 'expected exactly 3 rows in posts after seeding twice' unless store.count('posts') == 3
|
||||
raise 'expected exactly 2 rows in comments after seeding twice' unless store.count('comments') == 2
|
||||
puts " store.count directly off the pstore confirms: users=#{store.count('users')} posts=#{store.count('posts')} comments=#{store.count('comments')}"
|
||||
|
||||
puts '-' * 40
|
||||
puts 'booting examples/blog against the seeded database and fetching GET / over real HTTP'
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
begin
|
||||
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/"))
|
||||
raise "expected GET / to succeed, got #{response.code}" unless response.code == '200'
|
||||
# The main <ul class="posts"> listing is PostsController#index's own
|
||||
# Post.published scope - the sidebar's "recent posts" widget just above it
|
||||
# is a separate, deliberately unscoped Post.order(...).limit(5) (see
|
||||
# examples/blog/app/views/posts/index.erb) that shows every post
|
||||
# regardless of status, drafts included, so only the main list is the
|
||||
# right place to check that a draft stays unpublished.
|
||||
main_list = response.body[/<ul class="posts">.*?<\/ul>/m]
|
||||
raise 'expected a <ul class="posts"> listing on the homepage' unless main_list
|
||||
['Welcome to the blog', 'A second seeded post'].each do |title|
|
||||
raise "expected the homepage's post list to include the seeded post #{title.inspect}" unless main_list.include?(title)
|
||||
end
|
||||
if main_list.include?('An unpublished draft')
|
||||
raise 'expected the seeded draft post to stay off the published post list'
|
||||
end
|
||||
puts ' GET / -> 200, both published seeded posts are in the main list, the seeded draft is correctly excluded from it'
|
||||
ensure
|
||||
server.stop
|
||||
end
|
||||
|
||||
puts '-' * 40
|
||||
puts 'blog_seed_demo done'
|
||||
@@ -0,0 +1,44 @@
|
||||
# Exercises Prism::Middleware::Session/Prism::CookieJar's `previous_secrets:`
|
||||
# rotation as a real, multi-process lifecycle against examples/blog - the
|
||||
# session-cookie counterpart to blog_rotation_demo.rb (which exercises the
|
||||
# same rotation contract for Prism::Orm::Encryption's `previous_keys:`
|
||||
# instead). See blog_session_secret_rotation_worker.rb for what each phase
|
||||
# actually proves over real HTTP.
|
||||
require 'fileutils'
|
||||
require 'rbconfig'
|
||||
|
||||
ROOT = __dir__
|
||||
DB_DIR = File.join(ROOT, 'examples/blog/db/session_rotation_demo')
|
||||
RUBY = RbConfig.ruby
|
||||
|
||||
# Fresh scratch database every run, isolated from examples/blog/db/
|
||||
# development.pstore (blog_demo.rb's own data) - re-running this file is
|
||||
# always deterministic and never fights blog_demo.rb for the same rows.
|
||||
FileUtils.rm_rf(DB_DIR)
|
||||
FileUtils.mkdir_p(DB_DIR)
|
||||
|
||||
V1_SECRET = 'blog-demo-session-secret-v1'.freeze
|
||||
V2_SECRET = 'blog-demo-session-secret-v2'.freeze
|
||||
|
||||
def run_phase(phase, env)
|
||||
puts '-' * 60
|
||||
puts "phase: #{phase}"
|
||||
base_env = { 'PRISM_BLOG_DB_DIR' => DB_DIR, 'PRISM_BLOG_SESSION_PREVIOUS_SECRETS' => '' }
|
||||
pid = Process.spawn(base_env.merge(env), RUBY, '-Ilib', 'blog_session_secret_rotation_worker.rb', phase, chdir: ROOT)
|
||||
_, status = Process.wait2(pid)
|
||||
raise "phase #{phase} failed with #{status}" unless status.success?
|
||||
end
|
||||
|
||||
run_phase('signup', 'PRISM_BLOG_SESSION_SECRET' => V1_SECRET)
|
||||
|
||||
run_phase('rotate',
|
||||
'PRISM_BLOG_SESSION_SECRET' => V2_SECRET,
|
||||
'PRISM_BLOG_SESSION_PREVIOUS_SECRETS' => V1_SECRET)
|
||||
|
||||
run_phase('post_rotation_final', 'PRISM_BLOG_SESSION_SECRET' => V2_SECRET)
|
||||
|
||||
puts '-' * 60
|
||||
puts 'blog_session_secret_rotation_demo done: a session written to during the rotation window (user A, via a ' \
|
||||
'real post creation) was transparently re-signed onto the new secret and survived full cutover; a session ' \
|
||||
'never written to again after signing in under the retired secret (user B) was correctly logged out, not ' \
|
||||
'crashed, once that secret was fully retired - all over real HTTP across independent processes.'
|
||||
@@ -0,0 +1,139 @@
|
||||
# Runs ONE phase of the SESSION secret rotation lifecycle against
|
||||
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
||||
# new `ruby` process by blog_session_secret_rotation_demo.rb (never required
|
||||
# directly) - Application#enable_sessions reads PRISM_BLOG_SESSION_SECRET/
|
||||
# PRISM_BLOG_SESSION_PREVIOUS_SECRETS once at boot, so rotating them for real
|
||||
# needs a fresh process, not just a different in-process config call.
|
||||
#
|
||||
# Mirrors blog_rotation_worker.rb's three-phase shape (provision -> rotate
|
||||
# -> retire), but for Prism::Middleware::Session/Prism::CookieJar's
|
||||
# `previous_secrets:` instead of Prism::Orm::Encryption's `previous_keys:`.
|
||||
# The key difference this phase split deliberately surfaces: an encrypted
|
||||
# attribute migrates onto the new key the moment its row is next SAVED,
|
||||
# while a session cookie only gets re-signed onto the new secret when the
|
||||
# session itself is next MUTATED - a page view alone (no session write)
|
||||
# leaves the cookie signed under whatever secret was current when it was
|
||||
# last written. User A creates a post during the transition window (a real
|
||||
# session write, via the flash notice it sets) and survives full cutover;
|
||||
# user B never does anything else after signing up and is correctly logged
|
||||
# out once the secret they were signed in under is fully retired.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'fileutils'
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
COOKIE_A_V1_PATH = File.join(DB_DIR, 'cookie_a_v1.txt')
|
||||
COOKIE_B_V1_PATH = File.join(DB_DIR, 'cookie_b_v1.txt')
|
||||
COOKIE_A_V2_PATH = File.join(DB_DIR, 'cookie_a_v2.txt')
|
||||
|
||||
EMAIL_A = 'rotation-a@example.com'.freeze
|
||||
EMAIL_B = 'rotation-b@example.com'.freeze
|
||||
PASSWORD = 'correct horse battery staple v2'.freeze
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
def sign_up(http, email)
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
body = URI.encode_www_form(
|
||||
'user[email]' => email, 'user[password]' => PASSWORD, 'user[password_confirmation]' => PASSWORD,
|
||||
'authenticity_token' => token
|
||||
)
|
||||
response = http.post('/users', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up for #{email} to redirect, got #{response.code}" unless response.code == '302'
|
||||
cookie_from(response)
|
||||
end
|
||||
|
||||
phase = ARGV[0]
|
||||
|
||||
server = Prism::Server.new(Blog, port: 0).start
|
||||
port = server.port
|
||||
|
||||
case phase
|
||||
when 'signup'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
File.write(COOKIE_A_V1_PATH, sign_up(http, EMAIL_A))
|
||||
puts 'signup: user A signed up and signed in under v1 - will write to their session during the rotation window'
|
||||
|
||||
File.write(COOKIE_B_V1_PATH, sign_up(http, EMAIL_B))
|
||||
puts 'signup: user B signed up and signed in under v1 too - deliberately left untouched from here on, stranded on v1'
|
||||
end
|
||||
when 'rotate'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
cookie_a = File.read(COOKIE_A_V1_PATH).strip
|
||||
cookie_b = File.read(COOKIE_B_V1_PATH).strip
|
||||
|
||||
# The app is now running with v2 as the CURRENT session secret and v1
|
||||
# only as a previous_secrets fallback - both users' cookies were never
|
||||
# touched since 'signup' wrote them under v1, so both still working here
|
||||
# is the real proof that Middleware::Session's rotation fallback holds
|
||||
# over a genuine request cycle, not just a unit-test fixture.
|
||||
edit_a = http.get('/account/edit', 'Cookie' => cookie_a)
|
||||
raise "expected user A's v1-signed session to still authenticate via previous_secrets, got #{edit_a.code}" unless edit_a.code == '200'
|
||||
|
||||
edit_b = http.get('/account/edit', 'Cookie' => cookie_b)
|
||||
raise "expected user B's v1-signed session to still authenticate via previous_secrets, got #{edit_b.code}" unless edit_b.code == '200'
|
||||
puts 'rotate: both v1-signed sessions still authenticate under v2-current/v1-previous'
|
||||
|
||||
# Creating a post is a real session WRITE (it sets flash[:notice] before
|
||||
# redirecting) - the natural way an ordinary user's session actually
|
||||
# gets re-signed onto the current secret, exactly as
|
||||
# Prism::Session.decode's rotation comment describes ("the next time it
|
||||
# changes at all"). Unlike Orm::Encryption there is no explicit "resave"
|
||||
# step to call; any mutating request does it for free.
|
||||
edit_token = csrf_token_from(edit_a.body)
|
||||
create_body = URI.encode_www_form(
|
||||
'post[title]' => 'Written during the secret rotation window',
|
||||
'post[body]' => 'This post exists to force a session write while v2 is current.',
|
||||
'authenticity_token' => edit_token
|
||||
)
|
||||
created = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie_a)
|
||||
raise "expected creating a post to redirect, got #{created.code}" unless created.code == '302'
|
||||
|
||||
cookie_a_v2 = cookie_from(created)
|
||||
raise 'expected the flash-notice write to actually re-sign the session cookie' if cookie_a_v2.empty?
|
||||
raise 'expected the re-signed cookie to differ from the original v1-signed one' if cookie_a_v2 == cookie_a
|
||||
File.write(COOKIE_A_V2_PATH, cookie_a_v2)
|
||||
puts 'rotate: user A wrote to their session (created a post) and got re-signed onto v2 in the same response'
|
||||
|
||||
# Deliberately NOT touched again: user B's cookie stays exactly as
|
||||
# 'signup' left it, still signed under v1 only.
|
||||
puts 'rotate: user B deliberately left untouched (still v1-signed on the wire)'
|
||||
end
|
||||
when 'post_rotation_final'
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
cookie_a_v2 = File.read(COOKIE_A_V2_PATH).strip
|
||||
cookie_b_v1 = File.read(COOKIE_B_V1_PATH).strip
|
||||
|
||||
# v1 has been fully retired (PRISM_BLOG_SESSION_PREVIOUS_SECRETS is
|
||||
# unset for this phase) - user A's session, re-signed onto v2 during the
|
||||
# 'rotate' phase, must still authenticate with nothing but v2 configured.
|
||||
edit_a = http.get('/account/edit', 'Cookie' => cookie_a_v2)
|
||||
raise "expected user A's rotated (v2-signed) session to survive full cutover, got #{edit_a.code}" unless edit_a.code == '200'
|
||||
puts 'post_rotation_final: user A (session written during the transition window) still authenticated with v1 fully retired'
|
||||
|
||||
# The risk this whole scenario documents: a session nobody ever wrote to
|
||||
# again during the transition window is still signed under the secret
|
||||
# that just got fully retired - Prism::Session.decode has nothing left
|
||||
# that verifies it, so it silently comes back as an empty session (not
|
||||
# a crash), and the very next protected page redirects to the login
|
||||
# screen exactly as it would for any other signed-out visitor.
|
||||
edit_b = http.get('/account/edit', 'Cookie' => cookie_b_v1)
|
||||
raise "expected user B's never-rewritten v1-only session to be logged out after full cutover, got #{edit_b.code}" unless edit_b.code == '302'
|
||||
raise 'expected the logged-out redirect to point at the login page' unless edit_b['Location'].to_s.end_with?('/session/new')
|
||||
puts 'post_rotation_final: user B (never wrote to their session again) correctly logged out after v1 was fully retired'
|
||||
end
|
||||
else
|
||||
raise "unknown phase #{phase.inspect} (expected signup, rotate, or post_rotation_final)"
|
||||
end
|
||||
|
||||
server.stop
|
||||
@@ -0,0 +1,254 @@
|
||||
# End-to-end proof of PostActivityChannel (examples/blog/app/channels/
|
||||
# post_activity_channel.rb) - the WebSocket + Hub broadcaster from lib/
|
||||
# prism/websocket wired into a real controller for the first time.
|
||||
# Everything below happens over a real TCPSocket doing its own HTTP
|
||||
# Upgrade handshake by hand (same style as test_websocket_integration.rb),
|
||||
# alongside ordinary Net::HTTP requests hitting the SAME running server -
|
||||
# proving the two protocols coexist on one Prism::Server, and that an
|
||||
# actual CommentsController#approve/#reject/#destroy over HTTP is what
|
||||
# pushes the broadcast, not a hand-triggered Hub call.
|
||||
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||
require 'prism'
|
||||
require 'net/http'
|
||||
require 'socket'
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
|
||||
ROOT = __dir__
|
||||
SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/websocket_demo')
|
||||
FileUtils.rm_rf(SCRATCH_DB_DIR)
|
||||
ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR
|
||||
|
||||
require_relative 'examples/blog/app'
|
||||
|
||||
# max_websocket_connections: 3 mirrors examples/blog/bin/server (which uses
|
||||
# a realistic 2000) but small enough that this demo's own three concurrent
|
||||
# viewers below (viewer_a, viewer_b, bystander) can actually saturate it,
|
||||
# proving the cap for real instead of just asserting the option is accepted.
|
||||
server = Prism::Server.new(Blog, port: 0, max_websocket_connections: 3).start
|
||||
port = server.port
|
||||
puts "blog_websocket_demo server on port #{port}"
|
||||
puts '-' * 40
|
||||
|
||||
Frame = Prism::WebSocket::Frame
|
||||
Handshake = Prism::WebSocket::Handshake
|
||||
|
||||
def cookie_from(response)
|
||||
lines = response.get_fields('set-cookie') || []
|
||||
lines.map { |line| line.split(';').first }.join('; ')
|
||||
end
|
||||
|
||||
def csrf_token_from(html)
|
||||
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
# Hand-rolled Upgrade handshake over a raw TCPSocket, deliberately not
|
||||
# reusing anything from Net::HTTP - an Upgrade request is not an ordinary
|
||||
# HTTP request/response cycle (see Prism::Server#serve intercepting it
|
||||
# before @app.call ever runs), so this proves the real wire protocol,
|
||||
# the same way test_websocket_integration.rb does for the library itself.
|
||||
def open_cable(host, port, post_id)
|
||||
socket = TCPSocket.new(host, port)
|
||||
key = [Array.new(16) { rand(256) }.pack('C16')].pack('m0')
|
||||
socket.write(
|
||||
"GET /cable?post_id=#{post_id} HTTP/1.1\r\n" \
|
||||
"Host: #{host}\r\n" \
|
||||
"Upgrade: websocket\r\n" \
|
||||
"Connection: Upgrade\r\n" \
|
||||
"Sec-WebSocket-Key: #{key}\r\n" \
|
||||
"Sec-WebSocket-Version: 13\r\n" \
|
||||
"\r\n"
|
||||
)
|
||||
headers = +''
|
||||
until headers.end_with?("\r\n\r\n")
|
||||
line = socket.gets("\r\n")
|
||||
raise 'connection closed mid-handshake' if line.nil?
|
||||
headers << line
|
||||
end
|
||||
raise "expected a 101 handshake, got: #{headers}" unless headers.include?('101')
|
||||
expected_accept = Handshake.accept_key(key)
|
||||
raise 'Sec-WebSocket-Accept did not match the RFC 6455 formula' unless headers.include?(expected_accept)
|
||||
socket
|
||||
end
|
||||
|
||||
# Same handshake request as open_cable, but for proving max_websocket_
|
||||
# connections rejects it BEFORE any Upgrade: the server must answer with an
|
||||
# ordinary HTTP 503 (not a 101, and not a partial/dropped connection), read
|
||||
# off the raw socket exactly like open_cable reads a successful handshake.
|
||||
def attempt_rejected_cable(host, port, post_id)
|
||||
socket = TCPSocket.new(host, port)
|
||||
key = [Array.new(16) { rand(256) }.pack('C16')].pack('m0')
|
||||
socket.write(
|
||||
"GET /cable?post_id=#{post_id} HTTP/1.1\r\n" \
|
||||
"Host: #{host}\r\n" \
|
||||
"Upgrade: websocket\r\n" \
|
||||
"Connection: Upgrade\r\n" \
|
||||
"Sec-WebSocket-Key: #{key}\r\n" \
|
||||
"Sec-WebSocket-Version: 13\r\n" \
|
||||
"\r\n"
|
||||
)
|
||||
headers = +''
|
||||
until headers.end_with?("\r\n\r\n")
|
||||
line = socket.gets("\r\n")
|
||||
raise 'connection closed before any HTTP status line arrived' if line.nil?
|
||||
headers << line
|
||||
end
|
||||
socket.close
|
||||
headers
|
||||
end
|
||||
|
||||
def read_event(socket)
|
||||
JSON.parse(Frame.decode(socket).payload)
|
||||
end
|
||||
|
||||
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||
home = http.get('/')
|
||||
cookie = cookie_from(home)
|
||||
token = csrf_token_from(home.body)
|
||||
puts "GET / -> #{home.code}"
|
||||
|
||||
signup_body = URI.encode_www_form(
|
||||
'user[email]' => 'moderator@example.com', 'user[password]' => 'correct horse battery staple',
|
||||
'user[password_confirmation]' => 'correct horse battery staple', 'authenticity_token' => token
|
||||
)
|
||||
signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302'
|
||||
cookie = cookie_from(signup)
|
||||
puts "POST /users (sign up as moderator) -> #{signup.code}"
|
||||
|
||||
create_body = URI.encode_www_form(
|
||||
'post[title]' => 'Live comment counts', 'post[body]' => 'Watching this page update without a reload.',
|
||||
'authenticity_token' => token
|
||||
)
|
||||
create = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the post to be created, got #{create.code}" unless create.code == '302'
|
||||
post_path = create['location']
|
||||
post_id = post_path.split('/').last
|
||||
cookie = cookie_from(create)
|
||||
puts "POST /posts -> #{create.code} Location=#{post_path}"
|
||||
|
||||
# Two independent viewers open the post's live channel before any
|
||||
# comment exists - each gets its own subscribed acknowledgement, proving
|
||||
# the Hub keys subscribers by stream (this post's id), not just "anyone
|
||||
# connected right now".
|
||||
viewer_a = open_cable('127.0.0.1', port, post_id)
|
||||
viewer_b = open_cable('127.0.0.1', port, post_id)
|
||||
welcome_a = read_event(viewer_a)
|
||||
welcome_b = read_event(viewer_b)
|
||||
raise "expected viewer_a's subscribe ack, got #{welcome_a.inspect}" unless welcome_a == { 'event' => 'subscribed', 'post_id' => post_id }
|
||||
raise "expected viewer_b's subscribe ack, got #{welcome_b.inspect}" unless welcome_b == { 'event' => 'subscribed', 'post_id' => post_id }
|
||||
puts "GET /cable?post_id=#{post_id} x2 -> 101 Switching Protocols, both subscribed"
|
||||
|
||||
# A third connection opens a channel for a DIFFERENT (nonexistent) post -
|
||||
# it must never see this post's events, proving streams are genuinely
|
||||
# isolated per post, not one shared firehose.
|
||||
bystander = open_cable('127.0.0.1', port, '999999')
|
||||
read_event(bystander) # subscribe ack, discarded
|
||||
puts "GET /cable?post_id=999999 -> 101 (unrelated post, must stay silent below)"
|
||||
|
||||
# viewer_a, viewer_b, and bystander are now three live WebSocket
|
||||
# connections on this process - exactly the max_websocket_connections
|
||||
# cap set above. A fourth, entirely unrelated stream must be turned away
|
||||
# BEFORE the Upgrade handshake even starts, proving the limit is global
|
||||
# (per-process) rather than PostActivityChannel::MAX_VIEWERS_PER_POST's
|
||||
# per-stream cap, which would never trip with only three viewers total.
|
||||
rejection_headers = attempt_rejected_cable('127.0.0.1', port, post_id)
|
||||
raise "expected a 503, got: #{rejection_headers}" unless rejection_headers.include?('503')
|
||||
puts "GET /cable?post_id=#{post_id} (4th concurrent WS connection) -> 503 Service Unavailable " \
|
||||
'(max_websocket_connections: 3 reached, rejected before any Upgrade)'
|
||||
|
||||
comment_body = URI.encode_www_form('comment[author]' => 'Reader', 'comment[body]' => 'First!', 'authenticity_token' => token)
|
||||
comment = http.post("/posts/#{post_id}/comments", comment_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the comment to be created, got #{comment.code}" unless comment.code == '302'
|
||||
cookie = cookie_from(comment)
|
||||
show = http.get(post_path, 'Cookie' => cookie)
|
||||
comment_id = show.body[/id="comment-(\d+)"/, 1]
|
||||
raise 'could not find the new comment id on the post page' unless comment_id
|
||||
puts "POST /posts/#{post_id}/comments -> #{comment.code} (still pending, no broadcast yet)"
|
||||
|
||||
approve_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
|
||||
approve = http.post("/posts/#{post_id}/comments/#{comment_id}/approve", approve_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the approval to redirect, got #{approve.code}" unless approve.code == '302'
|
||||
cookie = cookie_from(approve)
|
||||
|
||||
event_a = read_event(viewer_a)
|
||||
event_b = read_event(viewer_b)
|
||||
expected = { 'event' => 'comment_count', 'post_id' => post_id, 'count' => 1 }
|
||||
raise "viewer_a expected #{expected.inspect}, got #{event_a.inspect}" unless event_a == expected
|
||||
raise "viewer_b expected #{expected.inspect}, got #{event_b.inspect}" unless event_b == expected
|
||||
puts "POST /posts/#{post_id}/comments/#{comment_id}/approve -> #{approve.code}, " \
|
||||
"both live viewers pushed {event: comment_count, count: 1} over the real socket"
|
||||
|
||||
reject_body = URI.encode_www_form('_method' => 'PATCH', 'authenticity_token' => token)
|
||||
reject = http.post("/posts/#{post_id}/comments/#{comment_id}/reject", reject_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the rejection to redirect, got #{reject.code}" unless reject.code == '302'
|
||||
cookie = cookie_from(reject)
|
||||
|
||||
event_a = read_event(viewer_a)
|
||||
expected = { 'event' => 'comment_count', 'post_id' => post_id, 'count' => 0 }
|
||||
raise "viewer_a expected #{expected.inspect} after rejection, got #{event_a.inspect}" unless event_a == expected
|
||||
puts "POST /posts/#{post_id}/comments/#{comment_id}/reject -> #{reject.code}, " \
|
||||
"live viewer pushed {event: comment_count, count: 0} (approved -> rejected demotes the count back down)"
|
||||
|
||||
# Re-approve, then destroy outright - the third mutation that can move
|
||||
# the visible count, and the last one this demo has left to prove.
|
||||
http.post("/posts/#{post_id}/comments/#{comment_id}/approve", approve_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
read_event(viewer_a) # count -> 1 again, already proven above
|
||||
|
||||
destroy_body = URI.encode_www_form('_method' => 'DELETE', 'authenticity_token' => token)
|
||||
destroy = http.post("/posts/#{post_id}/comments/#{comment_id}", destroy_body,
|
||||
'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||
raise "expected the destroy to redirect, got #{destroy.code}" unless destroy.code == '302'
|
||||
|
||||
event_a = read_event(viewer_a)
|
||||
event_b = read_event(viewer_b)
|
||||
expected = { 'event' => 'comment_count', 'post_id' => post_id, 'count' => 0 }
|
||||
raise "viewer_a expected #{expected.inspect} after destroy, got #{event_a.inspect}" unless event_a == expected
|
||||
raise "viewer_b expected #{expected.inspect} after destroy, got #{event_b.inspect}" unless event_b == expected
|
||||
puts "DELETE /posts/#{post_id}/comments/#{comment_id} -> #{destroy.code}, " \
|
||||
'both live viewers pushed {event: comment_count, count: 0} for the removed comment'
|
||||
|
||||
bystander.write(Frame.encode(Frame::PING, '', mask: true))
|
||||
reply = Frame.decode(bystander)
|
||||
raise "expected the unrelated channel's socket to only ever see our own PONG, got opcode #{reply.opcode}" unless reply.opcode == Frame::PONG
|
||||
puts "bystander subscribed to post_id=999999 received ZERO comment_count events for post #{post_id} " \
|
||||
'(streams are isolated per post, confirmed by the only frame available being our own PONG)'
|
||||
|
||||
viewer_a.write(Frame.encode(Frame::CLOSE, '', mask: true))
|
||||
close_reply = Frame.decode(viewer_a)
|
||||
raise "expected a CLOSE frame echoed back, got opcode #{close_reply.opcode}" unless close_reply.opcode == Frame::CLOSE
|
||||
puts 'viewer_a sent a close frame -> server echoed CLOSE back (RFC 6455 5.5.1), on_close unsubscribed it from the Hub'
|
||||
|
||||
# examples/blog/app.rb registers '/cable' with max_message_size: 2048,
|
||||
# far below the library's 16 MiB default - proven here the same way
|
||||
# test_websocket_integration.rb proves the option in the library itself,
|
||||
# but this time through the actual application's real configuration
|
||||
# instead of a purpose-built test endpoint.
|
||||
oversized_viewer = open_cable('127.0.0.1', port, post_id)
|
||||
read_event(oversized_viewer) # subscribe ack, discarded
|
||||
oversized_viewer.write(Frame.encode(Frame::TEXT, 'x' * 3000, mask: true))
|
||||
close_frame = Frame.decode(oversized_viewer)
|
||||
raise "expected a CLOSE frame, got opcode #{close_frame.opcode}" unless close_frame.opcode == Frame::CLOSE
|
||||
close_code = close_frame.payload.byteslice(0, 2).unpack1('n')
|
||||
raise "expected close code 1009 (message too big), got #{close_code}" unless close_code == 1009
|
||||
puts "sent a 3000 byte frame on /cable (max_message_size: 2048 in examples/blog/app.rb) " \
|
||||
"-> server closed with code #{close_code} (message too big), without disturbing viewer_b or bystander"
|
||||
oversized_viewer.close
|
||||
|
||||
[viewer_a, viewer_b, bystander].each { |socket| socket.close unless socket.closed? }
|
||||
|
||||
still_alive = http.get('/')
|
||||
raise "expected the plain HTTP server to still be healthy after the WebSocket traffic, got #{still_alive.code}" unless still_alive.code == '200'
|
||||
puts "GET / -> #{still_alive.code} (ordinary HTTP routes on the same server are unaffected by the cable traffic above)"
|
||||
end
|
||||
|
||||
server.stop
|
||||
puts '-' * 40
|
||||
puts 'blog_websocket_demo done: PostActivityChannel subscribed two real WebSocket clients to one post ' \
|
||||
'and kept a third, unrelated one silent; three genuine HTTP moderation actions (approve/reject/destroy) ' \
|
||||
'each pushed a live {event: comment_count} frame to every subscriber over the real socket, and the close ' \
|
||||
'handshake correctly unsubscribed a departing viewer from the shared Hub.'
|
||||
+210
-3
@@ -1,23 +1,230 @@
|
||||
require 'prism'
|
||||
require 'fileutils'
|
||||
|
||||
APP_ROOT = __dir__
|
||||
|
||||
Prism::Orm.connect(File.join(APP_ROOT, 'db', 'development.pstore'))
|
||||
# Both overridable by ENV so a second, independent OS process (see
|
||||
# rotation_worker.rb) can boot this exact app against its own scratch
|
||||
# database and its own encryption secret without touching development.pstore
|
||||
# - key rotation genuinely retires the OLD secret from the running process'
|
||||
# memoized, per-model derived keys, so exercising it for real needs a fresh
|
||||
# `ruby` invocation, not just a different in-process config call.
|
||||
DB_DIR = ENV.fetch('PRISM_BLOG_DB_DIR', File.join(APP_ROOT, 'db'))
|
||||
FileUtils.mkdir_p(DB_DIR)
|
||||
|
||||
Prism::Orm.connect(File.join(DB_DIR, 'development.pstore'))
|
||||
Prism::Orm.migrator(File.join(APP_ROOT, 'db', 'migrate')).migrate!
|
||||
|
||||
Prism::Jobs.default_queue = Prism::Jobs::Queue.new(File.join(DB_DIR, 'jobs.pstore'))
|
||||
Prism::Mailer.delivery_method = :test
|
||||
|
||||
# PStoreStore instead of the default in-memory RateLimiter::MemoryStore -
|
||||
# throttle windows and fail2ban-style bans survive a process restart the
|
||||
# same way development.pstore/jobs.pstore already do. Without this, an
|
||||
# attacker who gets banned right before a deploy (a plain process restart)
|
||||
# would find the ban silently gone the moment the new process comes up,
|
||||
# since MemoryStore keeps every window and ban only in that one process'
|
||||
# RAM. See blog_rate_limit_worker.rb for the two-process proof.
|
||||
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(File.join(DB_DIR, 'rate_limits.pstore'))
|
||||
|
||||
Prism::I18n.default_locale = :en
|
||||
Prism::I18n.load_path << File.join(APP_ROOT, 'config', 'locales')
|
||||
Prism::I18n.load_translations!
|
||||
|
||||
LOG_DIR = File.join(APP_ROOT, 'log')
|
||||
Dir.mkdir(LOG_DIR) unless Dir.exist?(LOG_DIR)
|
||||
DEVELOPMENT_LOG = File.open(File.join(LOG_DIR, 'development.log'), 'a')
|
||||
DEVELOPMENT_LOG.sync = true
|
||||
Prism::Instrumentation::LogSubscriber.attach(
|
||||
DEVELOPMENT_LOG,
|
||||
patterns: %w[sql.prism render_template.prism process_action.prism perform_job.prism deliver_mail.prism]
|
||||
)
|
||||
|
||||
Prism::Controller.views_root(File.join(APP_ROOT, 'app', 'views'))
|
||||
Prism::Controller.layout(:application)
|
||||
|
||||
Dir[File.join(APP_ROOT, 'app', 'models', '*.rb')].sort.each { |file| require file }
|
||||
Dir[File.join(APP_ROOT, 'app', 'mailers', '*.rb')].sort.each { |file| require file }
|
||||
Dir[File.join(APP_ROOT, 'app', 'policies', '*.rb')].sort.each { |file| require file }
|
||||
Dir[File.join(APP_ROOT, 'app', 'channels', '*.rb')].sort.each { |file| require file }
|
||||
# application_controller.rb has to load before every other controller
|
||||
# (they all subclass it) - a plain alphabetical sort no longer guarantees
|
||||
# that on its own now that account_controller.rb sorts ahead of it.
|
||||
require File.join(APP_ROOT, 'app', 'controllers', 'application_controller.rb')
|
||||
Dir[File.join(APP_ROOT, 'app', 'controllers', '*.rb')].sort.each { |file| require file }
|
||||
|
||||
# req.path is the raw PATH_INFO - the router only strips a trailing
|
||||
# ".json" (etc) internally while matching, so without stripping it here
|
||||
# too a POST to /session.json would silently dodge this throttle
|
||||
# entirely (same account, same IP, just a different extension).
|
||||
def throttled_path(req, base)
|
||||
req.path.sub(%r{\.[^./]+\z}, '') == base
|
||||
end
|
||||
|
||||
Prism::RateLimiter.throttle('logins/ip', limit: 5, period: 60) do |req|
|
||||
throttled_path(req, '/session') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||
end
|
||||
|
||||
Prism::RateLimiter.throttle('signups/ip', limit: 3, period: 60) do |req|
|
||||
throttled_path(req, '/users') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||
end
|
||||
|
||||
# Same shape as signups/ip above, same reason: #create's response is
|
||||
# identical whether or not the email exists (see the comment on
|
||||
# PasswordResetsController#create), but without a limit here that endpoint
|
||||
# would still let an attacker hammer arbitrary addresses with reset emails
|
||||
# for free.
|
||||
Prism::RateLimiter.throttle('password_resets/ip', limit: 3, period: 60) do |req|
|
||||
throttled_path(req, '/password_resets') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||
end
|
||||
|
||||
# Same shape and reason as password_resets/ip above: #create's response is
|
||||
# identical whether or not the address exists or is already confirmed, but
|
||||
# without a limit here that endpoint would let an attacker hammer arbitrary
|
||||
# addresses with confirmation emails for free.
|
||||
Prism::RateLimiter.throttle('email_confirmations/ip', limit: 3, period: 60) do |req|
|
||||
throttled_path(req, '/email_confirmations') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||
end
|
||||
|
||||
# Two separate rules on purpose: the .json API is meant to be embeddable
|
||||
# by anyone (a "latest posts" widget on a third-party site), but only the
|
||||
# trusted admin dashboard origin gets to write through it, and only that
|
||||
# origin is allowed to send the session cookie cross-origin at all. The
|
||||
# specific origin is registered FIRST - Prism::Cors.resource_for returns
|
||||
# the first rule whose origin matches, and '*' matches every origin, so a
|
||||
# wildcard rule ahead of this one would shadow it for every request.
|
||||
Prism::Cors.configure do |config|
|
||||
config.allow do
|
||||
origins 'https://admin.blog-demo.internal'
|
||||
resource '/posts', methods: %w[GET POST], headers: %w[Content-Type X-CSRF-Token], credentials: true
|
||||
resource '/posts/*', methods: %w[GET PATCH DELETE], headers: %w[Content-Type X-CSRF-Token], credentials: true
|
||||
end
|
||||
|
||||
config.allow do
|
||||
origins '*'
|
||||
resource '/posts', methods: %w[GET]
|
||||
resource '/posts/*', methods: %w[GET]
|
||||
end
|
||||
end
|
||||
|
||||
class Blog < Prism::Application
|
||||
set :views, File.join(APP_ROOT, 'app', 'views')
|
||||
enable_sessions(secret: 'blog-demo-secret')
|
||||
enable_sessions(
|
||||
secret: ENV.fetch('PRISM_BLOG_SESSION_SECRET', 'blog-demo-secret'),
|
||||
previous_secrets: ENV['PRISM_BLOG_SESSION_PREVIOUS_SECRETS'].to_s.split(',')
|
||||
)
|
||||
enable_attribute_encryption(
|
||||
secret: ENV.fetch('PRISM_BLOG_ENCRYPTION_SECRET', 'blog-demo-encryption-secret'),
|
||||
previous_secrets: ENV['PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS'].to_s.split(',')
|
||||
)
|
||||
# Backs User.generates_token_for(:password_reset) - a separate secret
|
||||
# (own ENV var, own rotation list) from both session and encryption above
|
||||
# on purpose: retiring this one to kill every outstanding reset link
|
||||
# (say, right after the leak a rotation is usually responding to) should
|
||||
# never force-log-out every signed-in visitor or break decryption of
|
||||
# already-encrypted columns as a side effect.
|
||||
enable_token_signing(
|
||||
secret: ENV.fetch('PRISM_BLOG_TOKEN_SECRET', 'blog-demo-token-secret'),
|
||||
previous_secrets: ENV['PRISM_BLOG_TOKEN_PREVIOUS_SECRETS'].to_s.split(',')
|
||||
)
|
||||
enable_assets(source: File.join(APP_ROOT, 'app', 'assets'), output: File.join(APP_ROOT, 'public', 'assets'))
|
||||
use Prism::Middleware::Logger
|
||||
use Prism::Middleware::Compression
|
||||
use Prism::Middleware::Cors
|
||||
use Prism::Middleware::ParamsParser
|
||||
use Prism::Middleware::RateLimiting
|
||||
use Prism::Middleware::Static, root: File.join(APP_ROOT, 'public')
|
||||
|
||||
# Log-only by default so a detected N+1 shows up in the dev log the same
|
||||
# way Bullet would, without ever turning an otherwise fine response into a
|
||||
# 500 - this app's controllers already lean hard on .includes (see
|
||||
# PostsController#index/#show) specifically to avoid the pattern this
|
||||
# exists to catch, so in steady state this should stay silent; a warning
|
||||
# here is a real regression, not a false positive.
|
||||
#
|
||||
# PRISM_BLOG_STRICT_N_PLUS_ONE=1 flips raise_on_violation on - meant for
|
||||
# CI/test runs of this demo, where a regression should fail the run
|
||||
# instead of scrolling past in a log nobody re-reads. Application#dispatch
|
||||
# routes a violation through the same handle_uncaught_exception path as
|
||||
# any other bug, so this always surfaces as a normal 500 over real HTTP
|
||||
# (see strict_n_plus_one_demo.rb, which proves that end-to-end against a
|
||||
# deliberately reintroduced N+1 through a real socket).
|
||||
enable_n_plus_one_detection(threshold: 2, raise_on_violation: ENV['PRISM_BLOG_STRICT_N_PLUS_ONE'] == '1')
|
||||
enable_mailer(host: 'localhost', port: 4567)
|
||||
mount '/rails/mailers', Prism::Mailer::PreviewApp.new
|
||||
# PostActivityChannel only ever pushes small JSON control/notification
|
||||
# frames (subscribe acks, comment-count updates) and never acts on
|
||||
# anything a client sends (see #on_message) - 2 KiB is generous headroom
|
||||
# over the largest real payload while still being a meaningfully tighter
|
||||
# cap than Connection::DEFAULT_MAX_MESSAGE_SIZE's 16 MiB, so a client
|
||||
# that tries to abuse this read-mostly stream gets cut off quickly
|
||||
# instead of being allowed to buffer up to the library-wide default.
|
||||
websocket '/cable', PostActivityChannel, max_message_size: 2048
|
||||
|
||||
root(to: 'posts#index')
|
||||
resources :posts, only: %i[index show create]
|
||||
resource :session, only: %i[new create destroy]
|
||||
# Not a `resource`/`resources` - the record this is "about" is only ever
|
||||
# addressed by a signed token in the URL, never by a User id (that would
|
||||
# let anyone reset any account by guessing/incrementing ids), so it has
|
||||
# no numeric-id member routes to fall out of that DSL.
|
||||
get('/password_resets/new', to: 'password_resets#new', as: :new_password_reset)
|
||||
post('/password_resets', to: 'password_resets#create', as: :password_resets)
|
||||
get('/password_resets/:token/edit', to: 'password_resets#edit', as: :edit_password_reset)
|
||||
patch('/password_resets/:token', to: 'password_resets#update', as: :password_reset)
|
||||
# Same reasoning as password_resets above - addressed only by a signed
|
||||
# token, never by User id.
|
||||
get('/email_confirmations/new', to: 'email_confirmations#new', as: :new_email_confirmation)
|
||||
post('/email_confirmations', to: 'email_confirmations#create', as: :email_confirmations)
|
||||
get('/email_confirmations/:token', to: 'email_confirmations#show', as: :email_confirmation)
|
||||
resource :account, only: %i[edit update]
|
||||
# Flat, not nested under a post - a user's own comment history spans every
|
||||
# post they ever commented on, same shape as GET /comments/trash below but
|
||||
# for live (not soft-deleted) comments. See AccountController#comments.
|
||||
get('/account/comments', to: 'account#comments', as: :account_comments)
|
||||
# Flat, same reasoning as /account/comments above but filtered to
|
||||
# pending instead of to authorship - see AccountController#moderation.
|
||||
get('/account/moderation', to: 'account#moderation', as: :account_moderation)
|
||||
resources :users, only: %i[new create]
|
||||
# param: :slug - post URLs are /posts/some-title, not /posts/42 (see
|
||||
# Post#to_param and Post.create_with_unique_slug). Nested comment routes
|
||||
# stay /posts/:post_id/... (ResourceScope#nested_prefix hardcodes the
|
||||
# segment name to "#{singular}_id" regardless of the parent's own param -
|
||||
# only the VALUE passed for that segment becomes a slug string instead of
|
||||
# a numeric id, see CommentsController).
|
||||
resources :posts, only: %i[index show create edit update destroy], param: :slug do
|
||||
collection do
|
||||
get(:trash)
|
||||
get(:export)
|
||||
post(:import)
|
||||
end
|
||||
member do
|
||||
patch(:preview)
|
||||
patch(:publish)
|
||||
patch(:unpublish)
|
||||
patch(:restore)
|
||||
patch(:move_up)
|
||||
patch(:move_down)
|
||||
end
|
||||
resources :comments, only: %i[index create edit update destroy] do
|
||||
member do
|
||||
patch(:approve)
|
||||
patch(:reject)
|
||||
end
|
||||
end
|
||||
# create-only - see ClaimsController, a claim has no update/destroy
|
||||
# anywhere in this app.
|
||||
resources :claims, only: %i[create]
|
||||
end
|
||||
# Flat, not nested under a post - a commenter's own trashed comments span
|
||||
# every post they ever wrote on, and restoring one is independent of
|
||||
# whatever trash/restore state its post happens to be in (see
|
||||
# CommentsController#trash/#restore).
|
||||
get('/comments/trash', to: 'comments#trash', as: :trash_comments)
|
||||
patch('/comments/:id/restore', to: 'comments#restore', as: :restore_comment)
|
||||
resources :likes, only: %i[create destroy]
|
||||
resources :login_sessions, only: %i[index destroy] do
|
||||
collection do
|
||||
post(:sign_out_others)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
// Rails-UJS-lite: any element with a data-confirm attribute (button_to
|
||||
// destroy links generate one, see app/views/posts/_post.erb) pops a native
|
||||
// confirm() dialog before its click is allowed to reach the form submit;
|
||||
// cancelling the dialog cancels the click.
|
||||
document.addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-confirm]");
|
||||
if (!target) return;
|
||||
if (!window.confirm(target.getAttribute("data-confirm"))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.layout main {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
.layout aside {
|
||||
flex: 1;
|
||||
border-left: 1px solid #ddd;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.button-to {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.button-to button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #b00020;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-notice {
|
||||
background: #eef7ee;
|
||||
border: 1px solid #bcd8bc;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'uri'
|
||||
require 'json'
|
||||
|
||||
# ActionCable-lite channel for posts#show: anyone with a post open can
|
||||
# subscribe (GET /cable?post_id=N, upgraded to a WebSocket) to hear about
|
||||
# moderation events on that post's comments - approved, rejected, or
|
||||
# removed - the instant they happen, instead of polling or reloading.
|
||||
# CommentsController broadcasts through the same Hub every time one of
|
||||
# those three actions changes the post's *visible* (approved) comment
|
||||
# count; this channel itself never accepts anything back from the client,
|
||||
# it only ever pushes.
|
||||
class PostActivityChannel
|
||||
# A hard cap on live viewers per post - without one, one post going viral
|
||||
# would mean one Hub#broadcast call fanning out to an unbounded number of
|
||||
# live sockets on every single moderation action. Real, but generous
|
||||
# enough that the demo's handful of viewers never trips it by accident.
|
||||
MAX_VIEWERS_PER_POST = 500
|
||||
|
||||
def self.hub
|
||||
@hub ||= Prism::WebSocket::Hub.new(max_subscribers_per_stream: MAX_VIEWERS_PER_POST)
|
||||
end
|
||||
|
||||
def self.stream_for(post_id)
|
||||
"post_#{post_id}_comments"
|
||||
end
|
||||
|
||||
def on_open(connection)
|
||||
post_id = post_id_from(connection)
|
||||
return connection.close(1008, 'post_id query parameter is required') unless post_id
|
||||
unless self.class.hub.subscribe(self.class.stream_for(post_id), connection)
|
||||
return connection.close(1008, 'this post already has the maximum number of live viewers')
|
||||
end
|
||||
connection.write_text(JSON.generate(event: 'subscribed', post_id: post_id))
|
||||
end
|
||||
|
||||
def on_message(connection, _message)
|
||||
# Read-only stream - nothing a client sends is ever acted on.
|
||||
end
|
||||
|
||||
def on_close(connection)
|
||||
self.class.hub.unsubscribe_all(connection)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# The handshake request never reaches a Prism::Request/Params, just the
|
||||
# raw Rack env the server captured before the Upgrade (see
|
||||
# Prism::Server#handle_websocket) - so the query string is decoded by
|
||||
# hand here instead of through Prism::Params#nested_from, which only
|
||||
# ever runs as part of an ordinary Request#params call.
|
||||
def post_id_from(connection)
|
||||
query = URI.decode_www_form(connection.env['QUERY_STRING'].to_s).to_h
|
||||
id = query['post_id'].to_s
|
||||
id.empty? ? nil : id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
class AccountController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
def edit
|
||||
render(:edit, locals: { user: current_user, errors: [] })
|
||||
end
|
||||
|
||||
# current_user.comments is a plain `Comment.where(user_id: id)` (Associable
|
||||
# #has_many, no through:/as:) - a single HashNode equality answerable by
|
||||
# Store#lookup_by_index on comments.user_id the moment that index exists
|
||||
# (see db/migrate/20260907000008_add_index_on_comments_user_id.rb), unlike
|
||||
# every other approved/visible_to call site in this app: those all chain
|
||||
# off post.comments first, so their equalities hash already carries
|
||||
# post_id too and the pre-existing post_id index wins the tie - this is
|
||||
# the one place comments.user_id gets to be the index actually picked.
|
||||
def comments
|
||||
comments = current_user.comments.order(:created_at, :desc).page(params['page']).per(10)
|
||||
fresh_when(etag: [current_user.id, comments.current_page, comments.total_count,
|
||||
comments.to_a.map { |c| [c.id, c.updated_at] }])
|
||||
render(:comments, locals: { comments: comments })
|
||||
end
|
||||
|
||||
# A post's comments start life pending (Comment's `enum :status`) so its
|
||||
# author can approve/reject before strangers see them - but the only way
|
||||
# to act on that today is opening each post's own page one at a time
|
||||
# (posts/_comment.erb's approve/reject buttons). This gathers every
|
||||
# pending comment across EVERY post the current user owns onto one page,
|
||||
# the same "flatten across posts" shape as #comments above but filtered
|
||||
# to pending instead of to authorship. Two index-backed queries, not one
|
||||
# scan each: current_user.posts (posts.user_id, see the migration
|
||||
# referenced on User) narrows to this user's own post ids first, then
|
||||
# Comment.pending.where(post_id: ...) reuses the existing comments.post_id
|
||||
# index's IN-list lookup (Store#lookup_by_index_in) across all of them at
|
||||
# once, with the leftover `status` equality filtered from that already-
|
||||
# small candidate set in memory - see ConditionNode#indexed_candidates.
|
||||
def moderation
|
||||
post_ids = current_user.posts.pluck(:id)
|
||||
comments = Comment.pending.where(post_id: post_ids)
|
||||
.order(:created_at, :desc).page(params['page']).per(10)
|
||||
fresh_when(etag: [current_user.id, comments.current_page, comments.total_count,
|
||||
comments.to_a.map { |c| [c.id, c.updated_at] }])
|
||||
render(:moderation, locals: { comments: comments })
|
||||
end
|
||||
|
||||
# User#email is `encrypts :email, deterministic: true` (see the model) -
|
||||
# this is the one place in the whole demo where a user's own encrypted
|
||||
# attribute is genuinely rewritten through a real HTTP request instead of
|
||||
# only being set once at sign-up, which is exactly what forces a fresh
|
||||
# `Encryption.encrypt` call under the CURRENTLY configured primary key.
|
||||
# That makes this action the natural vehicle for exercising key rotation
|
||||
# end to end (see rotation_worker.rb): a user who visits this page after
|
||||
# the app's encryption secret has rotated and re-saves their (possibly
|
||||
# unchanged) email migrates that one row onto the new key for free,
|
||||
# exactly the way Prism::Orm::Encryption's own comments describe.
|
||||
def update
|
||||
attrs = params.require('user').permit(:email).to_h
|
||||
current_user.assign(attrs)
|
||||
|
||||
if current_user.save
|
||||
changed_fields = current_user.saved_changes.keys - ['updated_at']
|
||||
flash[:notice] = changed_fields.empty? ? t('account.update_noop_notice') : t('account.updated_notice', fields: changed_fields.join(', '))
|
||||
redirect url_for(:edit_account)
|
||||
else
|
||||
status(422)
|
||||
render(:edit, locals: { user: current_user, errors: current_user.errors.full_messages })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
class ClaimsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
# Giveaway slots are a hard cap that has to hold under real concurrency,
|
||||
# not just in whichever request happens to run first: two visitors
|
||||
# racing the same "claim a slot" button must never both walk away
|
||||
# believing they won one of the last N copies. Post#with_lock
|
||||
# (Prism::Orm::Pessimistic, wired into every Model since session ~99)
|
||||
# holds Store's real cross-process file lock for this entire
|
||||
# reload-check-decide-write critical section, so a concurrent request -
|
||||
# even from a completely separate `Prism::Server` OS process sharing this
|
||||
# same database file, not just another thread of this one, see
|
||||
# blog_giveaway_demo.rb/blog_giveaway_worker.rb - blocks on its own
|
||||
# transaction until this one has already committed, instead of both
|
||||
# reading the same not-yet-updated claims_count and both deciding a slot
|
||||
# was still free.
|
||||
def create
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
raise Prism::Orm::RecordNotFound unless post.giveaway?
|
||||
|
||||
outcome = nil
|
||||
post.with_lock do |locked_post|
|
||||
if locked_post.giveaway_sold_out?
|
||||
outcome = :sold_out
|
||||
else
|
||||
claim = Claim.new(post_id: locked_post.id, user_id: current_user.id)
|
||||
# The only realistic validation failure left once
|
||||
# giveaway_sold_out? has already passed is the uniqueness scope -
|
||||
# the same visitor double-submitting the claim form (e.g. a
|
||||
# doubled-click, or hitting back and resubmitting).
|
||||
outcome = claim.save ? :claimed : :already_claimed
|
||||
end
|
||||
end
|
||||
|
||||
flash[:notice] = t("claims.#{outcome}_notice")
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('posts.not_found'))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
class CommentsController < ApplicationController
|
||||
before_action :require_authentication, only: %i[create edit update destroy approve reject trash restore]
|
||||
|
||||
# Comments were previously only ever visible nested inside
|
||||
# PostsController#show (whatever fits on that one page). This gives them
|
||||
# their own paginated URL, same etag-over-the-page trick as
|
||||
# PostsController#index: current_page/total_count catch a comment being
|
||||
# added or removed shifting what belongs on this page even when nothing
|
||||
# ON this page changed, the id/updated_at pairs catch edits to any of them.
|
||||
def index
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comments = post.comments.includes(:likes).visible_to(current_user, post.user_id)
|
||||
.order(:created_at, :desc).page(params['page']).per(10)
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
fresh_when(etag: [post.id, post.updated_at, current_user&.id, comments.current_page, comments.total_count,
|
||||
comments.to_a.map { |c| [c.id, c.updated_at] }])
|
||||
render(:index, locals: { post: post, comments: comments })
|
||||
end
|
||||
format.json { json(comments.as_json(only: %i[id author body post_id status])) }
|
||||
end
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('posts.not_found'))
|
||||
end
|
||||
|
||||
def create
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.new(params.require('comment').permit(:author, :body).to_h
|
||||
.merge('post_id' => post.id, 'user_id' => current_user.id))
|
||||
|
||||
if comment.save
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t('comments.created_notice')
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
end
|
||||
format.json { json(comment.as_json(only: %i[id author body post_id status]), 201) }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = comment.errors.full_messages.join(', ')
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
end
|
||||
format.json { render_errors(comment) }
|
||||
end
|
||||
end
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('posts.not_found'))
|
||||
end
|
||||
|
||||
def edit
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.find(params['id'])
|
||||
authorize(comment, :update?)
|
||||
render(:edit, locals: { post: post, comment: comment, errors: [] })
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('comments.not_found'))
|
||||
end
|
||||
|
||||
# Only the body is editable (author is fixed at creation, same reasoning
|
||||
# as PostsController#update not letting the edit form touch cover/user_id)
|
||||
# so Comment#saved_changes below can only ever report `body`. `lock_version`
|
||||
# rides along the same way it does for Post - the value the edit form was
|
||||
# rendered with, checked against the row's actual current version by
|
||||
# Comment#save (locking_enabled? auto-detects the column added by the
|
||||
# add_lock_version_to_comments migration, no model change needed).
|
||||
def update
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.find(params['id'])
|
||||
authorize(comment)
|
||||
comment.assign(params.require('comment').permit(:body, :lock_version).to_h)
|
||||
|
||||
if comment.save
|
||||
changed_fields = comment.saved_changes.keys - ['updated_at', 'lock_version']
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = changed_fields.empty? ? t('comments.update_noop_notice') : t('comments.updated_notice', fields: changed_fields.join(', '))
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
end
|
||||
format.json { json(comment.as_json(only: %i[id author body post_id status])) }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
status(422)
|
||||
render(:edit, locals: { post: post, comment: comment, errors: comment.errors.full_messages })
|
||||
end
|
||||
format.json { render_errors(comment) }
|
||||
end
|
||||
end
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('comments.not_found'))
|
||||
rescue Prism::Orm::StaleObjectError
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
status(409)
|
||||
render(:edit, locals: { post: post, comment: Comment.find(params['id']), errors: [t('comments.stale_notice')] })
|
||||
end
|
||||
format.json do
|
||||
json({ errors: [jsonapi_error(status: 409, pointer: '/data/attributes/lock_version',
|
||||
title: 'is stale', detail: t('comments.stale_notice'))] }, 409)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.find(params['id'])
|
||||
authorize(comment)
|
||||
comment.destroy
|
||||
broadcast_comment_count(post)
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('posts.not_found'))
|
||||
end
|
||||
|
||||
# approved!/rejected! come straight from Comment's `enum :status` (see
|
||||
# the model), same "find, authorize like an edit, flip the status" shape
|
||||
# as PostsController#publish/#unpublish. CommentPolicy#approve?/#reject?
|
||||
# check the POST's owner, not the comment's - moderation is the post
|
||||
# author's call, not the commenter's.
|
||||
def approve
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.find(params['id'])
|
||||
authorize(comment)
|
||||
comment.approved!
|
||||
broadcast_comment_count(post)
|
||||
flash[:notice] = t('comments.approved_notice')
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('comments.not_found'))
|
||||
end
|
||||
|
||||
def reject
|
||||
post = Post.find_by!(slug: params['post_id'])
|
||||
comment = Comment.find(params['id'])
|
||||
authorize(comment)
|
||||
comment.rejected!
|
||||
broadcast_comment_count(post)
|
||||
flash[:notice] = t('comments.rejected_notice')
|
||||
redirect url_for(:post, slug: post.to_param)
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('comments.not_found'))
|
||||
end
|
||||
|
||||
# Every comment a signed-in user has removed, across every post they ever
|
||||
# commented on - a flat, top-level page (see the routes in app.rb), not
|
||||
# nested under any one post, since a commenter's trash is not the same
|
||||
# thing as PostsController#trash and does not depend on whether the post
|
||||
# itself is trashed (Comment#acts_as_paranoid keeps its own deleted_at,
|
||||
# independent of its post's - see the model). Comment.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
|
||||
comments = Comment.only_deleted.where(user_id: current_user.id).order(:updated_at, :desc).to_a
|
||||
render(:trash, locals: { comments: comments, notice: flash[:notice] })
|
||||
end
|
||||
|
||||
# find_by! against only_deleted (not the default Comment.find, which
|
||||
# hides trashed rows) - restoring a comment that was never trashed, or
|
||||
# someone else's, both come back as the same 404 a stranger's comment id
|
||||
# would. recursive: true brings back this comment's own likes if they
|
||||
# were soft-deleted alongside it by the dependent: :destroy cascade on
|
||||
# Comment#destroy (Like is paranoid too - see cascade_restore_dependent_
|
||||
# associations); it does not touch the comment's post either way.
|
||||
def restore
|
||||
comment = Comment.only_deleted.find_by!(id: params['id'].to_i)
|
||||
authorize(comment, :restore?)
|
||||
comment.restore(recursive: true)
|
||||
flash[:notice] = t('comments.restored_notice')
|
||||
redirect url_for(:trash_comments)
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('comments.not_found'))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Pushed to every open posts#show tab's PostActivityChannel subscription
|
||||
# (see the model) - approve/reject/destroy are the only three actions
|
||||
# that can change how many APPROVED comments a post has (create cannot,
|
||||
# every comment starts life pending), so this is called from exactly
|
||||
# those three, never from create/update.
|
||||
def broadcast_comment_count(post)
|
||||
# stream_for/post_id_from (see the channel) treat this as an opaque
|
||||
# string key, not a real id - post.to_param (the slug) so the stream a
|
||||
# client subscribes to via GET /cable?post_id=<slug> (posts/show.erb
|
||||
# has no inline subscriber script yet, but blog_websocket_demo.rb
|
||||
# subscribes using the same slug it got from the post's own URL)
|
||||
# actually matches what gets broadcast to here.
|
||||
PostActivityChannel.hub.broadcast(
|
||||
PostActivityChannel.stream_for(post.to_param),
|
||||
event: 'comment_count', post_id: post.to_param, count: post.comments.approved.count
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class EmailConfirmationsController < ApplicationController
|
||||
# Resend form - for whoever's original confirmation link expired out of
|
||||
# their inbox, landed in spam, or was simply never sent because a typo in
|
||||
# the signup form pointed User#generates_token_for at an address they
|
||||
# don't control.
|
||||
def new
|
||||
render(:new)
|
||||
end
|
||||
|
||||
# Same enumeration-safe shape as PasswordResetsController#create: the
|
||||
# response never reveals whether the address has an account here, or
|
||||
# whether it is already confirmed - branching on either would turn this
|
||||
# form into a one-guess-at-a-time oracle for both facts.
|
||||
def create
|
||||
email = params.require('email_confirmation').permit(:email).to_h['email']
|
||||
user = User.find_by(email: email)
|
||||
if user && !user.confirmed?
|
||||
NotificationMailer.confirmation_instructions(user.id, user.generate_token_for(:email_confirmation)).deliver_later
|
||||
end
|
||||
flash[:notice] = t('email_confirmations.instructions_sent_notice')
|
||||
redirect url_for(:new_session)
|
||||
end
|
||||
|
||||
# Reached via a plain GET from the link in the confirmation email, like
|
||||
# PasswordResetsController#edit - safe to expose over GET (and to visit
|
||||
# more than once) because, unlike a password reset, confirming does not
|
||||
# hand the visitor anything sensitive and does not itself change the
|
||||
# fingerprint (email) the token was minted under, so a bookmarked or
|
||||
# double-clicked link keeps working instead of dying after first use.
|
||||
def show
|
||||
user = User.find_by_token_for(:email_confirmation, params['token'])
|
||||
unless user
|
||||
flash[:notice] = t('email_confirmations.invalid_token_notice')
|
||||
redirect url_for(:new_email_confirmation)
|
||||
return
|
||||
end
|
||||
|
||||
user.update!(confirmed_at: Time.now.utc) unless user.confirmed?
|
||||
flash[:notice] = t('email_confirmations.confirmed_notice')
|
||||
redirect url_for(:root)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
class LikesController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
# Only Post/Comment may be liked - likeable_type otherwise rides in fully
|
||||
# client-controlled request params, so without this whitelist a crafted
|
||||
# request could aim a Like at an arbitrary model by name (e.g. User) and
|
||||
# #likeable would happily resolve it.
|
||||
LIKEABLE_TYPES = { 'Post' => Post, 'Comment' => Comment }.freeze
|
||||
|
||||
def create
|
||||
likeable = find_likeable!
|
||||
Like.create(user_id: current_user.id, likeable_id: likeable.id, likeable_type: likeable.class.name)
|
||||
redirect url_for(:post, slug: post_slug_for(likeable))
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('likes.not_found'))
|
||||
end
|
||||
|
||||
def destroy
|
||||
like = Like.find(params['id'])
|
||||
authorize(like)
|
||||
likeable = like.likeable
|
||||
# really_destroy!, not the paranoid-aware plain #destroy - unliking is
|
||||
# a permanent, unremarkable action with no "restore my unlike" concept
|
||||
# in this app, unlike Post.acts_as_paranoid's actual trash/restore UI.
|
||||
# Left as a soft delete, every unlike/relike cycle would pile up a
|
||||
# dead Like row forever (uniqueness scope only sees live rows, so
|
||||
# nothing would ever stop a visitor from accumulating one per cycle),
|
||||
# and a post's dependent: :destroy cascade would then have no way to
|
||||
# tell "trashed along with its post" apart from "explicitly unliked
|
||||
# ages ago" when a restore brings the post's likes back.
|
||||
like.really_destroy!
|
||||
redirect url_for(:post, slug: post_slug_for(likeable))
|
||||
rescue Prism::Orm::RecordNotFound
|
||||
respond_not_found(t('likes.not_found'))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_likeable!
|
||||
klass = LIKEABLE_TYPES.fetch(params['likeable_type']) { raise Prism::Orm::RecordNotFound }
|
||||
klass.find(params['likeable_id'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class LoginSessionsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
# "Your devices": every remembered browser for the signed-in user, with
|
||||
# whichever one answered THIS request called out separately so the view
|
||||
# can label it "this device" instead of just another row in the list.
|
||||
def index
|
||||
render(:index, locals: {
|
||||
current_session: current_login_session,
|
||||
other_sessions: other_login_sessions.order(:created_at, :desc).to_a,
|
||||
notice: flash[:notice]
|
||||
})
|
||||
end
|
||||
|
||||
# Revokes one remembered browser. revoke_login_session! already checks
|
||||
# ownership (a foreign id, or one belonging to someone else, is a no-op),
|
||||
# so there is nothing else to authorize here.
|
||||
def destroy
|
||||
flash[:notice] = if revoke_login_session!(params['id'])
|
||||
t('login_sessions.revoked_notice')
|
||||
else
|
||||
t('login_sessions.not_found')
|
||||
end
|
||||
redirect url_for(:login_sessions)
|
||||
end
|
||||
|
||||
def sign_out_others
|
||||
sign_out_all_other_sessions
|
||||
flash[:notice] = t('login_sessions.signed_out_others_notice')
|
||||
redirect url_for(:login_sessions)
|
||||
end
|
||||
|
||||
# "TestBrowser/1.0 from 203.0.113.7" - falls back to the generic
|
||||
# "unknown browser"/"unknown address" strings for rows remembered before
|
||||
# this metadata started being captured (a plain nil column, not a bug).
|
||||
def device_detail_for(login_session)
|
||||
t('login_sessions.device_detail',
|
||||
user_agent: login_session.user_agent || t('login_sessions.unknown_user_agent'),
|
||||
ip_address: login_session.ip_address || t('login_sessions.unknown_ip_address'))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
def new
|
||||
render(:new)
|
||||
end
|
||||
|
||||
# Always shows the SAME generic "check your email" notice whether or not
|
||||
# the address belongs to an account - branching the response on
|
||||
# User.find_by(email:) here would turn this form into an email-enumeration
|
||||
# oracle one guess at a time, the same concern app.rb's signups/ip
|
||||
# throttle exists for one step earlier in the account lifecycle (and this
|
||||
# action gets its own password_resets/ip throttle below for the same
|
||||
# reason: even a constant-response endpoint still leaks timing/rate
|
||||
# information if it can be hit without limit).
|
||||
def create
|
||||
email = params.require('password_reset').permit(:email).to_h['email']
|
||||
user = User.find_by(email: email)
|
||||
if user
|
||||
token = user.generate_token_for(:password_reset)
|
||||
NotificationMailer.password_reset(user.id, token).deliver_later
|
||||
end
|
||||
flash[:notice] = t('password_resets.instructions_sent_notice')
|
||||
redirect url_for(:new_session)
|
||||
end
|
||||
|
||||
def edit
|
||||
user = User.find_by_token_for(:password_reset, params['token'])
|
||||
if user
|
||||
render(:edit, locals: { token: params['token'], errors: [] })
|
||||
else
|
||||
flash[:notice] = t('password_resets.invalid_token_notice')
|
||||
redirect url_for(:new_password_reset)
|
||||
end
|
||||
end
|
||||
|
||||
# find_by_token_for's fingerprint block (see User#generates_token_for)
|
||||
# snapshots password_digest at #new/#create time - so the very save
|
||||
# below that sets the new password also produces a new digest, which
|
||||
# means the SAME token this action just accepted can never verify again
|
||||
# afterwards. A stolen-but-unused link still dies once its owner resets
|
||||
# through it; there is no separate "used" flag or token table to keep
|
||||
# that guarantee true.
|
||||
def update
|
||||
user = User.find_by_token_for(:password_reset, params['token'])
|
||||
unless user
|
||||
flash[:notice] = t('password_resets.invalid_token_notice')
|
||||
redirect url_for(:new_password_reset)
|
||||
return
|
||||
end
|
||||
|
||||
attrs = params.require('password_reset').permit(:password, :password_confirmation).to_h
|
||||
user.password = attrs['password']
|
||||
user.password_confirmation = attrs['password_confirmation']
|
||||
|
||||
if user.save
|
||||
flash[:notice] = t('password_resets.reset_notice')
|
||||
redirect url_for(:new_session)
|
||||
else
|
||||
status(422)
|
||||
render(:edit, locals: { token: params['token'], errors: user.errors.full_messages })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,24 +1,351 @@
|
||||
class PostsController < Prism::Controller
|
||||
layout :application
|
||||
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
|
||||
render(:index, locals: { posts: Post.order(:created_at, :desc), notice: flash[:notice], errors: [] })
|
||||
# `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
|
||||
render(:show, locals: { post: Post.find(params['id']) })
|
||||
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
|
||||
halt(404, 'Post not found')
|
||||
respond_not_found(t('posts.not_found'))
|
||||
end
|
||||
|
||||
def create
|
||||
post = Post.new(title: params['title'], body: params['body'])
|
||||
if post.save
|
||||
flash[:notice] = "Post ##{post.id} created"
|
||||
# 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.order(:created_at, :desc), notice: nil, errors: post.errors.full_messages })
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
class SessionController < ApplicationController
|
||||
def new
|
||||
render(:new, locals: { errors: [] })
|
||||
end
|
||||
|
||||
def create
|
||||
credentials = params.require('user').permit(:email, :password, :remember_me).to_h
|
||||
user = User.find_by(email: credentials['email'])
|
||||
|
||||
if user && user.authenticate(credentials['password'])
|
||||
sign_in(user, remember: credentials['remember_me'] == '1')
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t('sessions.signed_in_notice')
|
||||
redirect after_authentication_url
|
||||
end
|
||||
format.json { json(user.as_json(methods: [:email])) }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
status(422)
|
||||
render(:new, locals: { errors: [t('sessions.invalid_credentials')] })
|
||||
end
|
||||
# 401, not 422 like the HTML branch above: there is no resource
|
||||
# here whose validation failed (no User object at all when the
|
||||
# email does not match anyone), just a rejected authentication
|
||||
# attempt - the JSON:API status a client should actually branch on.
|
||||
format.json do
|
||||
json({ errors: [jsonapi_error(status: 401, pointer: '/data/attributes/password',
|
||||
title: 'invalid credentials', detail: t('sessions.invalid_credentials'))] }, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
sign_out
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t('sessions.signed_out_notice')
|
||||
redirect url_for(:root)
|
||||
end
|
||||
format.json { json({ message: t('sessions.signed_out_notice') }) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class UsersController < ApplicationController
|
||||
def new
|
||||
render(:new, locals: { user: User.new, errors: [] })
|
||||
end
|
||||
|
||||
def create
|
||||
attrs = params.require('user').permit(:email, :password, :password_confirmation).to_h
|
||||
user = User.new(attrs)
|
||||
|
||||
if user.save
|
||||
sign_in(user)
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
flash[:notice] = t('users.signed_up_notice')
|
||||
redirect url_for(:root)
|
||||
end
|
||||
format.json { json(user.as_json(methods: [:email]), 201) }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
status(422)
|
||||
render(:new, locals: { user: user, errors: user.errors.full_messages })
|
||||
end
|
||||
format.json { render_errors(user) }
|
||||
end
|
||||
end
|
||||
# The unique index on users.email (see db/migrate) is a storage-level
|
||||
# constraint, not just the app-level `validates :email, uniqueness: true`
|
||||
# above - it still fires for anything that reaches Store#insert without
|
||||
# going through Model#valid? first (a racing concurrent signup that read
|
||||
# the table before the other one committed, a future admin script, a bug
|
||||
# elsewhere that skips validation). #create can't tell those apart from a
|
||||
# normal duplicate, so it reports the same friendly message either way.
|
||||
rescue Prism::Orm::Store::UniqueConstraintViolation
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
status(422)
|
||||
render(:new, locals: { user: user, errors: [t('users.email_taken_notice')] })
|
||||
end
|
||||
format.json do
|
||||
json({ errors: [jsonapi_error(status: 422, pointer: '/data/attributes/email',
|
||||
title: 'is taken', detail: t('users.email_taken_notice'))] }, 422)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class NotificationMailer < Prism::Mailer::Base
|
||||
self.template_roots = [File.join(APP_ROOT, 'app', 'views', 'mailers')]
|
||||
default from: 'blog@prism.example'
|
||||
|
||||
def new_post(post_id)
|
||||
@post = Post.find(post_id)
|
||||
@post_url = "/posts/#{@post.to_param}"
|
||||
mail(to: 'editors@prism.example', subject: "New post: #{@post.title}")
|
||||
end
|
||||
|
||||
def welcome(user_id)
|
||||
@user = User.find(user_id)
|
||||
mail(to: @user.email, subject: 'Welcome to Prism Blog')
|
||||
end
|
||||
|
||||
# `token` is generated by the caller (User#generate_token_for(:password_reset),
|
||||
# see PasswordResetsController#create) rather than here - keeps this
|
||||
# mailer, like the others, a pure "given the data, render+send" step with
|
||||
# no signing concerns of its own.
|
||||
def password_reset(user_id, token)
|
||||
@user = User.find(user_id)
|
||||
@reset_url = url_for(:edit_password_reset, token: token)
|
||||
mail(to: @user.email, subject: 'Reset your Prism Blog password')
|
||||
end
|
||||
|
||||
# `token` is generated by the caller, same reasoning as password_reset
|
||||
# above - either User's own after_commit(on: :create) at signup time, or
|
||||
# EmailConfirmationsController#create for a resend.
|
||||
def confirmation_instructions(user_id, token)
|
||||
@user = User.find(user_id)
|
||||
@confirmation_url = url_for(:email_confirmation, token: token)
|
||||
mail(to: @user.email, subject: 'Confirm your Prism Blog email address')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class NotificationMailerPreview < Prism::Mailer::Preview
|
||||
def new_post
|
||||
post = Post.first || Post.create_with_unique_slug(title: 'Preview Post', body: 'Fixture body for mailer previews.')
|
||||
NotificationMailer.new_post(post.id)
|
||||
end
|
||||
|
||||
def welcome
|
||||
user = User.first || User.create!(email: 'preview@example.com', password: 'preview passphrase',
|
||||
password_confirmation: 'preview passphrase')
|
||||
NotificationMailer.welcome(user.id)
|
||||
end
|
||||
|
||||
def password_reset
|
||||
user = User.first || User.create!(email: 'preview@example.com', password: 'preview passphrase',
|
||||
password_confirmation: 'preview passphrase')
|
||||
NotificationMailer.password_reset(user.id, user.generate_token_for(:password_reset))
|
||||
end
|
||||
|
||||
def confirmation_instructions
|
||||
user = User.first || User.create!(email: 'preview@example.com', password: 'preview passphrase',
|
||||
password_confirmation: 'preview passphrase')
|
||||
NotificationMailer.confirmation_instructions(user.id, user.generate_token_for(:email_confirmation))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class Claim < Prism::Orm::Model
|
||||
# counter_cache keeps Post#claims_count (and therefore
|
||||
# Post#giveaway_remaining/#giveaway_sold_out?) O(1) to read - the whole
|
||||
# point of ClaimsController#create locking the post first is to make that
|
||||
# read-then-decide safe under concurrency, which would be undermined if
|
||||
# the read itself required walking every Claim row instead.
|
||||
belongs_to :post, counter_cache: true
|
||||
belongs_to :user
|
||||
|
||||
# Not a user-facing "unclaim" - a claim has no destroy action anywhere in
|
||||
# this app (see ClaimsController, create-only). Paranoid purely so
|
||||
# Post#acts_as_paranoid's `dependent: :destroy` cascade (see the model)
|
||||
# soft-deletes a giveaway's claims along with a trashed post instead of
|
||||
# erasing the record of who already won a slot, and #restore(recursive:
|
||||
# true) brings them back - same shape as Comment/Like.
|
||||
acts_as_paranoid
|
||||
|
||||
# One claim per user per post - see the unique index on [:post_id,
|
||||
# :user_id] in the create_claims migration for the same rule enforced at
|
||||
# the Store level too, inside whatever transaction the insert runs in.
|
||||
validates :user_id, presence: true, uniqueness: { scope: :post_id }
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class Comment < Prism::Orm::Model
|
||||
# touch: true keeps Post#updated_at reflecting the most recent comment
|
||||
# activity (created, edited, deleted), not just edits to the post itself -
|
||||
# shown as "Last activity" on the post list/show pages.
|
||||
belongs_to :post, counter_cache: true, touch: true
|
||||
belongs_to :user
|
||||
has_many :likes, as: :likeable, dependent: :destroy
|
||||
|
||||
# Paranoid so PostsController#restore(recursive: true) can bring a
|
||||
# trashed post's comments back along with it - see Post#acts_as_paranoid.
|
||||
acts_as_paranoid
|
||||
|
||||
# Every comment starts life pending (column default 0, see the
|
||||
# add_status_to_comments migration) so a post's author gets a chance to
|
||||
# approve or reject it before strangers can read it - see
|
||||
# CommentsController#approve/#reject and the `visible_to` scope below.
|
||||
enum :status, { pending: 0, approved: 1, rejected: 2 }
|
||||
|
||||
# Who gets to see a comment depends on who's asking, not just its status:
|
||||
# everyone sees approved comments, the post's author (the moderator) sees
|
||||
# everything on their own post regardless of status, and a signed-in
|
||||
# commenter always sees their own comment even while it's still pending.
|
||||
# `self` inside the block is whatever relation this scope was chained
|
||||
# onto (e.g. already narrowed to one post's comments by `post.comments`),
|
||||
# so `approved`/`where` compose with that instead of re-querying globally.
|
||||
scope :visible_to, lambda { |user, post_author_id|
|
||||
next self if user && user.id == post_author_id
|
||||
next approved if user.nil?
|
||||
approved.or(where(user_id: user.id))
|
||||
}
|
||||
|
||||
validates :author, presence: true, length: { minimum: 2 }
|
||||
validates :body, presence: true
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class Like < Prism::Orm::Model
|
||||
belongs_to :user
|
||||
belongs_to :likeable, polymorphic: true, counter_cache: true
|
||||
|
||||
# Same reasoning as Comment#acts_as_paranoid - a trashed post's likes (and
|
||||
# a trashed post's comments' own likes) come back with it on restore.
|
||||
acts_as_paranoid
|
||||
|
||||
validates :user_id, presence: true, uniqueness: { scope: %i[likeable_id likeable_type] }
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class LoginSession < Prism::Orm::Model
|
||||
belongs_to :user
|
||||
|
||||
# The persistent-login cookie's actual credential - never rendered
|
||||
# anywhere (see LoginSessionsController#index), and hidden from
|
||||
# serialization so a stray as_json never leaks it either.
|
||||
has_secure_token :token
|
||||
hide_from_json :token
|
||||
end
|
||||
@@ -1,3 +1,250 @@
|
||||
require 'csv'
|
||||
|
||||
class Post < Prism::Orm::Model
|
||||
# Result of .import_csv below - `imported` are posts that actually made
|
||||
# it to disk, `skipped` are { title:, errors: } pairs for rows that
|
||||
# never did (blank title, or a slug collision that outlived
|
||||
# MAX_SLUG_ATTEMPTS).
|
||||
ImportResult = Struct.new(:imported, :skipped)
|
||||
|
||||
MAX_SLUG_ATTEMPTS = 20
|
||||
|
||||
belongs_to :user
|
||||
has_many :comments, dependent: :destroy
|
||||
has_many :likes, as: :likeable, dependent: :destroy
|
||||
# dependent: :destroy, not :nullify - same reasoning as comments/likes
|
||||
# above: Claim is paranoid too (see the model), so trashing this post
|
||||
# soft-deletes its claims along with it and #restore(recursive: true)
|
||||
# brings them back, rather than a plain trash permanently erasing the
|
||||
# record of who already won a giveaway slot.
|
||||
has_many :claims, dependent: :destroy
|
||||
|
||||
# A deleted post lands in the trash (see PostsController#destroy/#trash/
|
||||
# #restore) instead of vanishing outright - Post.all/#find already hide
|
||||
# it everywhere else in the app for free. Comment and Like are paranoid
|
||||
# too, so the `dependent: :destroy` cascades above soft-delete them the
|
||||
# moment a post is trashed instead of removing them for real, and
|
||||
# PostsController#restore(recursive: true) brings them all back together
|
||||
# with the post (see Associable#cascade_restore_dependent_associations).
|
||||
acts_as_paranoid
|
||||
|
||||
# Every post starts life as a draft (column default 0, see the
|
||||
# add_status_to_posts migration) - only #publish! on posts#show makes it
|
||||
# show up on the public index (see PostsController#index below).
|
||||
enum :status, { draft: 0, published: 1, archived: 2 }
|
||||
|
||||
# Prism::Orm::Attributes: casts the "featured" checkbox's form value
|
||||
# ("1"/"0", always a String off an HTTP request - see the hidden-field
|
||||
# trick in _form.erb) into a real TrueClass/FalseClass on write, instead
|
||||
# of leaving the literal string "1" sitting in the featured column. That
|
||||
# distinction is not cosmetic: `where(featured: true)` (see the
|
||||
# .featured scope below and PostsController#index's `?featured=1`
|
||||
# filter) does a plain Ruby `==` against whatever is actually stored,
|
||||
# so a stored String would never match a query for the Boolean `true`.
|
||||
attribute :featured, :boolean, default: false
|
||||
|
||||
# Same reasoning as TypeCasting.cast_integer's own comment: blank input
|
||||
# from the (optional) giveaway_slots form field casts to nil, not 0 - nil
|
||||
# means "not a giveaway post" (see #giveaway? below), 0 means "was a
|
||||
# giveaway, every slot already claimed", and those are not the same
|
||||
# state for #giveaway_sold_out? to report.
|
||||
attribute :giveaway_slots, :integer
|
||||
|
||||
scope :featured, -> { where(featured: true) }
|
||||
|
||||
# acts_as_list scoped by the featured flag itself: every post gets a
|
||||
# position within its own featured/non-featured group (the non-featured
|
||||
# group's numbers are never shown or reordered - see posts#index and
|
||||
# PostsController#move_up/#move_down, both gated on featured_only), so
|
||||
# toggling a post's own featured flag on/off cheaply hops it between two
|
||||
# already-maintained lists (append to the bottom of whichever group it
|
||||
# lands in) instead of needing a bespoke third state to track "not in
|
||||
# any list yet". Editors use #move_up/#move_down to curate which
|
||||
# featured posts lead the homepage instead of always showing the most
|
||||
# recently featured one first.
|
||||
acts_as_list column: :position, scope: :featured
|
||||
|
||||
# Backed by the range-capable secondary index on created_at
|
||||
# (db/migrate/20260907000004_add_index_on_posts_created_at.rb) - the
|
||||
# gte/lte hash this builds is exactly what
|
||||
# Query::ConditionNode#indexable_ranges recognizes, so PostsController#index
|
||||
# chaining this onto .published (see below) hits
|
||||
# Store#lookup_by_index_range with an O(log n) binary search over
|
||||
# sorted_values instead of a full table scan. See Store#lookup_by_index_range.
|
||||
scope :created_within, ->(days) { where(created_at: { gte: Time.now.utc - (days.to_i * 86_400) }) }
|
||||
|
||||
# Lets the author moderate comments (edit or remove) from the same form
|
||||
# used to edit the post itself - reject_if: :all_blank so a moderation
|
||||
# form that only ever submits EXISTING comment rows (no "add a comment"
|
||||
# field on this screen) never has to worry about accidentally building
|
||||
# a blank new one.
|
||||
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank
|
||||
|
||||
validates :title, presence: true, length: { minimum: 3 }
|
||||
validates :body, presence: true
|
||||
|
||||
before_destroy :remove_cover_file
|
||||
|
||||
class << self
|
||||
# Builds and saves a post, deriving a URL-safe slug from its title and
|
||||
# retrying with a numeric suffix (-2, -3, ...) on a real collision from
|
||||
# the unique index on posts.slug (see the add_slug_to_posts migration)
|
||||
# instead of surfacing a raw Store::UniqueConstraintViolation to the
|
||||
# caller. Each attempt runs in its own transaction(requires_new: true)
|
||||
# SAVEPOINT (lib/prism/orm/transactions.rb, session 94) rather than a
|
||||
# plain nested transaction - this is not decorative: a post whose
|
||||
# insert loses the unique-slug race is still enlisted as a transaction
|
||||
# participant (Model#save calls enlist_in_transaction before the
|
||||
# insert that might fail), so without a savepoint to pop that doomed
|
||||
# participant back out, it would ride along in the SAME frame as
|
||||
# whatever else the surrounding transaction commits and wrongly fire
|
||||
# this model's after_commit(on: :create) - our NotificationMailer
|
||||
# hook below - for a post that was never actually created (id nil).
|
||||
# requires_new's rollback_frame removes it from the frame entirely and
|
||||
# fires after_rollback for it immediately instead. See .import_csv,
|
||||
# the one caller that puts multiple attempts inside a shared outer
|
||||
# transaction where this distinction is observable.
|
||||
#
|
||||
# A candidate that collides with a RESERVED slug (see #reserved_slugs
|
||||
# below) is treated exactly like a real unique-index collision and
|
||||
# retried with the same numeric suffix - raising the store's own
|
||||
# UniqueConstraintViolation before ever attempting the insert reuses
|
||||
# the retry/give-up loop below instead of duplicating it, and is
|
||||
# accurate: both cases mean this exact slug can never resolve to this
|
||||
# post's show page.
|
||||
def create_with_unique_slug(attributes)
|
||||
base_slug = slugify(attributes['title'] || attributes[:title])
|
||||
post = new(attributes)
|
||||
return post unless post.valid?
|
||||
|
||||
attempt = 0
|
||||
begin
|
||||
attempt += 1
|
||||
candidate = attempt == 1 ? base_slug : "#{base_slug}-#{attempt}"
|
||||
raise Prism::Orm::Store::UniqueConstraintViolation if reserved_slugs.include?(candidate)
|
||||
|
||||
post.slug = candidate
|
||||
transaction(requires_new: true) { post.save! }
|
||||
rescue Prism::Orm::Store::UniqueConstraintViolation
|
||||
retry if attempt < MAX_SLUG_ATTEMPTS
|
||||
post.errors.add(:slug, "could not be made unique after #{MAX_SLUG_ATTEMPTS} attempts")
|
||||
end
|
||||
post
|
||||
end
|
||||
|
||||
# Every literal (non-`:param`) single-segment route Blog registers
|
||||
# directly under /posts/ - i.e. `collection do get(:trash) end` and
|
||||
# friends in app.rb. A post whose slug happened to literally equal one
|
||||
# of these (say, a post titled "Trash") would be permanently
|
||||
# unreachable at its own /posts/<slug> URL: Application#dispatch tries
|
||||
# routes in registration order (see the "Custom collection/member/
|
||||
# nested routes go first" comment in Application.resources) and the
|
||||
# collection route, being literal, is registered - and therefore
|
||||
# tried - before the generic /posts/:slug show route it would
|
||||
# otherwise shadow. Derived from the router itself rather than a
|
||||
# hand-kept word list, so a future collection action added in app.rb
|
||||
# is automatically reserved too instead of silently reintroducing
|
||||
# this bug the day someone forgets to update a hardcoded list.
|
||||
def reserved_slugs
|
||||
@reserved_slugs ||= Blog.router.routes.filter_map do |route|
|
||||
match = route.pattern.match(%r{\A/posts/([^:/][^/]*)\z})
|
||||
match && match[1]
|
||||
end.uniq
|
||||
end
|
||||
|
||||
# Bulk-creates posts from an uploaded CSV (title,body columns) in ONE
|
||||
# outer transaction for the whole batch - the counterpart to
|
||||
# PostsController#export, which streams the same shape back out. A row
|
||||
# that can't be imported (blank title, or an exhausted slug retry) is
|
||||
# skipped and reported instead of aborting rows already committed
|
||||
# earlier in the same batch, which is exactly the guarantee
|
||||
# .create_with_unique_slug's per-row savepoint above exists to give:
|
||||
# without it, Store#within_transaction flattens every nested `transaction`
|
||||
# call into ONE shared in-memory table (see TransactionRegistry), so a
|
||||
# single bad row deep in a large CSV would otherwise either roll back
|
||||
# every row already imported ahead of it, or - worse - stay silently
|
||||
# enlisted and fire a phantom "new post" notification for a row that
|
||||
# was never actually saved.
|
||||
def import_csv(io, author:)
|
||||
imported = []
|
||||
skipped = []
|
||||
transaction do
|
||||
CSV.parse(io, headers: true) do |row|
|
||||
title = row['title'].to_s
|
||||
post = create_with_unique_slug(
|
||||
'title' => title, 'body' => row['body'].to_s, 'status' => :published, 'user_id' => author.id
|
||||
)
|
||||
if post.persisted?
|
||||
imported << post
|
||||
else
|
||||
skipped << { title: title, errors: post.errors.full_messages }
|
||||
end
|
||||
end
|
||||
end
|
||||
ImportResult.new(imported, skipped)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def slugify(title)
|
||||
base = title.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
||||
base.empty? ? 'post' : base
|
||||
end
|
||||
end
|
||||
|
||||
# Queued from after_commit (not right after #save returns truthy) so the
|
||||
# notification only ever goes out for a post that actually survived to
|
||||
# disk - a failed after_create elsewhere in the callback chain rolls the
|
||||
# whole save back and this never fires, whereas a plain post-save call
|
||||
# site can't tell the difference between "saved" and "saved, but the
|
||||
# transaction it was part of later rolled back".
|
||||
after_commit(on: :create) { NotificationMailer.new_post(id).deliver_later }
|
||||
|
||||
# Overrides Prism::Orm::Model#to_param (id.to_s) so every post-URL
|
||||
# call site that writes `post.to_param` gets the friendly slug instead
|
||||
# of the numeric id - see app.rb's `resources :posts, param: :slug`.
|
||||
def to_param
|
||||
slug
|
||||
end
|
||||
|
||||
# nil giveaway_slots (the column default) means this post was never
|
||||
# opted into the giveaway feature at all - distinct from a real 0, which
|
||||
# can only be reached by every slot actually being claimed (see
|
||||
# #giveaway_remaining/#giveaway_sold_out?).
|
||||
def giveaway?
|
||||
!giveaway_slots.nil?
|
||||
end
|
||||
|
||||
# claims_count, not claims.count/claims.size - the counter cache column
|
||||
# ClaimsController#create's Post#with_lock critical section reads (via
|
||||
# #giveaway_sold_out? below) has to be the SAME read a concurrent,
|
||||
# blocked-on-the-file-lock request will see the instant it gets its own
|
||||
# turn, and only the counter cache is guaranteed to be updated inside
|
||||
# that same transaction (see Claim's belongs_to counter_cache: true) -
|
||||
# counting the claims association fresh here would still be correct, but
|
||||
# would cost an extra query for no benefit.
|
||||
def giveaway_remaining
|
||||
return nil unless giveaway?
|
||||
[giveaway_slots - claims_count, 0].max
|
||||
end
|
||||
|
||||
def giveaway_sold_out?
|
||||
giveaway? && giveaway_remaining.zero?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Without this, every hard-deleted post left its uploaded cover behind in
|
||||
# public/uploads forever - PostsController#store_cover writes only the
|
||||
# bare filename onto the row, so nothing else ever points back at the file.
|
||||
# Skipped for a plain trash (#soft_deleting?, see acts_as_paranoid above):
|
||||
# the row can still come back via #restore, and a restored post whose
|
||||
# cover image was already deleted from disk would be a broken restore,
|
||||
# not a real one.
|
||||
def remove_cover_file
|
||||
return if soft_deleting?
|
||||
return unless cover && !cover.empty?
|
||||
path = File.join(APP_ROOT, 'public', 'uploads', cover)
|
||||
File.delete(path) if File.exist?(path)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
class User < Prism::Orm::Model
|
||||
has_secure_password
|
||||
has_many :login_sessions, dependent: :destroy
|
||||
# No dependent: - there is no user-deletion flow, so nothing ever destroys
|
||||
# the owner side of this association; used read-only by AccountController#
|
||||
# comments ("my comments" across every post, indexed on comments.user_id).
|
||||
has_many :comments
|
||||
# Same read-only shape as :comments above, used only by AccountController
|
||||
# #moderation to find which posts belong to the current user before
|
||||
# looking up pending comments on them (indexed on posts.user_id - see
|
||||
# db/migrate/20260907000009_add_index_on_posts_user_id.rb).
|
||||
has_many :posts
|
||||
|
||||
# email is PII, so it is encrypted at rest (deterministic: true so
|
||||
# find_by(email:)/uniqueness can still match on plaintext - see
|
||||
# Prism::Orm::Encryption). Validators read the attribute's own reader
|
||||
# method (Validatable#public_send), so they see the decrypted plaintext
|
||||
# here exactly like they would for a plain column.
|
||||
encrypts :email, deterministic: true
|
||||
validates :email, presence: true, uniqueness: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ }
|
||||
|
||||
# Backs PasswordResetsController's forgot-password flow (session 99's
|
||||
# Prism::Orm::TokenFor). The fingerprint block snapshots password_digest
|
||||
# at token-mint time, so a token becomes worthless the moment ANY save
|
||||
# changes the digest - the successful reset that consumes it, but also a
|
||||
# second concurrent reset request or a manual password change elsewhere -
|
||||
# without a separate "used" column or token table to track that by hand.
|
||||
generates_token_for(:password_reset, expires_in: 900) { password_digest }
|
||||
|
||||
# Backs EmailConfirmationsController. Fingerprinted on email (not
|
||||
# password_digest like the reset token above, and not confirmed_at
|
||||
# either) - so a link minted for one address stops verifying the moment
|
||||
# that address changes (see reset_confirmation_if_email_changed below),
|
||||
# but stays valid across repeat visits: confirming twice with the same
|
||||
# link is harmless (#show is a no-op once confirmed_at is already set),
|
||||
# unlike a password reset token, which self-invalidates on first use
|
||||
# because using it always changes the fingerprint it was minted under.
|
||||
# No expires_in - an email that sits unread for a week should still
|
||||
# confirm when it is finally opened, unlike a reset link nobody would
|
||||
# still want to click that late.
|
||||
generates_token_for(:email_confirmation) { email }
|
||||
|
||||
def confirmed?
|
||||
!confirmed_at.nil?
|
||||
end
|
||||
|
||||
# Devise-style "reconfirmable": changing to a new address un-confirms the
|
||||
# account rather than silently keeping the old confirmation around for an
|
||||
# email nobody has proven they control yet. Only fires on an update (see
|
||||
# `before_update`, not `before_save`) - on #create confirmed_at is already
|
||||
# nil from the migration's default, so there is nothing to reset.
|
||||
before_update :reset_confirmation_if_email_changed
|
||||
|
||||
# Same after_commit(on: :create) shape as Post's new-post notification -
|
||||
# queued only once the sign-up has actually survived to disk, so a
|
||||
# rolled-back create (e.g. a validation failure surfacing after this
|
||||
# hook in some future callback) never sends a welcome/confirmation email
|
||||
# for an account that does not exist.
|
||||
after_commit(on: :create) { NotificationMailer.welcome(id).deliver_later }
|
||||
after_commit(on: :create) do
|
||||
NotificationMailer.confirmation_instructions(id, generate_token_for(:email_confirmation)).deliver_later
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_confirmation_if_email_changed
|
||||
self.confirmed_at = nil if attribute_changed?('email')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class CommentPolicy < Prism::Policy
|
||||
def update?
|
||||
user && record.user_id == user.id
|
||||
end
|
||||
|
||||
def destroy?
|
||||
update?
|
||||
end
|
||||
|
||||
def restore?
|
||||
update?
|
||||
end
|
||||
|
||||
# Approving/rejecting is a moderation call, not an authorship one - it
|
||||
# belongs to whoever owns the post the comment hangs off of, same as the
|
||||
# nested-attributes moderation form on PostsController#update (which goes
|
||||
# through PostPolicy#update? instead, since that request targets the post).
|
||||
def approve?
|
||||
user && record.post.user_id == user.id
|
||||
end
|
||||
|
||||
def reject?
|
||||
approve?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class LikePolicy < Prism::Policy
|
||||
def destroy?
|
||||
user && record.user_id == user.id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class PostPolicy < Prism::Policy
|
||||
def update?
|
||||
user && record.user_id == user.id
|
||||
end
|
||||
|
||||
def destroy?
|
||||
update?
|
||||
end
|
||||
|
||||
def restore?
|
||||
update?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
<h1><%= t('account.comments_heading') %></h1>
|
||||
|
||||
<% if comments.none? %>
|
||||
<p><%= t('account.comments_empty') %></p>
|
||||
<% else %>
|
||||
<ul class="comments mine">
|
||||
<% comments.each do |comment| %>
|
||||
<li class="comment" id="comment-<%= comment.id %>">
|
||||
<p>
|
||||
<% if comment_post_linkable?(comment) %>
|
||||
<%== link_to(comment_post_label(comment), url_for(:post, slug: comment_post_slug(comment))) %>
|
||||
<% else %>
|
||||
<%= comment_post_label(comment) %>
|
||||
<% end %>
|
||||
<span class="comment-status"><%= t("comments.status_#{comment.status}") %></span>
|
||||
</p>
|
||||
<p><%= comment.body %></p>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<%== paginate(comments, path: ->(page) { "#{url_for(:account_comments)}?page=#{page}" }) %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,21 @@
|
||||
<h1><%= t('account.heading') %></h1>
|
||||
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<% form_for(user, url: '/account', method: :patch) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:email, t('account.email_label')) %>
|
||||
<%== f.email_field(:email) %>
|
||||
<%== f.error_messages(:email) %>
|
||||
</p>
|
||||
<%== f.submit(t('account.submit')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:root)) %></p>
|
||||
@@ -0,0 +1,27 @@
|
||||
<h1><%= t('account.moderation_heading') %></h1>
|
||||
|
||||
<% if comments.none? %>
|
||||
<p><%= t('account.moderation_empty') %></p>
|
||||
<% else %>
|
||||
<ul class="comments moderation">
|
||||
<% comments.each do |comment| %>
|
||||
<li class="comment" id="comment-<%= comment.id %>">
|
||||
<p>
|
||||
<% if comment_post_linkable?(comment) %>
|
||||
<%== link_to(comment_post_label(comment), url_for(:post, slug: comment_post_slug(comment))) %>
|
||||
<% else %>
|
||||
<%= comment_post_label(comment) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<p><strong><%= comment.author %></strong></p>
|
||||
<p><%= comment.body %></p>
|
||||
<%== button_to(t('comments.approve'), "/posts/#{comment_post_slug(comment)}/comments/#{comment.id}/approve", method: :patch) %>
|
||||
<%== button_to(t('comments.reject'), "/posts/#{comment_post_slug(comment)}/comments/#{comment.id}/reject", method: :patch) %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<%== paginate(comments, path: ->(page) { "#{url_for(:account_moderation)}?page=#{page}" }) %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,22 @@
|
||||
<h1><%= t('comments.edit_heading') %></h1>
|
||||
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<% form_for(comment, url: url_for(:comment, post_id: post.to_param, id: comment.id)) do |f| %>
|
||||
<%== f.hidden_field(:lock_version) %>
|
||||
<p>
|
||||
<%== f.label(:body, t('comments.body_label')) %>
|
||||
<%== f.text_area(:body) %>
|
||||
<%== f.error_messages(:body) %>
|
||||
</p>
|
||||
<%== f.submit(t('comments.update_submit')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:post, slug: post.to_param)) %></p>
|
||||
@@ -0,0 +1,16 @@
|
||||
<h1><%= t('comments.heading') %></h1>
|
||||
<p><%== link_to(post.title, url_for(:post, slug: post.to_param)) %></p>
|
||||
|
||||
<% if comments.none? %>
|
||||
<p><%= t('comments.empty') %></p>
|
||||
<% else %>
|
||||
<ul class="comments">
|
||||
<% comments.each do |comment| %>
|
||||
<%== partial('posts/comment', locals: { comment: comment, post: post }) %>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<%== paginate(comments, path: ->(page) { "#{url_for(:comments, post_id: post.to_param)}?page=#{page}" }) %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,27 @@
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('comments.trash_heading') %></h1>
|
||||
|
||||
<% if comments.empty? %>
|
||||
<p><%= t('comments.trash_empty') %></p>
|
||||
<% else %>
|
||||
<ul class="comments trash">
|
||||
<% comments.each do |comment| %>
|
||||
<li class="comment" id="comment-<%= comment.id %>">
|
||||
<p>
|
||||
<% if comment_post_linkable?(comment) %>
|
||||
<%== link_to(comment_post_label(comment), url_for(:post, slug: comment_post_slug(comment))) %>
|
||||
<% else %>
|
||||
<%= comment_post_label(comment) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<p><%= comment.body %></p>
|
||||
<%== button_to(t('comments.restore'), url_for(:restore_comment, id: comment.id), method: :patch) %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,12 @@
|
||||
<h1><%= t('email_confirmations.request_heading') %></h1>
|
||||
<p><%= t('email_confirmations.request_hint') %></p>
|
||||
|
||||
<% form_for(nil, url: url_for(:email_confirmations), method: :post, object_name: 'email_confirmation') do |f| %>
|
||||
<p>
|
||||
<%== f.label(:email, t('email_confirmations.email_label')) %>
|
||||
<%== f.email_field(:email) %>
|
||||
</p>
|
||||
<%== f.submit(t('email_confirmations.request_submit')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('sessions.sign_in'), url_for(:new_session)) %></p>
|
||||
@@ -1,5 +1,47 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title><%= defined?(title) ? title : 'Prism App' %></title></head>
|
||||
<body><%== content %></body>
|
||||
<html lang="<%= locale %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="csrf-token" content="<%= csrf_token %>">
|
||||
<title><%= t('blog.title') %></title>
|
||||
<%== stylesheet_link_tag('stylesheets/application.css') %>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><%== link_to(t('blog.title'), url_for(:root)) %></h1>
|
||||
<p><%= t('blog.tagline') %></p>
|
||||
<nav>
|
||||
<%== link_to(t('nav.switch_to_polish'), "#{url_for(:root)}?locale=pl") %>
|
||||
|
|
||||
<%== link_to(t('nav.switch_to_english'), "#{url_for(:root)}?locale=en") %>
|
||||
</nav>
|
||||
<p class="session-status">
|
||||
<% if user_signed_in? %>
|
||||
<%= t('sessions.signed_in_as', email: current_user.email) %>
|
||||
|
|
||||
<%== link_to(t('account.nav_link'), url_for(:edit_account)) %>
|
||||
|
|
||||
<%== link_to(t('account.comments_nav_link'), url_for(:account_comments)) %>
|
||||
|
|
||||
<%== link_to(t('account.moderation_nav_link'), url_for(:account_moderation)) %>
|
||||
|
|
||||
<%== link_to(t('login_sessions.nav_link'), url_for(:login_sessions)) %>
|
||||
|
|
||||
<%== link_to(t('comments.trash_link'), url_for(:trash_comments)) %>
|
||||
<%== button_to(t('sessions.sign_out'), '/session', method: :delete) %>
|
||||
<% else %>
|
||||
<%== link_to(t('sessions.sign_in'), url_for(:new_session)) %>
|
||||
|
|
||||
<%== link_to(t('sessions.sign_up'), url_for(:new_user)) %>
|
||||
<% end %>
|
||||
</p>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<main><%== content %></main>
|
||||
<% if content_for?(:sidebar) %>
|
||||
<aside><%== content_for(:sidebar) %></aside>
|
||||
<% end %>
|
||||
</div>
|
||||
<%== javascript_include_tag('javascripts/application.js') %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('login_sessions.heading') %></h1>
|
||||
|
||||
<ul class="login-sessions">
|
||||
<% if current_session %>
|
||||
<li class="login-session current" id="login-session-<%= current_session.id %>">
|
||||
<%= t('login_sessions.this_device', created_at: current_session.created_at) %>
|
||||
<br><small><%= device_detail_for(current_session) %></small>
|
||||
</li>
|
||||
<% end %>
|
||||
<% other_sessions.each do |login_session| %>
|
||||
<li class="login-session" id="login-session-<%= login_session.id %>">
|
||||
<%= t('login_sessions.other_device', created_at: login_session.created_at) %>
|
||||
<br><small><%= device_detail_for(login_session) %></small>
|
||||
<%== button_to(t('login_sessions.revoke'), url_for(:login_session, id: login_session.id), method: :delete,
|
||||
data_confirm: t('login_sessions.confirm_revoke')) %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<% if other_sessions.empty? %>
|
||||
<p><%= t('login_sessions.no_other_devices') %></p>
|
||||
<% else %>
|
||||
<%== button_to(t('login_sessions.sign_out_others'), url_for(:sign_out_others_login_sessions), method: :post,
|
||||
data_confirm: t('login_sessions.confirm_sign_out_others')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,4 @@
|
||||
<h1>Confirm your email address</h1>
|
||||
<p>Hi <%= user.email %>, welcome to Prism Blog. Please confirm this address is yours.</p>
|
||||
<p><a href="<%= confirmation_url %>">Confirm my email</a></p>
|
||||
<p>If you didn't sign up, you can ignore this email.</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
Confirm your email address
|
||||
|
||||
Hi <%= user.email %>, welcome to Prism Blog. Please confirm this address is yours.
|
||||
|
||||
Confirm my email: <%= confirmation_url %>
|
||||
|
||||
If you didn't sign up, you can ignore this email.
|
||||
@@ -0,0 +1,3 @@
|
||||
<h1><%= post.title %></h1>
|
||||
<p><%= post.body %></p>
|
||||
<p><a href="<%= post_url %>">Read it on Prism Blog</a></p>
|
||||
@@ -0,0 +1,7 @@
|
||||
A new post was published on Prism Blog:
|
||||
|
||||
<%= post.title %>
|
||||
|
||||
<%= post.body %>
|
||||
|
||||
Read it at <%= post_url %>
|
||||
@@ -0,0 +1,4 @@
|
||||
<h1>Reset your password</h1>
|
||||
<p>Hi <%= user.email %>, someone (hopefully you) asked to reset your Prism Blog password.</p>
|
||||
<p><a href="<%= reset_url %>">Choose a new password</a></p>
|
||||
<p>This link expires in 15 minutes. If you didn't request this, you can ignore this email.</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
Reset your password
|
||||
|
||||
Hi <%= user.email %>, someone (hopefully you) asked to reset your Prism Blog password.
|
||||
|
||||
Choose a new password: <%= reset_url %>
|
||||
|
||||
This link expires in 15 minutes. If you didn't request this, you can ignore this email.
|
||||
@@ -0,0 +1,3 @@
|
||||
<h1>Welcome to Prism Blog</h1>
|
||||
<p>Hi <%= user.email %>, your account is ready.</p>
|
||||
<p><a href="/posts">Start reading</a></p>
|
||||
@@ -0,0 +1,5 @@
|
||||
Welcome to Prism Blog
|
||||
|
||||
Hi <%= user.email %>, your account is ready.
|
||||
|
||||
Start reading at /posts
|
||||
@@ -0,0 +1,20 @@
|
||||
<% unless errors.empty? %>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('password_resets.reset_heading') %></h1>
|
||||
<% form_for(nil, url: url_for(:password_reset, token: token), method: :patch, object_name: 'password_reset') do |f| %>
|
||||
<p>
|
||||
<%== f.label(:password, t('password_resets.new_password_label')) %>
|
||||
<%== f.password_field(:password) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:password_confirmation, t('password_resets.confirm_password_label')) %>
|
||||
<%== f.password_field(:password_confirmation) %>
|
||||
</p>
|
||||
<%== f.submit(t('password_resets.reset_submit')) %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,12 @@
|
||||
<h1><%= t('password_resets.request_heading') %></h1>
|
||||
<p><%= t('password_resets.request_hint') %></p>
|
||||
|
||||
<% form_for(nil, url: url_for(:password_resets), method: :post, object_name: 'password_reset') do |f| %>
|
||||
<p>
|
||||
<%== f.label(:email, t('password_resets.email_label')) %>
|
||||
<%== f.email_field(:email) %>
|
||||
</p>
|
||||
<%== f.submit(t('password_resets.request_submit')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('sessions.sign_in'), url_for(:new_session)) %></p>
|
||||
@@ -0,0 +1,28 @@
|
||||
<li class="comment" id="comment-<%= comment.id %>">
|
||||
<strong><%= comment.author %></strong>
|
||||
<% unless comment.approved? %>
|
||||
<span class="comment-status"><%= t("comments.status_#{comment.status}") %></span>
|
||||
<% end %>
|
||||
<p><%= comment.body %></p>
|
||||
<p>
|
||||
<%= t('likes.count', count: likes_count(comment)) %>
|
||||
<% if user_signed_in? %>
|
||||
<% own_like = current_user_like(comment) %>
|
||||
<% if own_like %>
|
||||
<%== button_to(t('likes.unlike'), "/likes/#{own_like.id}", method: :delete) %>
|
||||
<% else %>
|
||||
<%== button_to(t('likes.like'), "/likes?likeable_type=Comment&likeable_id=#{comment.id}") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if current_user && current_user.id == comment.user_id %>
|
||||
<%== link_to(t('comments.edit_link'), url_for(:edit_comment, post_id: post.to_param, id: comment.id)) %>
|
||||
<%== button_to(t('comments.delete'), "/posts/#{post.to_param}/comments/#{comment.id}", method: :delete, data_confirm: t('comments.confirm_delete')) %>
|
||||
<% end %>
|
||||
<% if current_user && current_user.id == post.user_id && !comment.approved? %>
|
||||
<%== button_to(t('comments.approve'), "/posts/#{post.to_param}/comments/#{comment.id}/approve", method: :patch) %>
|
||||
<% end %>
|
||||
<% if current_user && current_user.id == post.user_id && !comment.rejected? %>
|
||||
<%== button_to(t('comments.reject'), "/posts/#{post.to_param}/comments/#{comment.id}/reject", method: :patch) %>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -1,4 +1,23 @@
|
||||
<li class="post" id="post-<%= post.id %>">
|
||||
<h2><%= post.title %></h2>
|
||||
<li class="post" id="post-<%= post.to_param %>">
|
||||
<h2><%== link_to(post.title, url_for(:post, slug: post.to_param)) %></h2>
|
||||
<% if post.featured %>
|
||||
<p class="featured-badge"><%= t('posts.featured_badge') %></p>
|
||||
<% end %>
|
||||
<% if featured_only && post.featured && user_signed_in? && current_user.id == post.user_id %>
|
||||
<p class="featured-reorder">
|
||||
<% unless post.first_in_list? %>
|
||||
<%== button_to(t('posts.move_up'), url_for(:move_up_post, slug: post.to_param), method: :patch) %>
|
||||
<% end %>
|
||||
<% unless post.last_in_list? %>
|
||||
<%== button_to(t('posts.move_down'), url_for(:move_down_post, slug: post.to_param), method: :patch) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% if post.cover %>
|
||||
<p><img src="/uploads/<%= post.cover %>" alt="<%= post.title %>" width="120"></p>
|
||||
<% end %>
|
||||
<p><%= post.body %></p>
|
||||
<p><%= t('posts.comments_count', count: post.comments_count) %></p>
|
||||
<p class="last-activity"><%= t('posts.last_activity', when: l(post.updated_at)) %></p>
|
||||
<p><%== link_to(t('posts.read_more'), url_for(:post, slug: post.to_param)) %></p>
|
||||
</li>
|
||||
@@ -0,0 +1,56 @@
|
||||
<h1><%= t('posts.edit_heading') %></h1>
|
||||
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<% form_for(post, url: url_for(:post, slug: post.to_param)) do |f| %>
|
||||
<%== f.hidden_field(:lock_version) %>
|
||||
<p>
|
||||
<%== f.label(:title, t('posts.title_label')) %>
|
||||
<%== f.text_field(:title) %>
|
||||
<%== f.error_messages(:title) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:body, t('posts.body_label')) %>
|
||||
<%== f.text_area(:body) %>
|
||||
<%== f.error_messages(:body) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.check_box(:featured) %>
|
||||
<%== f.label(:featured, t('posts.featured_label')) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:giveaway_slots, t('claims.giveaway_slots_label')) %>
|
||||
<%== f.text_field(:giveaway_slots) %>
|
||||
<%== f.error_messages(:giveaway_slots) %>
|
||||
</p>
|
||||
<%== f.submit(t('posts.update_submit')) %>
|
||||
<%== f.submit(t('posts.preview_submit'), formaction: url_for(:preview_post, slug: post.to_param)) %>
|
||||
|
||||
<h2><%= t('posts.moderate_comments_heading') %></h2>
|
||||
<% moderated_comments = post.comments.order(:created_at, :desc).to_a %>
|
||||
<% if moderated_comments.empty? %>
|
||||
<p><%= t('posts.moderate_comments_empty') %></p>
|
||||
<% else %>
|
||||
<% f.fields_for(:comments, moderated_comments) do |cf| %>
|
||||
<p class="moderated-comment">
|
||||
<%== cf.hidden_field(:id) %>
|
||||
<%== cf.label(:body, t('posts.moderate_comment_body_label')) %>
|
||||
<%== cf.text_area(:body) %>
|
||||
<%== cf.error_messages(:body) %>
|
||||
<label>
|
||||
<%== cf.check_box(:_destroy) %>
|
||||
<%= t('posts.moderate_comment_remove_label') %>
|
||||
</label>
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -1,21 +1,77 @@
|
||||
<h1>Blog</h1>
|
||||
<% content_for(:sidebar) do %>
|
||||
<%# Deliberately NOT keyed on current_user, unlike the fresh_when etag on this same action: the 5 most recent post titles/links are identical for every viewer, and the signed-in-as banner + new-post form that DO vary per viewer render outside this block, never inside the cached fragment. See prism-postep.md session 62/64. %>
|
||||
<% cache(:recent_posts_sidebar, expires_in: 60) do %>
|
||||
<h2><%= t('posts.recent_heading') %></h2>
|
||||
<ul>
|
||||
<% Post.order(:created_at, :desc).limit(5).each do |recent| %>
|
||||
<li><%== link_to(recent.title, url_for(:post, slug: recent.to_param)) %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<form method="post" action="/posts">
|
||||
<input type="text" name="title" placeholder="Title">
|
||||
<textarea name="body" placeholder="Body"></textarea>
|
||||
<button type="submit">Create post</button>
|
||||
</form>
|
||||
|
||||
<% if user_signed_in? %>
|
||||
<h2><%= t('posts.new_post_heading') %></h2>
|
||||
<% form_for(new_post, url: url_for(:posts), html: { enctype: 'multipart/form-data' }) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:title, t('posts.title_label')) %>
|
||||
<%== f.text_field(:title) %>
|
||||
<%== f.error_messages(:title) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:body, t('posts.body_label')) %>
|
||||
<%== f.text_area(:body) %>
|
||||
<%== f.error_messages(:body) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:cover, t('posts.cover_label')) %>
|
||||
<%== f.tag(:input, type: 'file', name: 'post[cover]', id: 'post_cover') %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.check_box(:featured) %>
|
||||
<%== f.label(:featured, t('posts.featured_label')) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:giveaway_slots, t('claims.giveaway_slots_label')) %>
|
||||
<%== f.text_field(:giveaway_slots) %>
|
||||
<%== f.error_messages(:giveaway_slots) %>
|
||||
</p>
|
||||
<%== f.submit(t('posts.submit')) %>
|
||||
<% end %>
|
||||
|
||||
<h2><%= t('posts.import_heading') %></h2>
|
||||
<% form_for(Post.new, url: url_for(:import_posts), method: :post, html: { enctype: 'multipart/form-data' }) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:file, t('posts.import_label')) %>
|
||||
<%== f.tag(:input, type: 'file', name: 'file', id: 'post_file', accept: '.csv,text/csv') %>
|
||||
</p>
|
||||
<%== f.submit(t('posts.import_submit')) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p class="post-filters">
|
||||
<%== link_to(t('posts.filter_all'), url_for(:posts)) %>
|
||||
| <%== link_to(t('posts.filter_recent', days: 7), "#{url_for(:posts)}?days=7") %>
|
||||
| <%== link_to(t('posts.filter_recent', days: 30), "#{url_for(:posts)}?days=30") %>
|
||||
| <%== link_to(t('posts.filter_featured'), "#{url_for(:posts)}?featured=1") %>
|
||||
</p>
|
||||
|
||||
<ul class="posts">
|
||||
<% posts.each do |post| %>
|
||||
<%== partial('post', locals: { post: post }) %>
|
||||
<%== partial('post', locals: { post: post, featured_only: featured_only }) %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%== paginate(posts, path: ->(page) { "#{url_for(:posts)}?page=#{page}#{days.positive? ? "&days=#{days}" : ''}#{featured_only ? '&featured=1' : ''}" }) %>
|
||||
@@ -0,0 +1,46 @@
|
||||
<h1><%= t('posts.preview_heading') %></h1>
|
||||
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<% if diff.empty? %>
|
||||
<p><%= t('posts.preview_noop') %></p>
|
||||
<% else %>
|
||||
<h2><%= t('posts.preview_diff_heading') %></h2>
|
||||
<table class="diff">
|
||||
<thead>
|
||||
<tr><th><%= t('posts.preview_field') %></th><th><%= t('posts.preview_current') %></th><th><%= t('posts.preview_proposed') %></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% diff.each do |field, (old_value, new_value)| %>
|
||||
<tr>
|
||||
<td><%= field %></td>
|
||||
<td><%= old_value %></td>
|
||||
<td><%= new_value %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
|
||||
<!-- post.title here is read straight off `post` AFTER Dirty#restore_attributes -
|
||||
it must be the row's actual saved title, proving the preview above never
|
||||
mutated the persisted record. -->
|
||||
<p><%= t('posts.preview_currently_saved', title: post.title) %></p>
|
||||
|
||||
<% if errors.empty? %>
|
||||
<% form_for(post, url: url_for(:post, slug: post.to_param), method: :patch) do |f| %>
|
||||
<%== f.hidden_field(:title, value: attempted['title']) %>
|
||||
<%== f.hidden_field(:body, value: attempted['body']) %>
|
||||
<%== f.hidden_field(:lock_version) %>
|
||||
<%== f.submit(t('posts.preview_confirm')) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.edit_link'), url_for(:edit_post, slug: post.to_param)) %></p>
|
||||
@@ -1,2 +1,75 @@
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<h1><%= post.title %></h1>
|
||||
<p class="status"><%= t("posts.status_#{post.status}") %></p>
|
||||
<% if post.cover %>
|
||||
<p><img src="/uploads/<%= post.cover %>" alt="<%= post.title %>"></p>
|
||||
<% end %>
|
||||
<p><%= post.body %></p>
|
||||
<p class="last-activity"><%= t('posts.last_activity', when: l(post.updated_at)) %></p>
|
||||
<p>
|
||||
<%= t('likes.count', count: likes_count(post)) %>
|
||||
<% if user_signed_in? %>
|
||||
<% own_like = current_user_like(post) %>
|
||||
<% if own_like %>
|
||||
<%== button_to(t('likes.unlike'), "/likes/#{own_like.id}", method: :delete) %>
|
||||
<% else %>
|
||||
<%== button_to(t('likes.like'), "/likes?likeable_type=Post&likeable_id=#{post.id}") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if post.giveaway? %>
|
||||
<p class="giveaway">
|
||||
<% if post.giveaway_sold_out? %>
|
||||
<%= t('claims.sold_out_label') %>
|
||||
<% else %>
|
||||
<%= t('claims.remaining_label', count: post.giveaway_remaining) %>
|
||||
<% end %>
|
||||
<% if user_signed_in? %>
|
||||
<% if current_user_claim(post) %>
|
||||
<%= t('claims.already_claimed_label') %>
|
||||
<% elsif !post.giveaway_sold_out? %>
|
||||
<%== button_to(t('claims.claim_button'), "/posts/#{post.to_param}/claims") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
<% if current_user && current_user.id == post.user_id %>
|
||||
<p><%== link_to(t('posts.edit_link'), url_for(:edit_post, slug: post.to_param)) %></p>
|
||||
<% if post.published? %>
|
||||
<%== button_to(t('posts.unpublish'), url_for(:unpublish_post, slug: post.to_param), method: :patch) %>
|
||||
<% else %>
|
||||
<%== button_to(t('posts.publish'), url_for(:publish_post, slug: post.to_param), method: :patch) %>
|
||||
<% end %>
|
||||
<%== button_to(t('posts.delete'), url_for(:post, slug: post.to_param), method: :delete, data_confirm: t('posts.confirm_delete')) %>
|
||||
<%== link_to(t('posts.trash_heading'), url_for(:trash_posts)) %>
|
||||
<% end %>
|
||||
|
||||
<h2><%= t('comments.heading') %></h2>
|
||||
<p><%== link_to(t('comments.view_all_link'), url_for(:comments, post_id: post.to_param)) %></p>
|
||||
<% if comments.empty? %>
|
||||
<p><%= t('comments.empty') %></p>
|
||||
<% else %>
|
||||
<ul class="comments">
|
||||
<% comments.each do |comment| %>
|
||||
<%== partial('comment', locals: { comment: comment, post: post }) %>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<% if user_signed_in? %>
|
||||
<% form_for(new_comment, url: "/posts/#{post.to_param}/comments", method: :post) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:author, t('comments.author_label')) %>
|
||||
<%== f.text_field(:author) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:body, t('comments.body_label')) %>
|
||||
<%== f.text_area(:body) %>
|
||||
</p>
|
||||
<%== f.submit(t('comments.submit')) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,21 @@
|
||||
<% if notice %>
|
||||
<p class="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('posts.trash_heading') %></h1>
|
||||
|
||||
<% if posts.empty? %>
|
||||
<p><%= t('posts.trash_empty') %></p>
|
||||
<% else %>
|
||||
<ul class="posts trash">
|
||||
<% posts.each do |post| %>
|
||||
<li class="post" id="post-<%= post.to_param %>">
|
||||
<h2><%= post.title %></h2>
|
||||
<p><%= post.body %></p>
|
||||
<%== button_to(t('posts.restore'), url_for(:restore_post, slug: post.to_param), method: :patch) %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('posts.back_to_list'), url_for(:posts)) %></p>
|
||||
@@ -0,0 +1,28 @@
|
||||
<% unless errors.empty? %>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('sessions.heading') %></h1>
|
||||
<% form_for(User.new, url: '/session', method: :post) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:email, t('sessions.email_label')) %>
|
||||
<%== f.email_field(:email) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:password, t('sessions.password_label')) %>
|
||||
<%== f.password_field(:password) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.check_box(:remember_me) %>
|
||||
<%== f.label(:remember_me, t('sessions.remember_me_label')) %>
|
||||
</p>
|
||||
<%== f.submit(t('sessions.submit')) %>
|
||||
<% end %>
|
||||
|
||||
<p><%== link_to(t('sessions.need_account'), url_for(:new_user)) %></p>
|
||||
<p><%== link_to(t('sessions.forgot_password'), url_for(:new_password_reset)) %></p>
|
||||
<p><%== link_to(t('sessions.resend_confirmation'), url_for(:new_email_confirmation)) %></p>
|
||||
@@ -0,0 +1,27 @@
|
||||
<% unless errors.empty? %>
|
||||
<h2><%= t('posts.errors_heading') %></h2>
|
||||
<ul class="errors">
|
||||
<% errors.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<h1><%= t('users.heading') %></h1>
|
||||
<% form_for(user, url: '/users', method: :post) do |f| %>
|
||||
<p>
|
||||
<%== f.label(:email, t('users.email_label')) %>
|
||||
<%== f.email_field(:email) %>
|
||||
<%== f.error_messages(:email) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:password, t('users.password_label')) %>
|
||||
<%== f.password_field(:password) %>
|
||||
<%== f.error_messages(:password) %>
|
||||
</p>
|
||||
<p>
|
||||
<%== f.label(:password_confirmation, t('users.password_confirmation_label')) %>
|
||||
<%== f.password_field(:password_confirmation) %>
|
||||
</p>
|
||||
<%== f.submit(t('users.submit')) %>
|
||||
<% end %>
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
require_relative '../app'
|
||||
require 'prism'
|
||||
|
||||
Prism::Cli.new(__dir__).console(['--prompt=blog> '])
|
||||
@@ -1,6 +1,18 @@
|
||||
require_relative '../app'
|
||||
|
||||
server = Prism::Server.new(Blog, port: (ARGV[0] || 3000).to_i).start
|
||||
# A hard cap on the total number of live WebSocket connections this
|
||||
# process will hold open at once (across every post's /cable stream
|
||||
# combined), distinct from PostActivityChannel::MAX_VIEWERS_PER_POST which
|
||||
# only bounds a single stream - without this, enough popular posts could
|
||||
# still exhaust the process's file descriptors even though no individual
|
||||
# post ever hits its own per-stream limit.
|
||||
MAX_WEBSOCKET_CONNECTIONS = 2000
|
||||
|
||||
server = Prism::Server.new(
|
||||
Blog,
|
||||
port: (ARGV[0] || 3000).to_i,
|
||||
max_websocket_connections: MAX_WEBSOCKET_CONNECTIONS
|
||||
).start
|
||||
puts "listening on #{server.url}"
|
||||
trap('INT') { server.stop; exit }
|
||||
sleep
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"errors": {
|
||||
"forbidden": "You are not allowed to do that."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Prism Blog",
|
||||
"tagline": "A tiny blog built entirely on Prism subsystems."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"switch_to_polish": "Polski",
|
||||
"switch_to_english": "English"
|
||||
},
|
||||
"posts": {
|
||||
"new_post_heading": "New post",
|
||||
"title_label": "Title",
|
||||
"body_label": "Body",
|
||||
"cover_label": "Cover image (optional)",
|
||||
"featured_label": "Featured",
|
||||
"featured_badge": "Featured",
|
||||
"move_up": "Move up",
|
||||
"move_down": "Move down",
|
||||
"submit": "Publish",
|
||||
"created_notice": "Post #%{id} created",
|
||||
"errors_heading": "Please fix the following:",
|
||||
"recent_heading": "Recent posts",
|
||||
"filter_all": "All posts",
|
||||
"filter_recent": "Last %{days} days",
|
||||
"filter_featured": "Featured only",
|
||||
"back_to_list": "Back to all posts",
|
||||
"read_more": "Read more",
|
||||
"not_found": "Post not found",
|
||||
"delete": "Delete post",
|
||||
"confirm_delete": "Move this post to the trash? You can restore it later.",
|
||||
"deleted_notice": "Post #%{id} moved to the trash",
|
||||
"trash_heading": "Trash",
|
||||
"trash_empty": "Your trash is empty.",
|
||||
"restore": "Restore",
|
||||
"restored_notice": "Post #%{id} restored",
|
||||
"edit_heading": "Edit post",
|
||||
"edit_link": "Edit post",
|
||||
"update_submit": "Save changes",
|
||||
"preview_submit": "Preview changes",
|
||||
"updated_notice": "Post updated (changed: %{fields})",
|
||||
"update_noop_notice": "Nothing changed",
|
||||
"preview_heading": "Preview changes",
|
||||
"preview_diff_heading": "Proposed changes",
|
||||
"preview_field": "Field",
|
||||
"preview_current": "Currently saved",
|
||||
"preview_proposed": "Proposed",
|
||||
"preview_noop": "Nothing would change.",
|
||||
"preview_currently_saved": "This post's saved title is still: %{title}",
|
||||
"preview_confirm": "Save these changes",
|
||||
"stale_notice": "Someone else changed this post while you were editing. Review their version below and try again.",
|
||||
"comments_count": "%{count} comment(s)",
|
||||
"last_activity": "Last activity: %{when}",
|
||||
"moderate_comments_heading": "Moderate comments",
|
||||
"moderate_comments_empty": "No comments to moderate.",
|
||||
"moderate_comment_body_label": "Body",
|
||||
"moderate_comment_remove_label": "Remove this comment",
|
||||
"status_draft": "Draft",
|
||||
"status_published": "Published",
|
||||
"status_archived": "Archived",
|
||||
"publish": "Publish post",
|
||||
"unpublish": "Unpublish post",
|
||||
"published_notice": "Post #%{id} published",
|
||||
"unpublished_notice": "Post #%{id} unpublished",
|
||||
"import_heading": "Import posts from CSV",
|
||||
"import_label": "CSV file (title,body columns)",
|
||||
"import_submit": "Import",
|
||||
"import_notice": "Import finished: %{imported} post(s) imported, %{skipped} skipped",
|
||||
"import_missing_file": "Choose a CSV file to import"
|
||||
},
|
||||
"comments": {
|
||||
"heading": "Comments",
|
||||
"view_all_link": "View all comments",
|
||||
"empty": "No comments yet.",
|
||||
"author_label": "Your name",
|
||||
"body_label": "Comment",
|
||||
"submit": "Add comment",
|
||||
"created_notice": "Comment added",
|
||||
"delete": "Remove",
|
||||
"confirm_delete": "Remove this comment?",
|
||||
"edit_link": "Edit",
|
||||
"edit_heading": "Edit comment",
|
||||
"update_submit": "Save changes",
|
||||
"updated_notice": "Comment updated (changed: %{fields})",
|
||||
"update_noop_notice": "Nothing changed",
|
||||
"not_found": "Comment not found",
|
||||
"stale_notice": "Someone else changed this comment while you were editing. Review their version below and try again.",
|
||||
"status_pending": "Pending approval",
|
||||
"status_approved": "Approved",
|
||||
"status_rejected": "Rejected",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"approved_notice": "Comment approved",
|
||||
"rejected_notice": "Comment rejected",
|
||||
"trash_link": "Your trashed comments",
|
||||
"trash_heading": "Your trashed comments",
|
||||
"trash_empty": "You have no removed comments.",
|
||||
"restore": "Restore",
|
||||
"restored_notice": "Comment restored",
|
||||
"orphaned_post": "(post no longer exists)",
|
||||
"post_also_trashed": "%{title} (also in trash)"
|
||||
},
|
||||
"likes": {
|
||||
"count": "%{count} like(s)",
|
||||
"like": "Like",
|
||||
"unlike": "Unlike",
|
||||
"not_found": "That can no longer be liked."
|
||||
},
|
||||
"claims": {
|
||||
"giveaway_slots_label": "Giveaway slots (blank = not a giveaway)",
|
||||
"remaining_label": "%{count} giveaway slot(s) left",
|
||||
"sold_out_label": "Giveaway sold out",
|
||||
"already_claimed_label": "You already claimed a slot",
|
||||
"claim_button": "Claim a slot",
|
||||
"claimed_notice": "You claimed a giveaway slot!",
|
||||
"already_claimed_notice": "You already claimed a slot for this giveaway",
|
||||
"sold_out_notice": "Sorry, this giveaway is sold out"
|
||||
},
|
||||
"sessions": {
|
||||
"heading": "Sign in",
|
||||
"email_label": "Email",
|
||||
"password_label": "Password",
|
||||
"remember_me_label": "Remember me",
|
||||
"submit": "Sign in",
|
||||
"need_account": "Need an account? Sign up",
|
||||
"signed_in_notice": "Signed in successfully",
|
||||
"signed_out_notice": "Signed out",
|
||||
"invalid_credentials": "Invalid email or password",
|
||||
"signed_in_as": "Signed in as %{email}",
|
||||
"sign_out": "Sign out",
|
||||
"sign_in": "Sign in",
|
||||
"sign_up": "Sign up",
|
||||
"forgot_password": "Forgot your password?",
|
||||
"resend_confirmation": "Didn't get a confirmation email? Resend it"
|
||||
},
|
||||
"password_resets": {
|
||||
"request_heading": "Reset your password",
|
||||
"request_hint": "Enter your email and we'll send you a link to choose a new password.",
|
||||
"email_label": "Email",
|
||||
"request_submit": "Send reset link",
|
||||
"instructions_sent_notice": "If that email has an account, we've sent a password reset link to it.",
|
||||
"reset_heading": "Choose a new password",
|
||||
"new_password_label": "New password",
|
||||
"confirm_password_label": "Confirm new password",
|
||||
"reset_submit": "Update password",
|
||||
"invalid_token_notice": "That password reset link is invalid or has expired.",
|
||||
"reset_notice": "Your password has been updated. Sign in with your new password."
|
||||
},
|
||||
"email_confirmations": {
|
||||
"request_heading": "Resend confirmation instructions",
|
||||
"request_hint": "Enter your email and we'll send you a new confirmation link.",
|
||||
"email_label": "Email",
|
||||
"request_submit": "Resend confirmation email",
|
||||
"instructions_sent_notice": "If that email has an unconfirmed account, we've sent a confirmation link to it.",
|
||||
"invalid_token_notice": "That confirmation link is invalid or has already been used.",
|
||||
"confirmed_notice": "Your email address has been confirmed."
|
||||
},
|
||||
"users": {
|
||||
"heading": "Create an account",
|
||||
"email_label": "Email",
|
||||
"password_label": "Password",
|
||||
"password_confirmation_label": "Confirm password",
|
||||
"submit": "Sign up",
|
||||
"signed_up_notice": "Welcome! Your account was created",
|
||||
"email_taken_notice": "That email is already taken"
|
||||
},
|
||||
"account": {
|
||||
"heading": "Your account",
|
||||
"email_label": "Email",
|
||||
"submit": "Save",
|
||||
"updated_notice": "Account updated (changed: %{fields})",
|
||||
"update_noop_notice": "Nothing changed",
|
||||
"nav_link": "Your account",
|
||||
"comments_nav_link": "Your comments",
|
||||
"comments_heading": "Your comments",
|
||||
"comments_empty": "You have not commented on anything yet.",
|
||||
"moderation_nav_link": "Moderation queue",
|
||||
"moderation_heading": "Comments waiting for your approval",
|
||||
"moderation_empty": "Nothing is waiting for your approval right now."
|
||||
},
|
||||
"login_sessions": {
|
||||
"nav_link": "Your devices",
|
||||
"heading": "Your devices",
|
||||
"this_device": "This device (remembered since %{created_at})",
|
||||
"other_device": "Another device (remembered since %{created_at})",
|
||||
"device_detail": "%{user_agent} from %{ip_address}",
|
||||
"unknown_user_agent": "unknown browser",
|
||||
"unknown_ip_address": "unknown address",
|
||||
"revoke": "Sign out this device",
|
||||
"confirm_revoke": "Sign out this remembered device?",
|
||||
"no_other_devices": "No other devices are remembering you right now.",
|
||||
"sign_out_others": "Sign out all other devices",
|
||||
"confirm_sign_out_others": "Sign out every OTHER remembered device? This one stays signed in.",
|
||||
"revoked_notice": "That device was signed out",
|
||||
"signed_out_others_notice": "All other devices were signed out",
|
||||
"not_found": "That device was already signed out"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"errors": {
|
||||
"forbidden": "Nie masz do tego uprawnien."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog Prism",
|
||||
"tagline": "Maly blog zbudowany w calosci na podsystemach Prism."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Start",
|
||||
"switch_to_polish": "Polski",
|
||||
"switch_to_english": "English"
|
||||
},
|
||||
"posts": {
|
||||
"new_post_heading": "Nowy wpis",
|
||||
"title_label": "Tytul",
|
||||
"body_label": "Tresc",
|
||||
"cover_label": "Obrazek (opcjonalnie)",
|
||||
"featured_label": "Wyroznione",
|
||||
"featured_badge": "Wyroznione",
|
||||
"move_up": "Przesun w gore",
|
||||
"move_down": "Przesun w dol",
|
||||
"submit": "Opublikuj",
|
||||
"created_notice": "Wpis #%{id} utworzony",
|
||||
"errors_heading": "Popraw ponizsze bledy:",
|
||||
"recent_heading": "Ostatnie wpisy",
|
||||
"filter_all": "Wszystkie wpisy",
|
||||
"filter_recent": "Ostatnie %{days} dni",
|
||||
"filter_featured": "Tylko wyroznione",
|
||||
"back_to_list": "Wroc do listy wpisow",
|
||||
"read_more": "Czytaj dalej",
|
||||
"not_found": "Nie znaleziono wpisu",
|
||||
"delete": "Usun wpis",
|
||||
"confirm_delete": "Przeniesc ten wpis do kosza? Bedzie mozna go potem przywrocic.",
|
||||
"deleted_notice": "Wpis #%{id} przeniesiony do kosza",
|
||||
"trash_heading": "Kosz",
|
||||
"trash_empty": "Kosz jest pusty.",
|
||||
"restore": "Przywroc",
|
||||
"restored_notice": "Wpis #%{id} przywrocony",
|
||||
"edit_heading": "Edytuj wpis",
|
||||
"edit_link": "Edytuj wpis",
|
||||
"update_submit": "Zapisz zmiany",
|
||||
"preview_submit": "Podglad zmian",
|
||||
"updated_notice": "Wpis zaktualizowany (zmienione: %{fields})",
|
||||
"update_noop_notice": "Nic sie nie zmienilo",
|
||||
"preview_heading": "Podglad zmian",
|
||||
"preview_diff_heading": "Proponowane zmiany",
|
||||
"preview_field": "Pole",
|
||||
"preview_current": "Aktualnie zapisane",
|
||||
"preview_proposed": "Proponowane",
|
||||
"preview_noop": "Nic by sie nie zmienilo.",
|
||||
"preview_currently_saved": "Zapisany tytul tego wpisu to nadal: %{title}",
|
||||
"preview_confirm": "Zapisz te zmiany",
|
||||
"stale_notice": "Ktos inny zmienil ten wpis, gdy go edytowales. Sprawdz jego wersje ponizej i sprobuj ponownie.",
|
||||
"comments_count": "Komentarze: %{count}",
|
||||
"last_activity": "Ostatnia aktywnosc: %{when}",
|
||||
"moderate_comments_heading": "Moderuj komentarze",
|
||||
"moderate_comments_empty": "Brak komentarzy do moderacji.",
|
||||
"moderate_comment_body_label": "Tresc",
|
||||
"moderate_comment_remove_label": "Usun ten komentarz",
|
||||
"status_draft": "Szkic",
|
||||
"status_published": "Opublikowany",
|
||||
"status_archived": "Zarchiwizowany",
|
||||
"publish": "Opublikuj post",
|
||||
"unpublish": "Cofnij publikacje",
|
||||
"published_notice": "Post #%{id} opublikowany",
|
||||
"unpublished_notice": "Post #%{id} cofniety do szkicow",
|
||||
"import_heading": "Import wpisow z CSV",
|
||||
"import_label": "Plik CSV (kolumny title,body)",
|
||||
"import_submit": "Importuj",
|
||||
"import_notice": "Import zakonczony: zaimportowano %{imported}, pominieto %{skipped}",
|
||||
"import_missing_file": "Wybierz plik CSV do zaimportowania"
|
||||
},
|
||||
"comments": {
|
||||
"heading": "Komentarze",
|
||||
"view_all_link": "Zobacz wszystkie komentarze",
|
||||
"empty": "Brak komentarzy.",
|
||||
"author_label": "Twoje imie",
|
||||
"body_label": "Komentarz",
|
||||
"submit": "Dodaj komentarz",
|
||||
"created_notice": "Komentarz dodany",
|
||||
"delete": "Usun",
|
||||
"confirm_delete": "Usunac ten komentarz?",
|
||||
"edit_link": "Edytuj",
|
||||
"edit_heading": "Edytuj komentarz",
|
||||
"update_submit": "Zapisz zmiany",
|
||||
"updated_notice": "Komentarz zaktualizowany (zmienione: %{fields})",
|
||||
"update_noop_notice": "Nic sie nie zmienilo",
|
||||
"not_found": "Nie znaleziono komentarza",
|
||||
"stale_notice": "Ktos inny zmienil ten komentarz, gdy go edytowales. Sprawdz jego wersje ponizej i sprobuj ponownie.",
|
||||
"status_pending": "Oczekuje na zatwierdzenie",
|
||||
"status_approved": "Zatwierdzony",
|
||||
"status_rejected": "Odrzucony",
|
||||
"approve": "Zatwierdz",
|
||||
"reject": "Odrzuc",
|
||||
"approved_notice": "Komentarz zatwierdzony",
|
||||
"rejected_notice": "Komentarz odrzucony",
|
||||
"trash_link": "Twoje usuniete komentarze",
|
||||
"trash_heading": "Twoje usuniete komentarze",
|
||||
"trash_empty": "Nie masz usunietych komentarzy.",
|
||||
"restore": "Przywroc",
|
||||
"restored_notice": "Komentarz przywrocony",
|
||||
"orphaned_post": "(wpis juz nie istnieje)",
|
||||
"post_also_trashed": "%{title} (tez w koszu)"
|
||||
},
|
||||
"likes": {
|
||||
"count": "Polubienia: %{count}",
|
||||
"like": "Polub",
|
||||
"unlike": "Cofnij polubienie",
|
||||
"not_found": "Tego juz nie mozna polubic."
|
||||
},
|
||||
"claims": {
|
||||
"giveaway_slots_label": "Miejsca w rozdaniu (puste = brak rozdania)",
|
||||
"remaining_label": "Zostalo miejsc: %{count}",
|
||||
"sold_out_label": "Rozdanie zakonczone",
|
||||
"already_claimed_label": "Juz odebrales swoje miejsce",
|
||||
"claim_button": "Odbierz miejsce",
|
||||
"claimed_notice": "Odebrales miejsce w rozdaniu!",
|
||||
"already_claimed_notice": "Juz odebrales miejsce w tym rozdaniu",
|
||||
"sold_out_notice": "Niestety, wszystkie miejsca w rozdaniu zostaly juz zajete"
|
||||
},
|
||||
"sessions": {
|
||||
"heading": "Zaloguj sie",
|
||||
"email_label": "Email",
|
||||
"password_label": "Haslo",
|
||||
"remember_me_label": "Zapamietaj mnie",
|
||||
"submit": "Zaloguj",
|
||||
"need_account": "Nie masz konta? Zarejestruj sie",
|
||||
"signed_in_notice": "Zalogowano pomyslnie",
|
||||
"signed_out_notice": "Wylogowano",
|
||||
"invalid_credentials": "Nieprawidlowy email lub haslo",
|
||||
"signed_in_as": "Zalogowano jako %{email}",
|
||||
"sign_out": "Wyloguj",
|
||||
"sign_in": "Zaloguj sie",
|
||||
"sign_up": "Zarejestruj sie",
|
||||
"forgot_password": "Nie pamietasz hasla?",
|
||||
"resend_confirmation": "Nie dostales emaila potwierdzajacego? Wyslij ponownie"
|
||||
},
|
||||
"password_resets": {
|
||||
"request_heading": "Zresetuj haslo",
|
||||
"request_hint": "Podaj swoj email, a wyslemy Ci link do ustawienia nowego hasla.",
|
||||
"email_label": "Email",
|
||||
"request_submit": "Wyslij link resetujacy",
|
||||
"instructions_sent_notice": "Jesli ten email ma konto, wyslalismy na niego link do resetu hasla.",
|
||||
"reset_heading": "Ustaw nowe haslo",
|
||||
"new_password_label": "Nowe haslo",
|
||||
"confirm_password_label": "Potwierdz nowe haslo",
|
||||
"reset_submit": "Zmien haslo",
|
||||
"invalid_token_notice": "Ten link do resetu hasla jest nieprawidlowy albo wygasl.",
|
||||
"reset_notice": "Haslo zostalo zmienione. Zaloguj sie nowym haslem."
|
||||
},
|
||||
"email_confirmations": {
|
||||
"request_heading": "Wyslij ponownie link potwierdzajacy",
|
||||
"request_hint": "Podaj swoj email, a wyslemy Ci nowy link potwierdzajacy.",
|
||||
"email_label": "Email",
|
||||
"request_submit": "Wyslij ponownie email potwierdzajacy",
|
||||
"instructions_sent_notice": "Jesli ten email ma niepotwierdzone konto, wyslalismy na niego link potwierdzajacy.",
|
||||
"invalid_token_notice": "Ten link potwierdzajacy jest nieprawidlowy albo zostal juz uzyty.",
|
||||
"confirmed_notice": "Twoj adres email zostal potwierdzony."
|
||||
},
|
||||
"users": {
|
||||
"heading": "Zaloz konto",
|
||||
"email_label": "Email",
|
||||
"password_label": "Haslo",
|
||||
"password_confirmation_label": "Powtorz haslo",
|
||||
"submit": "Zarejestruj",
|
||||
"signed_up_notice": "Witaj! Konto zostalo utworzone",
|
||||
"email_taken_notice": "Ten email jest juz zajety"
|
||||
},
|
||||
"account": {
|
||||
"heading": "Twoje konto",
|
||||
"email_label": "Email",
|
||||
"submit": "Zapisz",
|
||||
"updated_notice": "Konto zaktualizowane (zmienione: %{fields})",
|
||||
"update_noop_notice": "Nic sie nie zmienilo",
|
||||
"nav_link": "Twoje konto",
|
||||
"comments_nav_link": "Twoje komentarze",
|
||||
"comments_heading": "Twoje komentarze",
|
||||
"comments_empty": "Nie skomentowales/as jeszcze niczego.",
|
||||
"moderation_nav_link": "Kolejka moderacji",
|
||||
"moderation_heading": "Komentarze czekajace na Twoja moderacje",
|
||||
"moderation_empty": "Nic obecnie nie czeka na moderacje."
|
||||
},
|
||||
"login_sessions": {
|
||||
"nav_link": "Twoje urzadzenia",
|
||||
"heading": "Twoje urzadzenia",
|
||||
"this_device": "To urzadzenie (zapamietane od %{created_at})",
|
||||
"other_device": "Inne urzadzenie (zapamietane od %{created_at})",
|
||||
"device_detail": "%{user_agent} z adresu %{ip_address}",
|
||||
"unknown_user_agent": "nieznana przegladarka",
|
||||
"unknown_ip_address": "nieznany adres",
|
||||
"revoke": "Wyloguj to urzadzenie",
|
||||
"confirm_revoke": "Wylogowac to zapamietane urzadzenie?",
|
||||
"no_other_devices": "Zadne inne urzadzenie nie pamieta teraz Twojego logowania.",
|
||||
"sign_out_others": "Wyloguj wszystkie inne urzadzenia",
|
||||
"confirm_sign_out_others": "Wylogowac KAZDE inne zapamietane urzadzenie? To zostanie zalogowane.",
|
||||
"revoked_notice": "To urzadzenie zostalo wylogowane",
|
||||
"signed_out_others_notice": "Wszystkie inne urzadzenia zostaly wylogowane",
|
||||
"not_found": "To urzadzenie bylo juz wylogowane"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddCoverToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :cover, default: nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :cover
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateComments < Prism::Orm::Migration
|
||||
def up
|
||||
create_table :comments do |t|
|
||||
t.integer :post_id
|
||||
t.string :author
|
||||
t.text :body
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :comments
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateUsers < Prism::Orm::Migration
|
||||
def up
|
||||
create_table :users do |t|
|
||||
t.string :email
|
||||
t.string :password_digest
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :users
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddUserIdToPostsAndComments < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :user_id
|
||||
add_column :comments, :user_id
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :user_id
|
||||
remove_column :comments, :user_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddLockVersionToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :lock_version, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :lock_version
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddLockVersionToComments < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :comments, :lock_version, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :comments, :lock_version
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateLikes < Prism::Orm::Migration
|
||||
def up
|
||||
create_table :likes do |t|
|
||||
t.integer :user_id
|
||||
t.integer :likeable_id
|
||||
t.string :likeable_type
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :likes
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddCommentsCountToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :comments_count, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :comments_count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddStatusToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :status, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddStatusToComments < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :comments, :status, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :comments, :status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddDeletedAtToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :deleted_at, :datetime, default: nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :deleted_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddDeletedAtToCommentsAndLikes < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :comments, :deleted_at, :datetime, default: nil
|
||||
add_column :likes, :deleted_at, :datetime, default: nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :comments, :deleted_at
|
||||
remove_column :likes, :deleted_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddRememberTokenToUsers < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :users, :remember_token, default: nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :users, :remember_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateLoginSessions < Prism::Orm::Migration
|
||||
def up
|
||||
create_table :login_sessions do |t|
|
||||
t.integer :user_id
|
||||
t.string :token
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :login_sessions
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class RemoveRememberTokenFromUsers < Prism::Orm::Migration
|
||||
def up
|
||||
remove_column :users, :remember_token
|
||||
end
|
||||
|
||||
def down
|
||||
add_column :users, :remember_token, default: nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddUserAgentAndIpToLoginSessions < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :login_sessions, :user_agent, default: nil
|
||||
add_column :login_sessions, :ip_address, default: nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :login_sessions, :user_agent
|
||||
remove_column :login_sessions, :ip_address
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AddLikesCountToPostsAndComments < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :likes_count, :integer, default: 0
|
||||
add_column :comments, :likes_count, :integer, default: 0
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :likes_count
|
||||
remove_column :comments, :likes_count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnUsersEmail < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :users, :email, unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :users, :email
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnPostsStatus < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :posts, :status
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :posts, :status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnPostsCreatedAt < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :posts, :created_at
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :posts, :created_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnCommentsPostId < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :comments, :post_id
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :comments, :post_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnLikesLikeable < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :likes, %i[likeable_id likeable_type]
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :likes, %i[likeable_id likeable_type]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnLoginSessionsUserId < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :login_sessions, :user_id
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :login_sessions, :user_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnCommentsUserId < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :comments, :user_id
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :comments, :user_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddIndexOnPostsUserId < Prism::Orm::Migration
|
||||
def up
|
||||
add_index :posts, :user_id
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :posts, :user_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class AddSlugToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
# add_column's own default ('') would leave every existing row with the
|
||||
# SAME empty slug, which the unique index below would reject the moment
|
||||
# a second existing post tried to keep it - so every existing row gets
|
||||
# a real, distinct slug backfilled BEFORE the index goes on, the same
|
||||
# slugify Post.create_with_unique_slug uses for new rows (this runs at
|
||||
# the store level, not through the Post model - db:migrate does not
|
||||
# load app/models, see Prism::Cli#db_migrate).
|
||||
add_column :posts, :slug, default: ''
|
||||
backfill_unique_slugs
|
||||
add_index :posts, :slug, unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :posts, :slug
|
||||
remove_column :posts, :slug
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backfill_unique_slugs
|
||||
seen = {}
|
||||
store.all(:posts).each do |row|
|
||||
base = slugify(row['title'])
|
||||
slug = base
|
||||
suffix = 1
|
||||
while seen.key?(slug)
|
||||
suffix += 1
|
||||
slug = "#{base}-#{suffix}"
|
||||
end
|
||||
seen[slug] = true
|
||||
store.update(:posts, row['id'], 'slug' => slug)
|
||||
end
|
||||
end
|
||||
|
||||
def slugify(title)
|
||||
base = title.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
||||
base.empty? ? 'post' : base
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddFeaturedToPosts < Prism::Orm::Migration
|
||||
def up
|
||||
add_column :posts, :featured, default: false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :featured
|
||||
end
|
||||
end
|
||||
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user