Merge pull request #1 from dualfroz/feat/pending-work-2026-09
feat: land pending September work
This commit is contained in:
360 files changed
+34556
-182
No files matched your search
@@ -10,6 +10,13 @@ vendor/bundle/
|
|||||||
tmp/
|
tmp/
|
||||||
*.log
|
*.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
|
# os / editor / env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.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 'prism'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
APP_ROOT = __dir__
|
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::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.views_root(File.join(APP_ROOT, 'app', 'views'))
|
||||||
Prism::Controller.layout(:application)
|
Prism::Controller.layout(:application)
|
||||||
|
|
||||||
Dir[File.join(APP_ROOT, 'app', 'models', '*.rb')].sort.each { |file| require file }
|
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 }
|
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
|
class Blog < Prism::Application
|
||||||
set :views, File.join(APP_ROOT, 'app', 'views')
|
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::Logger
|
||||||
|
use Prism::Middleware::Compression
|
||||||
|
use Prism::Middleware::Cors
|
||||||
use Prism::Middleware::ParamsParser
|
use Prism::Middleware::ParamsParser
|
||||||
|
use Prism::Middleware::RateLimiting
|
||||||
use Prism::Middleware::Static, root: File.join(APP_ROOT, 'public')
|
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')
|
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
|
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
|
require 'csv'
|
||||||
layout :application
|
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
|
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
|
||||||
end
|
# posts.filter_* links in the view) chained onto .published BEFORE
|
||||||
|
# .order/.page - Post.created_within hits the range-capable secondary
|
||||||
def show
|
# index on created_at (Store#lookup_by_index_range) the same way
|
||||||
render(:show, locals: { post: Post.find(params['id']) })
|
# .published already hits the one on status, so neither filter alone
|
||||||
rescue Prism::Orm::RecordNotFound
|
# nor the two combined ever falls back to a full table scan.
|
||||||
halt(404, 'Post not found')
|
days = params['days'].to_i
|
||||||
end
|
# `params['featured']` is the raw query-string String Rack always hands
|
||||||
|
# a controller ("1" or nil) - cast through the same TypeCasting a form
|
||||||
def create
|
# POST would use, rather than a bespoke `== '1'` check here, so this
|
||||||
post = Post.new(title: params['title'], body: params['body'])
|
# filter and the checkbox in _form.erb agree on what counts as true.
|
||||||
if post.save
|
featured_only = params['featured'] && Prism::Orm::TypeCasting.cast(:boolean, params['featured'])
|
||||||
flash[:notice] = "Post ##{post.id} created"
|
scope = Post.published
|
||||||
redirect url_for(:posts)
|
scope = scope.created_within(days) if days.positive?
|
||||||
else
|
scope = scope.featured if featured_only
|
||||||
status(422)
|
# The featured view is manually curated (Post#acts_as_list, scoped by
|
||||||
render(:index, locals: { posts: Post.order(:created_at, :desc), notice: nil, errors: post.errors.full_messages })
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# `notice` rides along in the etag components (not just post/comments
|
||||||
|
# state) so a pending flash message - which only exists for the one
|
||||||
|
# request right after a redirect - always forces a fresh render instead
|
||||||
|
# of being hidden behind a 304 that matches an older, notice-less copy
|
||||||
|
# of this same page a client happened to have cached.
|
||||||
|
def show
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
comments = post.comments.includes(:likes).visible_to(current_user, post.user_id).order(:created_at, :desc).to_a
|
||||||
|
notice = flash[:notice]
|
||||||
|
# current_user.id rides along in the etag, not just the post/comments
|
||||||
|
# data: this page also shows moderator-only controls (edit/publish/
|
||||||
|
# delete, comment approve/reject) and viewer-only pending comments, so
|
||||||
|
# two different visitors who happen to see the identical VISIBLE
|
||||||
|
# comment set must still never be served each other's cached 304 body.
|
||||||
|
# post.claims_count rides along too - a giveaway claim (see
|
||||||
|
# ClaimsController#create) changes #giveaway_remaining/
|
||||||
|
# #giveaway_sold_out? and therefore this page's body without ever
|
||||||
|
# touching post.updated_at (Claim has no touch: true, on purpose: a
|
||||||
|
# claim is not an edit to the post itself), so without it here a
|
||||||
|
# cached 304 would keep serving a stale remaining-slots count and a
|
||||||
|
# stale claim button after someone had already claimed the last slot.
|
||||||
|
fresh_when(etag: [post.id, post.updated_at, post.claims_count, current_user&.id,
|
||||||
|
comments.map { |c| [c.id, c.updated_at] }, notice])
|
||||||
|
render(:show, locals: { post: post, comments: comments, new_comment: Comment.new, notice: notice })
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Streamed (Prism::Controller#stream, see lib/prism/streaming_body.rb),
|
||||||
|
# not rendered into one String first - a real Rails app faces the exact
|
||||||
|
# same shape of problem exporting a large table, and #index already
|
||||||
|
# proves .published/.created_within hit a secondary index instead of a
|
||||||
|
# full scan, so this is the same query with the row-by-row cost paid to
|
||||||
|
# the client incrementally instead of all at once in memory. The header
|
||||||
|
# row is written (and would reach the client) before the last matching
|
||||||
|
# post has even been read out of the store.
|
||||||
|
def export
|
||||||
|
days = params['days'].to_i
|
||||||
|
scope = Post.published
|
||||||
|
scope = scope.created_within(days) if days.positive?
|
||||||
|
headers('Content-Type' => 'text/csv')
|
||||||
|
stream do |out|
|
||||||
|
out.write(CSV.generate_line(%w[id title created_at]))
|
||||||
|
scope.order(:created_at, :desc).find_each do |post|
|
||||||
|
out.write(CSV.generate_line([post.id, post.title, post.created_at.utc.iso8601]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The reverse of #export above: bulk-creates posts from an uploaded CSV
|
||||||
|
# (title,body columns). All the actual work - the per-row unique-slug
|
||||||
|
# retry and the outer transaction wrapping the whole batch - lives in
|
||||||
|
# Post.import_csv; this action is just multipart plumbing and reporting.
|
||||||
|
def import
|
||||||
|
file = params['file']
|
||||||
|
unless file.is_a?(Prism::UploadedFile) && !file.original_filename.to_s.empty?
|
||||||
|
flash[:notice] = t('posts.import_missing_file')
|
||||||
|
redirect url_for(:posts)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
result = Post.import_csv(file.read, author: current_user)
|
||||||
|
flash[:notice] = t('posts.import_notice', imported: result.imported.size, skipped: result.skipped.size)
|
||||||
|
redirect url_for(:posts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The create form's submit button says "Publish" (see posts.submit in the
|
||||||
|
# locale files), so a post made here goes live immediately - status only
|
||||||
|
# ever starts at the enum's draft default for a row nothing else besides
|
||||||
|
# this action ever inserts. #unpublish/#publish (below) are how an
|
||||||
|
# existing post is pulled back to draft and put up again.
|
||||||
|
def create
|
||||||
|
attrs = params.require('post').permit(:title, :body, :featured, :giveaway_slots).to_h
|
||||||
|
attrs['status'] = :published
|
||||||
|
attrs['cover'] = store_cover(params['post']['cover'])
|
||||||
|
attrs['user_id'] = current_user.id
|
||||||
|
# Post.create_with_unique_slug (see the model), not a plain Post.new+#save
|
||||||
|
# - every post needs a slug for the unique index the add_slug_to_posts
|
||||||
|
# migration put on posts.slug, and this is the only place besides
|
||||||
|
# Post.import_csv that inserts a post at all.
|
||||||
|
post = Post.create_with_unique_slug(attrs)
|
||||||
|
|
||||||
|
if post.persisted?
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
flash[:notice] = t('posts.created_notice', id: post.id)
|
||||||
|
redirect url_for(:post, slug: post.to_param)
|
||||||
|
end
|
||||||
|
format.json { json(post.as_json(only: %i[id slug title body cover]), 201) }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
status(422)
|
||||||
|
render(:index, locals: { posts: Post.published.order(:created_at, :desc).page(1).per(5), new_post: post, notice: nil,
|
||||||
|
errors: post.errors.full_messages, days: 0, featured_only: false })
|
||||||
|
end
|
||||||
|
format.json { render_errors(post) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
render(:edit, locals: { post: post, errors: [] })
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Diffs the submitted title/body against the persisted row WITHOUT
|
||||||
|
# saving anything - assign() dirty-tracks the attempt, #changes captures
|
||||||
|
# the old/new pair for each touched field, then Dirty#restore_attributes
|
||||||
|
# reverts `post` back to exactly what #save last loaded so the rest of
|
||||||
|
# this action (and the view it renders) can keep using the same instance
|
||||||
|
# as a clean, still-persisted record instead of hitting the store again.
|
||||||
|
# `attempted` is kept separately (not read back off `post`, which is now
|
||||||
|
# clean again) so the confirm form below can still resubmit what the
|
||||||
|
# visitor actually typed.
|
||||||
|
def preview
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
attempted = params.require('post').permit(:title, :body, :lock_version).to_h
|
||||||
|
post.assign(attempted)
|
||||||
|
post.valid?
|
||||||
|
diff = post.changes.reject { |field, _| field == 'lock_version' }
|
||||||
|
errors = post.errors.full_messages
|
||||||
|
post.restore_attributes
|
||||||
|
render(:preview, locals: { post: post, diff: diff, errors: errors, attempted: attempted })
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only assigns the attributes the edit form actually submits (not cover/
|
||||||
|
# user_id) so Post#saved_changes below can only ever list fields the
|
||||||
|
# visitor meant to touch, then uses that dirty-tracking snapshot to tell
|
||||||
|
# them exactly what took effect instead of a generic "post updated".
|
||||||
|
# `lock_version` rides along as a permitted attribute holding whatever
|
||||||
|
# version the edit form was rendered with (not a fresh read) - see
|
||||||
|
# Post#save's optimistic-locking check, which compares that submitted
|
||||||
|
# value against the row's ACTUAL current version, not the just-loaded one.
|
||||||
|
def update
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post)
|
||||||
|
permitted = params.require('post').permit(
|
||||||
|
:title, :body, :featured, :giveaway_slots, :lock_version, comments_attributes: %i[id body _destroy]
|
||||||
|
).to_h
|
||||||
|
post.assign(permitted)
|
||||||
|
|
||||||
|
if post.save
|
||||||
|
changed_fields = post.saved_changes.keys - ['updated_at', 'lock_version']
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
flash[:notice] = changed_fields.empty? ? t('posts.update_noop_notice') : t('posts.updated_notice', fields: changed_fields.join(', '))
|
||||||
|
redirect url_for(:post, slug: post.to_param)
|
||||||
|
end
|
||||||
|
format.json { json(post.as_json(only: %i[id slug title body cover])) }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
status(422)
|
||||||
|
render(:edit, locals: { post: post, errors: post.errors.full_messages })
|
||||||
|
end
|
||||||
|
format.json { render_errors(post) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
rescue Prism::Orm::StaleObjectError
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
status(409)
|
||||||
|
render(:edit, locals: { post: Post.find_by!(slug: params['slug']), errors: [t('posts.stale_notice')] })
|
||||||
|
end
|
||||||
|
format.json do
|
||||||
|
json({ errors: [jsonapi_error(status: 409, pointer: '/data/attributes/lock_version',
|
||||||
|
title: 'is stale', detail: t('posts.stale_notice'))] }, 409)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Post#acts_as_paranoid means this call moves the post to the trash
|
||||||
|
# instead of removing it for real - #trash/#restore below are how its
|
||||||
|
# own author gets it back.
|
||||||
|
def destroy
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post)
|
||||||
|
post.destroy
|
||||||
|
flash[:notice] = t('posts.deleted_notice', id: post.id)
|
||||||
|
redirect url_for(:posts)
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only the signed-in author's own trashed posts - Post.only_deleted (see
|
||||||
|
# acts_as_paranoid) is the one place in this controller that deliberately
|
||||||
|
# reaches past the default paranoid scope every other action relies on
|
||||||
|
# to hide soft-deleted rows.
|
||||||
|
def trash
|
||||||
|
posts = Post.only_deleted.where(user_id: current_user.id).order(:updated_at, :desc).to_a
|
||||||
|
render(:trash, locals: { posts: posts, notice: flash[:notice] })
|
||||||
|
end
|
||||||
|
|
||||||
|
# find_by! against only_deleted (not the default Post.find, which hides
|
||||||
|
# trashed rows) - restoring a post that was never trashed, or someone
|
||||||
|
# else's, both come back as the same 404 a stranger's post id would.
|
||||||
|
def restore
|
||||||
|
# slug survives a soft delete unchanged (it's just a column, untouched
|
||||||
|
# by deleted_at) - no numeric coercion needed here the way the old
|
||||||
|
# id-keyed lookup required, since a String slug compares correctly
|
||||||
|
# against row['slug'] (also a String) with plain ==.
|
||||||
|
post = Post.only_deleted.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :restore?)
|
||||||
|
# recursive: true - a plain post.restore would only bring the post's
|
||||||
|
# own row back and leave its comments/likes trashed forever, since
|
||||||
|
# they were soft-deleted as a side effect of dependent: :destroy when
|
||||||
|
# the post itself was trashed (see Post#acts_as_paranoid).
|
||||||
|
post.restore(recursive: true)
|
||||||
|
flash[:notice] = t('posts.restored_notice', id: post.id)
|
||||||
|
redirect url_for(:trash_posts)
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# `published!`/`draft!` come straight from Post's `enum :status` (see the
|
||||||
|
# model) - they set the column and save() in one call, so this action is
|
||||||
|
# nothing but "find, authorize like an edit, flip the status".
|
||||||
|
def publish
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
post.published!
|
||||||
|
flash[:notice] = t('posts.published_notice', id: post.id)
|
||||||
|
redirect url_for(:post, slug: post.to_param)
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def unpublish
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
post.draft!
|
||||||
|
flash[:notice] = t('posts.unpublished_notice', id: post.id)
|
||||||
|
redirect url_for(:post, slug: post.to_param)
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Post#acts_as_list is scoped by `featured` (see the model) - moving a
|
||||||
|
# post that ISN'T currently featured is harmless (it just reorders it
|
||||||
|
# inside the unused non-featured group) but pointless, so both actions
|
||||||
|
# redirect back to the SAME featured=1 view the reorder links only ever
|
||||||
|
# appear on, regardless of which group the post actually belongs to.
|
||||||
|
def move_up
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
post.move_higher
|
||||||
|
redirect "#{url_for(:posts)}?featured=1"
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def move_down
|
||||||
|
post = Post.find_by!(slug: params['slug'])
|
||||||
|
authorize(post, :update?)
|
||||||
|
post.move_lower
|
||||||
|
redirect "#{url_for(:posts)}?featured=1"
|
||||||
|
rescue Prism::Orm::RecordNotFound
|
||||||
|
respond_not_found(t('posts.not_found'))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def store_cover(upload)
|
||||||
|
return nil unless upload.is_a?(Prism::UploadedFile)
|
||||||
|
return nil if upload.original_filename.to_s.empty?
|
||||||
|
|
||||||
|
uploads_dir = File.join(APP_ROOT, 'public', 'uploads')
|
||||||
|
FileUtils.mkdir_p(uploads_dir)
|
||||||
|
safe_name = "#{Time.now.strftime('%Y%m%d%H%M%S')}-#{upload.original_filename.gsub(/[^a-zA-Z0-9_.-]/, '_')}"
|
||||||
|
File.binwrite(File.join(uploads_dir, safe_name), upload.read)
|
||||||
|
safe_name
|
||||||
|
end
|
||||||
end
|
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
|
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 :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
|
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>
|
<!doctype html>
|
||||||
<html>
|
<html lang="<%= locale %>">
|
||||||
<head><title><%= defined?(title) ? title : 'Prism App' %></title></head>
|
<head>
|
||||||
<body><%== content %></body>
|
<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>
|
</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 %>">
|
<li class="post" id="post-<%= post.to_param %>">
|
||||||
<h2><%= post.title %></h2>
|
<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><%= 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>
|
</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 %>
|
<% if notice %>
|
||||||
<p class="notice"><%= notice %></p>
|
<p class="notice"><%= notice %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless errors.empty? %>
|
<% unless errors.empty? %>
|
||||||
|
<h2><%= t('posts.errors_heading') %></h2>
|
||||||
<ul class="errors">
|
<ul class="errors">
|
||||||
<% errors.each do |message| %>
|
<% errors.each do |message| %>
|
||||||
<li><%= message %></li>
|
<li><%= message %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% end %>
|
<% end %>
|
||||||
<form method="post" action="/posts">
|
|
||||||
<input type="text" name="title" placeholder="Title">
|
<% if user_signed_in? %>
|
||||||
<textarea name="body" placeholder="Body"></textarea>
|
<h2><%= t('posts.new_post_heading') %></h2>
|
||||||
<button type="submit">Create post</button>
|
<% form_for(new_post, url: url_for(:posts), html: { enctype: 'multipart/form-data' }) do |f| %>
|
||||||
</form>
|
<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">
|
<ul class="posts">
|
||||||
<% posts.each do |post| %>
|
<% posts.each do |post| %>
|
||||||
<%== partial('post', locals: { post: post }) %>
|
<%== partial('post', locals: { post: post, featured_only: featured_only }) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</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>
|
<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><%= 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'
|
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}"
|
puts "listening on #{server.url}"
|
||||||
trap('INT') { server.stop; exit }
|
trap('INT') { server.stop; exit }
|
||||||
sleep
|
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