bible270 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +181 -0
- data/MIT-LICENSE +18 -0
- data/README.md +373 -0
- data/Rakefile +11 -0
- data/app/controllers/bible270/application_controller.rb +48 -0
- data/app/controllers/bible270/checkoffs_controller.rb +32 -0
- data/app/controllers/bible270/comments_controller.rb +46 -0
- data/app/controllers/bible270/days_controller.rb +42 -0
- data/app/controllers/bible270/readers_controller.rb +46 -0
- data/app/controllers/bible270/sessions_controller.rb +126 -0
- data/app/helpers/bible270/plan_helper.rb +45 -0
- data/app/mailers/bible270/application_mailer.rb +7 -0
- data/app/mailers/bible270/sign_in_mailer.rb +14 -0
- data/app/models/bible270/application_record.rb +6 -0
- data/app/models/bible270/checkoff.rb +23 -0
- data/app/models/bible270/comment.rb +16 -0
- data/app/models/bible270/reader.rb +211 -0
- data/app/models/bible270/sign_in_token.rb +74 -0
- data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +4 -0
- data/app/views/bible270/comments/_comment.html.erb +15 -0
- data/app/views/bible270/comments/_form.html.erb +13 -0
- data/app/views/bible270/comments/create.turbo_stream.erb +7 -0
- data/app/views/bible270/comments/destroy.turbo_stream.erb +1 -0
- data/app/views/bible270/days/_reading.html.erb +25 -0
- data/app/views/bible270/days/_start_date.html.erb +43 -0
- data/app/views/bible270/days/index.html.erb +72 -0
- data/app/views/bible270/days/show.html.erb +50 -0
- data/app/views/bible270/readers/index.html.erb +24 -0
- data/app/views/bible270/readers/show.html.erb +41 -0
- data/app/views/bible270/sessions/new.html.erb +52 -0
- data/app/views/bible270/shared/_header.html.erb +20 -0
- data/app/views/bible270/shared/_styles.html.erb +147 -0
- data/app/views/bible270/sign_in_mailer/magic_link.html.erb +20 -0
- data/app/views/bible270/sign_in_mailer/magic_link.text.erb +7 -0
- data/app/views/layouts/bible270/application.html.erb +28 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260101000001_create_bible270_readers.rb +17 -0
- data/db/migrate/20260101000002_create_bible270_checkoffs.rb +15 -0
- data/db/migrate/20260101000003_create_bible270_comments.rb +14 -0
- data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +18 -0
- data/lib/bible270/configuration.rb +177 -0
- data/lib/bible270/email_sign_in.rb +68 -0
- data/lib/bible270/engine.rb +21 -0
- data/lib/bible270/plan.rb +352 -0
- data/lib/bible270/versification.rb +83 -0
- data/lib/bible270/version.rb +4 -0
- data/lib/bible270.rb +9 -0
- data/lib/generators/bible270/install/install_generator.rb +66 -0
- data/lib/generators/bible270/install/templates/bible270.rb.tt +41 -0
- data/lib/generators/bible270/install/templates/omniauth.rb.tt +25 -0
- metadata +158 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
class CommentsController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
return unless require_reader!
|
|
6
|
+
|
|
7
|
+
@day = params[:day].to_i
|
|
8
|
+
head :bad_request and return unless Plan.valid_day?(@day)
|
|
9
|
+
|
|
10
|
+
@comment = current_reader.comments.new(comment_params.merge(day: @day))
|
|
11
|
+
@readings = Plan.readings_for(@day)
|
|
12
|
+
|
|
13
|
+
if @comment.save
|
|
14
|
+
respond_to do |format|
|
|
15
|
+
format.turbo_stream
|
|
16
|
+
format.html { redirect_to day_path(@day, anchor: "comment-#{@comment.id}") }
|
|
17
|
+
end
|
|
18
|
+
else
|
|
19
|
+
respond_to do |format|
|
|
20
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("new_comment_form", partial: "bible270/comments/form", locals: { day: @day, comment: @comment }) }
|
|
21
|
+
format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def destroy
|
|
27
|
+
return unless require_reader!
|
|
28
|
+
|
|
29
|
+
@comment = current_reader.comments.find_by(id: params[:id])
|
|
30
|
+
head :not_found and return unless @comment
|
|
31
|
+
|
|
32
|
+
@day = @comment.day
|
|
33
|
+
@comment.destroy
|
|
34
|
+
respond_to do |format|
|
|
35
|
+
format.turbo_stream
|
|
36
|
+
format.html { redirect_to day_path(@day) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def comment_params
|
|
43
|
+
params.require(:comment).permit(:body, :track)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
class DaysController < ApplicationController
|
|
4
|
+
def index
|
|
5
|
+
@plan_totals = Plan.totals
|
|
6
|
+
|
|
7
|
+
# Community leaderboard (small-community friendly; see README on scaling).
|
|
8
|
+
@readers = Reader.order(updated_at: :desc).to_a
|
|
9
|
+
counts = Checkoff.group(:reader_id, :day).count
|
|
10
|
+
@days_completed = Hash.new(0)
|
|
11
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
|
|
12
|
+
@readers = @readers.sort_by { |r| -@days_completed[r.id] }
|
|
13
|
+
|
|
14
|
+
@start_day = current_reader&.current_day || 1
|
|
15
|
+
@today_day = current_reader&.calendar_day
|
|
16
|
+
@community_start_date = Bible270.config.start_date
|
|
17
|
+
@allow_reader_start_date = Bible270.config.allow_reader_start_date
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
@day = params[:day].to_i
|
|
22
|
+
unless Plan.valid_day?(@day)
|
|
23
|
+
redirect_to(root_path, alert: "That day is outside the plan.") and return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@readings = Plan.readings_for(@day)
|
|
27
|
+
@comments = Comment.for_day(@day).includes(:reader)
|
|
28
|
+
@new_comment = Comment.new(day: @day)
|
|
29
|
+
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
30
|
+
|
|
31
|
+
req = Plan.required_track_count(@day)
|
|
32
|
+
completer_ids = Checkoff.where(day: @day)
|
|
33
|
+
.group(:reader_id)
|
|
34
|
+
.having("COUNT(*) >= ?", req)
|
|
35
|
+
.pluck(:reader_id)
|
|
36
|
+
@completers = Reader.where(id: completer_ids).order(:display_name)
|
|
37
|
+
|
|
38
|
+
@prev_day = @day > 1 ? @day - 1 : nil
|
|
39
|
+
@next_day = @day < Plan::DAYS ? @day + 1 : nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
class ReadersController < ApplicationController
|
|
4
|
+
def index
|
|
5
|
+
@readers = Reader.all.to_a
|
|
6
|
+
counts = Checkoff.group(:reader_id, :day).count
|
|
7
|
+
@days_completed = Hash.new(0)
|
|
8
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
|
|
9
|
+
@readers.sort_by! { |r| [-@days_completed[r.id], r.display_name.to_s] }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@reader = Reader.find_by(id: params[:id])
|
|
14
|
+
redirect_to(community_path, alert: "Reader not found.") and return unless @reader
|
|
15
|
+
|
|
16
|
+
@days_completed = @reader.days_completed
|
|
17
|
+
@recent_comments = @reader.comments.recent.limit(20)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update_start_date
|
|
21
|
+
return unless require_reader!
|
|
22
|
+
|
|
23
|
+
unless Bible270.config.allow_reader_start_date
|
|
24
|
+
redirect_to(root_path, alert: "The start date is set for the whole community.") and return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if current_reader.update_start_date!(params[:start_date])
|
|
28
|
+
redirect_to root_path,
|
|
29
|
+
notice: "Start date set to #{current_reader.started_on.strftime('%B %-d, %Y')}."
|
|
30
|
+
else
|
|
31
|
+
redirect_to root_path, alert: "That doesn't look like a valid date."
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear_start_date
|
|
36
|
+
return unless require_reader!
|
|
37
|
+
|
|
38
|
+
if Bible270.config.allow_reader_start_date
|
|
39
|
+
current_reader.clear_start_date!
|
|
40
|
+
redirect_to root_path, notice: "Start date cleared — the plan is now undated for you."
|
|
41
|
+
else
|
|
42
|
+
redirect_to root_path, alert: "The start date is set for the whole community."
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
# Built-in OmniAuth sign-in.
|
|
4
|
+
#
|
|
5
|
+
# The OmniAuth middleware (registered by the host app) handles the request and
|
|
6
|
+
# callback phases; by the time #create runs, request.env["omniauth.auth"] is
|
|
7
|
+
# populated and we just need to turn it into a Reader and remember them.
|
|
8
|
+
#
|
|
9
|
+
# NOTE: OmniAuth 2.0+ only permits POST to its request-phase routes, so all
|
|
10
|
+
# sign-in controls in these views are forms/buttons rather than links.
|
|
11
|
+
class SessionsController < ApplicationController
|
|
12
|
+
def new
|
|
13
|
+
redirect_to(after_sign_in_path, notice: "You're already signed in.") and return if signed_in?
|
|
14
|
+
|
|
15
|
+
@providers = Bible270.config.omniauth_providers
|
|
16
|
+
@origin = safe_origin(params[:origin])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
auth = request.env["omniauth.auth"]
|
|
21
|
+
unless auth
|
|
22
|
+
redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
reader = Reader.from_omniauth(auth)
|
|
26
|
+
unless reader&.persisted?
|
|
27
|
+
redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
destination = safe_origin(request.env["omniauth.origin"]) || after_sign_in_path
|
|
31
|
+
|
|
32
|
+
# Rotate the session id to avoid session fixation, then sign in.
|
|
33
|
+
reset_session
|
|
34
|
+
session[:bible270_reader_id] = reader.id
|
|
35
|
+
@current_reader = reader
|
|
36
|
+
|
|
37
|
+
redirect_to destination, notice: "Welcome, #{reader.display_name}."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy
|
|
41
|
+
reset_session
|
|
42
|
+
@current_reader = nil
|
|
43
|
+
redirect_to(after_sign_out_path, notice: "Signed out.")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def failure
|
|
47
|
+
message = params[:message].presence || "unknown error"
|
|
48
|
+
redirect_to sign_in_path, alert: "Sign in failed (#{message.to_s.tr('_', ' ')})."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---- email (magic link) sign-in ---------------------------------------
|
|
52
|
+
|
|
53
|
+
# POST: accept an address and send a one-time link.
|
|
54
|
+
def email_link
|
|
55
|
+
unless Bible270.config.email_sign_in?
|
|
56
|
+
redirect_to(sign_in_path, alert: "Email sign-in isn't enabled here.") and return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
origin = safe_origin(params[:origin])
|
|
60
|
+
address = EmailSignIn.normalize_email(params[:email])
|
|
61
|
+
if address.nil?
|
|
62
|
+
redirect_to(sign_in_path(origin: origin),
|
|
63
|
+
alert: "That doesn't look like an email address — please check and try again.") and return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
name = Bible270.config.email_sign_in_ask_name ? params[:display_name].to_s.strip : nil
|
|
67
|
+
_record, raw = SignInToken.issue!(address, display_name: name)
|
|
68
|
+
|
|
69
|
+
if raw
|
|
70
|
+
url = email_sign_in_url(token: raw, origin: origin)
|
|
71
|
+
minutes = (Bible270.config.email_sign_in_ttl / 60.0).round
|
|
72
|
+
mail = SignInMailer.magic_link(email: address, url: url, expires_in_minutes: minutes)
|
|
73
|
+
Bible270.config.email_sign_in_deliver_later ? mail.deliver_later : mail.deliver_now
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deliberately identical whether or not a link was actually sent, so this
|
|
77
|
+
# reveals neither who has an account nor that a limit was hit.
|
|
78
|
+
redirect_to sign_in_path,
|
|
79
|
+
notice: "Check your inbox — if that address is valid we've sent a sign-in link to #{address}."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# GET: the reader clicked the link.
|
|
83
|
+
def email_callback
|
|
84
|
+
token = SignInToken.claim!(params[:token])
|
|
85
|
+
if token.nil?
|
|
86
|
+
redirect_to(sign_in_path,
|
|
87
|
+
alert: "That link has expired or was already used. Please request a new one.") and return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
reader = Reader.from_email(token.email, display_name: token.display_name)
|
|
91
|
+
unless reader&.persisted?
|
|
92
|
+
redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
destination = safe_origin(params[:origin]) || after_sign_in_path
|
|
96
|
+
|
|
97
|
+
reset_session
|
|
98
|
+
session[:bible270_reader_id] = reader.id
|
|
99
|
+
@current_reader = reader
|
|
100
|
+
|
|
101
|
+
redirect_to destination, notice: "Welcome, #{reader.display_name}."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Only ever redirect within this app, and never back into an auth route.
|
|
107
|
+
def safe_origin(value)
|
|
108
|
+
value = value.to_s
|
|
109
|
+
return nil if value.empty?
|
|
110
|
+
return nil unless value.start_with?("/")
|
|
111
|
+
return nil if value.include?("//") # protocol-relative URL
|
|
112
|
+
return nil if value.include?("\\") # browsers may normalise \ to /
|
|
113
|
+
return nil if value.include?("/auth/")
|
|
114
|
+
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def after_sign_in_path
|
|
119
|
+
Bible270.config.after_sign_in_path || root_path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def after_sign_out_path
|
|
123
|
+
Bible270.config.after_sign_out_path || root_path
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
module PlanHelper
|
|
4
|
+
def b270_track(track)
|
|
5
|
+
Plan::TRACKS.fetch(track.to_s)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Path that begins the OmniAuth request phase. Must be POSTed to: OmniAuth
|
|
9
|
+
# 2.0+ refuses GET on its own routes (CVE-2015-9284), so every sign-in
|
|
10
|
+
# control in these views is a button_to, not a link_to.
|
|
11
|
+
def b270_omniauth_path(provider)
|
|
12
|
+
prefix = Bible270.config.omniauth_path_prefix || "#{request.script_name}/auth"
|
|
13
|
+
"#{prefix}/#{provider}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def b270_provider_label(provider)
|
|
17
|
+
Bible270.config.label_for_provider(provider)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def b270_passage_url(reference)
|
|
21
|
+
return "#" if reference.blank?
|
|
22
|
+
Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def b270_avatar(reader, size: 34)
|
|
26
|
+
if reader.avatar_url.present?
|
|
27
|
+
image_tag reader.avatar_url, class: "b270-avatar", width: size, height: size, alt: reader.display_name
|
|
28
|
+
else
|
|
29
|
+
content_tag :span, reader.initials, class: "b270-avatar b270-avatar-fallback",
|
|
30
|
+
style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def b270_day_status_class(reader, day)
|
|
35
|
+
return "" unless reader
|
|
36
|
+
if reader.day_complete?(day)
|
|
37
|
+
"complete"
|
|
38
|
+
elsif reader.read_tracks_for(day).any?
|
|
39
|
+
"partial"
|
|
40
|
+
else
|
|
41
|
+
""
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
class SignInMailer < ApplicationMailer
|
|
4
|
+
# The URL is built by the controller (which has the request context), so the
|
|
5
|
+
# mailer needs no default_url_options of its own.
|
|
6
|
+
def magic_link(email:, url:, expires_in_minutes:)
|
|
7
|
+
@url = url
|
|
8
|
+
@expires_in_minutes = expires_in_minutes
|
|
9
|
+
@app_name = Bible270.config.app_name
|
|
10
|
+
|
|
11
|
+
mail to: email, subject: "Your sign-in link for #{@app_name}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
# A single reader marking one track (ot / nt / pp) read on one day.
|
|
4
|
+
class Checkoff < ApplicationRecord
|
|
5
|
+
self.table_name = "bible270_checkoffs"
|
|
6
|
+
|
|
7
|
+
belongs_to :reader, class_name: "Bible270::Reader"
|
|
8
|
+
|
|
9
|
+
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
10
|
+
validates :track, inclusion: { in: Plan::TRACKS.keys }
|
|
11
|
+
validates :track, uniqueness: { scope: %i[reader_id day] }
|
|
12
|
+
validate :track_present_on_day
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def track_present_on_day
|
|
17
|
+
return if day.nil? || track.nil?
|
|
18
|
+
return if Plan.present_tracks(day).include?(track)
|
|
19
|
+
|
|
20
|
+
errors.add(:track, "is not part of day #{day}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
# A publicly visible reflection attached to a plan day (optionally a track).
|
|
4
|
+
class Comment < ApplicationRecord
|
|
5
|
+
self.table_name = "bible270_comments"
|
|
6
|
+
|
|
7
|
+
belongs_to :reader, class_name: "Bible270::Reader"
|
|
8
|
+
|
|
9
|
+
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
10
|
+
validates :body, presence: true, length: { maximum: 4000 }
|
|
11
|
+
validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_nil: true
|
|
12
|
+
|
|
13
|
+
scope :for_day, ->(d) { where(day: d).order(created_at: :asc) }
|
|
14
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
# A reader identity. Either self-contained (created via OmniAuth) or bridged
|
|
4
|
+
# to one of the host application's users through the polymorphic :owner.
|
|
5
|
+
class Reader < ApplicationRecord
|
|
6
|
+
self.table_name = "bible270_readers"
|
|
7
|
+
|
|
8
|
+
has_many :checkoffs, class_name: "Bible270::Checkoff", dependent: :destroy
|
|
9
|
+
has_many :comments, class_name: "Bible270::Comment", dependent: :destroy
|
|
10
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
11
|
+
|
|
12
|
+
validates :display_name, presence: true
|
|
13
|
+
validates :uid, uniqueness: { scope: :provider }, allow_nil: true
|
|
14
|
+
|
|
15
|
+
# Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various
|
|
16
|
+
# shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).
|
|
17
|
+
def self.from_omniauth(auth)
|
|
18
|
+
provider = dig_auth(auth, :provider).to_s
|
|
19
|
+
uid = dig_auth(auth, :uid).to_s
|
|
20
|
+
return nil if provider.empty? || uid.empty?
|
|
21
|
+
|
|
22
|
+
info = dig_auth(auth, :info) || {}
|
|
23
|
+
name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
|
|
24
|
+
dig_auth(info, :first_name), dig_auth(info, :email))
|
|
25
|
+
|
|
26
|
+
reader = find_or_initialize_by(provider: provider, uid: uid)
|
|
27
|
+
reader.display_name = first_present(name, reader.display_name, "Reader")
|
|
28
|
+
email = dig_auth(info, :email)
|
|
29
|
+
image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
|
|
30
|
+
reader.email = email if email.present?
|
|
31
|
+
reader.avatar_url = image if image.present?
|
|
32
|
+
reader.save
|
|
33
|
+
reader
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Find or create the reader behind a verified email address. Uses the same
|
|
37
|
+
# provider/uid identity columns as OmniAuth, with provider "email", so an
|
|
38
|
+
# email reader is indistinguishable from any other downstream.
|
|
39
|
+
def self.from_email(email, display_name: nil)
|
|
40
|
+
address = EmailSignIn.normalize_email(email)
|
|
41
|
+
return nil if address.nil?
|
|
42
|
+
|
|
43
|
+
reader = find_or_initialize_by(provider: "email", uid: address)
|
|
44
|
+
chosen = first_present(display_name, reader.display_name,
|
|
45
|
+
EmailSignIn.display_name_from(address), "Reader")
|
|
46
|
+
reader.display_name = chosen
|
|
47
|
+
reader.email = address
|
|
48
|
+
reader.save
|
|
49
|
+
reader
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.dig_auth(obj, key)
|
|
53
|
+
return nil if obj.nil?
|
|
54
|
+
if obj.respond_to?(:[])
|
|
55
|
+
obj[key] || (obj[key.to_s] if key.is_a?(Symbol))
|
|
56
|
+
elsif obj.respond_to?(key)
|
|
57
|
+
obj.public_send(key)
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
private_class_method :dig_auth
|
|
63
|
+
|
|
64
|
+
def self.first_present(*values)
|
|
65
|
+
values.compact.find { |v| v.to_s.strip != "" }
|
|
66
|
+
end
|
|
67
|
+
private_class_method :first_present
|
|
68
|
+
|
|
69
|
+
# Find or create a reader bridged to a host user (or any model).
|
|
70
|
+
def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
|
|
71
|
+
reader = find_or_initialize_by(owner: owner)
|
|
72
|
+
reader.display_name = display_name.presence || reader.display_name || "Reader"
|
|
73
|
+
reader.email ||= email
|
|
74
|
+
reader.avatar_url ||= avatar_url
|
|
75
|
+
reader.save!
|
|
76
|
+
reader
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initials
|
|
80
|
+
display_name.to_s.split(/\s+/).first(2).map { |w| w[0] }.join.upcase.presence || "?"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# {day => number of tracks checked off}
|
|
84
|
+
def checked_counts
|
|
85
|
+
@checked_counts ||= checkoffs.group(:day).count
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_tracks_for(day)
|
|
89
|
+
checkoffs.where(day: day).pluck(:track)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read?(day, track)
|
|
93
|
+
read_tracks_for(day).include?(track.to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def day_complete?(day)
|
|
97
|
+
n = checked_counts[day].to_i
|
|
98
|
+
n.positive? && n >= Plan.required_track_count(day)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def days_completed
|
|
102
|
+
checked_counts.count { |day, n| n >= Plan.required_track_count(day) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def days_read_in(track)
|
|
106
|
+
checkoffs.where(track: track.to_s).count
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def completion_percent
|
|
110
|
+
(days_completed.to_f / Plan::DAYS * 100).round
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# First day not yet fully complete (where the reader "is").
|
|
114
|
+
def current_day
|
|
115
|
+
(1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Day number implied by the calendar, if a start date is set.
|
|
119
|
+
# ---- start date / calendar ------------------------------------------
|
|
120
|
+
|
|
121
|
+
# The start date that actually governs this reader. A reader's own
|
|
122
|
+
# started_on wins when per-reader dates are allowed; otherwise (or if they
|
|
123
|
+
# haven't got one) the community-wide config.start_date applies. Nil means
|
|
124
|
+
# the plan is undated for this reader and no calendar mapping exists.
|
|
125
|
+
def effective_start_date
|
|
126
|
+
config = Bible270.config
|
|
127
|
+
if config.allow_reader_start_date && started_on
|
|
128
|
+
started_on
|
|
129
|
+
else
|
|
130
|
+
config.start_date
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def dated?
|
|
135
|
+
effective_start_date.present?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Whether this reader is following a personal date or the shared cohort one.
|
|
139
|
+
def own_start_date?
|
|
140
|
+
Bible270.config.allow_reader_start_date && started_on.present?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The plan day that today corresponds to (clamped into range), or nil when undated.
|
|
144
|
+
def calendar_day
|
|
145
|
+
Plan.day_for(Date.current, effective_start_date)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Raw, unclamped — lets callers distinguish "not started yet" / "finished".
|
|
149
|
+
def raw_calendar_day
|
|
150
|
+
Plan.day_for(Date.current, effective_start_date, clamp: false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def not_started_yet?
|
|
154
|
+
Plan.before_start?(Date.current, effective_start_date)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def past_end_date?
|
|
158
|
+
Plan.after_end?(Date.current, effective_start_date)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def plan_end_date
|
|
162
|
+
Plan.end_date_for(effective_start_date)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def date_for_day(day)
|
|
166
|
+
Plan.date_for(day, effective_start_date)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# How many days behind (positive) or ahead (negative) of the calendar the
|
|
170
|
+
# reader's actual progress is. Nil when undated.
|
|
171
|
+
def days_off_pace
|
|
172
|
+
today = calendar_day
|
|
173
|
+
return nil unless today
|
|
174
|
+
|
|
175
|
+
today - days_completed
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Set or change this reader's own start date. Accepts a Date or a string.
|
|
179
|
+
def start_date=(value)
|
|
180
|
+
self.started_on = Plan.to_date(value)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def start_date
|
|
184
|
+
started_on
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def update_start_date!(value)
|
|
188
|
+
return false unless Bible270.config.allow_reader_start_date
|
|
189
|
+
|
|
190
|
+
date = Plan.to_date(value)
|
|
191
|
+
return false if date.nil?
|
|
192
|
+
|
|
193
|
+
update!(started_on: date)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def clear_start_date!
|
|
197
|
+
update!(started_on: nil)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Called when a reader first participates. Only stamps a personal start date
|
|
201
|
+
# when per-reader dates are enabled and a shared date isn't already in force.
|
|
202
|
+
def ensure_started!
|
|
203
|
+
config = Bible270.config
|
|
204
|
+
return unless config.allow_reader_start_date
|
|
205
|
+
return if started_on.present?
|
|
206
|
+
return if config.start_date.present?
|
|
207
|
+
|
|
208
|
+
update!(started_on: Date.current)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
# A single-use, short-lived magic-link token for email sign-in.
|
|
4
|
+
#
|
|
5
|
+
# Only the SHA-256 digest of the token is stored, so the table is useless to
|
|
6
|
+
# an attacker who reads it. Tokens are consumed on first successful use.
|
|
7
|
+
class SignInToken < ApplicationRecord
|
|
8
|
+
self.table_name = "bible270_sign_in_tokens"
|
|
9
|
+
|
|
10
|
+
validates :email, presence: true
|
|
11
|
+
validates :token_digest, presence: true, uniqueness: true
|
|
12
|
+
validates :expires_at, presence: true
|
|
13
|
+
|
|
14
|
+
scope :unconsumed, -> { where(consumed_at: nil) }
|
|
15
|
+
scope :live, -> { unconsumed.where(expires_at: Time.current..) }
|
|
16
|
+
|
|
17
|
+
# Issue a token for an email. Returns [token_record, raw_token], or
|
|
18
|
+
# [nil, nil] when the address is being hammered (see rate_limited?).
|
|
19
|
+
def self.issue!(email, display_name: nil)
|
|
20
|
+
address = EmailSignIn.normalize_email(email)
|
|
21
|
+
return [nil, nil] if address.nil?
|
|
22
|
+
return [nil, nil] if rate_limited?(address)
|
|
23
|
+
|
|
24
|
+
raw = EmailSignIn.generate_token
|
|
25
|
+
record = create!(
|
|
26
|
+
email: address,
|
|
27
|
+
display_name: display_name.presence,
|
|
28
|
+
token_digest: EmailSignIn.digest_token(raw),
|
|
29
|
+
expires_at: Time.current + Bible270.config.email_sign_in_ttl
|
|
30
|
+
)
|
|
31
|
+
[record, raw]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Look up a raw token and consume it. Returns the token record on success
|
|
35
|
+
# (carrying email and any requested display name), nil for anything
|
|
36
|
+
# invalid, expired, or already used.
|
|
37
|
+
def self.claim!(raw_token)
|
|
38
|
+
digest = EmailSignIn.digest_token(raw_token)
|
|
39
|
+
return nil if digest.nil?
|
|
40
|
+
|
|
41
|
+
record = live.find_by(token_digest: digest)
|
|
42
|
+
return nil if record.nil?
|
|
43
|
+
|
|
44
|
+
# Consume it: only the update that actually flips the row wins, so a
|
|
45
|
+
# double-clicked link can't sign in twice.
|
|
46
|
+
claimed = unconsumed.where(id: record.id).update_all(consumed_at: Time.current)
|
|
47
|
+
return nil if claimed.zero?
|
|
48
|
+
|
|
49
|
+
record
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Too many outstanding requests for one address in the window.
|
|
53
|
+
def self.rate_limited?(address)
|
|
54
|
+
window = Bible270.config.email_sign_in_window
|
|
55
|
+
max = Bible270.config.email_sign_in_max_per_window
|
|
56
|
+
where(email: address).where(created_at: (Time.current - window)..).count >= max
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Housekeeping: drop spent and stale rows. Safe to run from a cron/rake task.
|
|
60
|
+
def self.sweep!(older_than: 7 * 24 * 60 * 60)
|
|
61
|
+
where(expires_at: ...Time.current).or(where.not(consumed_at: nil))
|
|
62
|
+
.where(created_at: ...(Time.current - older_than))
|
|
63
|
+
.delete_all
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expired?
|
|
67
|
+
expires_at.nil? || expires_at < Time.current
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def consumed?
|
|
71
|
+
consumed_at.present?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="b270-comment" id="comment-<%= comment.id %>">
|
|
2
|
+
<%= b270_avatar(comment.reader) %>
|
|
3
|
+
<div style="flex:1">
|
|
4
|
+
<div class="b270-cmeta">
|
|
5
|
+
<strong><%= link_to comment.reader.display_name, reader_path(comment.reader), style: "text-decoration:none" %></strong>
|
|
6
|
+
· <%= comment.created_at.strftime("%b %-d, %Y") %>
|
|
7
|
+
<% if comment.track.present? %>· <%= b270_track(comment.track)[:label] %><% end %>
|
|
8
|
+
<% if signed_in? && comment.reader_id == current_reader.id %>
|
|
9
|
+
<%= button_to "delete", comment_path(comment), method: :delete, class: "b270-cdel",
|
|
10
|
+
form: { style: "display:inline", data: { turbo_stream: true } } %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="b270-cbody"><%= comment.body %></p>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|