feat(controllers): add EmailConfirmationsController, create

This commit is contained in:
dualfroz committed 2026-09-04 15:19:07 +02:00
1 parent 29f97c2db4
commit c66a866312
4 files changed
+188

No files matched your search

@@ -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