shiplog 0.3.1 → 0.4.0
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 +4 -4
- data/CHANGELOG.md +62 -4
- data/README.md +178 -7
- data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
- data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
- data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
- data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
- data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
- data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
- data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
- data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
- data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/frontends_controller.rb +2 -1
- data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
- data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
- data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
- data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
- data/app/controllers/shiplog/newsletter_controller.rb +34 -0
- data/app/controllers/shiplog/public_controller.rb +10 -0
- data/app/frontend/shiplog/admin.css +5 -0
- data/app/frontend/shiplog/once.js +31 -0
- data/app/frontend/shiplog/shiplog.css +72 -0
- data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
- data/app/helpers/shiplog/head_helper.rb +1 -1
- data/app/helpers/shiplog/newsletter_helper.rb +28 -0
- data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
- data/app/helpers/shiplog/permalink_helper.rb +24 -5
- data/app/helpers/shiplog/unseen_helper.rb +4 -0
- data/app/jobs/shiplog/announce_job.rb +30 -0
- data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
- data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
- data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
- data/app/jobs/shiplog/translate_entry_job.rb +2 -0
- data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
- data/app/models/shiplog/announcements.rb +34 -0
- data/app/models/shiplog/area_detector.rb +2 -1
- data/app/models/shiplog/change_candidates.rb +4 -0
- data/app/models/shiplog/drafter.rb +4 -2
- data/app/models/shiplog/entry.rb +76 -6
- data/app/models/shiplog/markdown.rb +233 -0
- data/app/models/shiplog/newsletter/content.rb +172 -0
- data/app/models/shiplog/newsletter/csv.rb +32 -0
- data/app/models/shiplog/newsletter/recipients.rb +92 -0
- data/app/models/shiplog/newsletter/tokens.rb +77 -0
- data/app/models/shiplog/newsletter.rb +180 -0
- data/app/models/shiplog/newsletter_delivery.rb +83 -0
- data/app/models/shiplog/newsletter_subscriber.rb +158 -0
- data/app/models/shiplog/period.rb +129 -0
- data/app/models/shiplog/public_links.rb +47 -0
- data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
- data/app/models/shiplog/rewriter.rb +23 -3
- data/app/models/shiplog/translator/prompt.rb +2 -2
- data/app/models/shiplog/translator.rb +1 -1
- data/app/views/shiplog/admin/_nav.html.erb +3 -0
- data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
- data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
- data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
- data/app/views/shiplog/admin/entries/index.html.erb +9 -0
- data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
- data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
- data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
- data/app/views/shiplog/changelogs/_month.html.erb +10 -3
- data/app/views/shiplog/changelogs/show.html.erb +4 -1
- data/app/views/shiplog/newsletter/_form.html.erb +25 -0
- data/app/views/shiplog/newsletter/_page.html.erb +14 -0
- data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
- data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
- data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
- data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
- data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
- data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
- data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
- data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
- data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
- data/config/admin_routes.rb +12 -0
- data/config/locales/en.yml +147 -0
- data/config/locales/es.yml +147 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
- data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
- data/lib/generators/shiplog/install/install_generator.rb +14 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
- data/lib/shiplog/classes.rb +14 -1
- data/lib/shiplog/configuration.rb +135 -3
- data/lib/shiplog/engine.rb +2 -0
- data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
- data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +33 -0
- data/lib/tasks/shiplog.rake +22 -0
- metadata +57 -2
|
@@ -42,9 +42,11 @@ module Shiplog
|
|
|
42
42
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Issues, pull requests and commits belong to a repository (periods don't); load them once per type.
|
|
46
46
|
def preload_repositories(entries)
|
|
47
|
-
entries.filter_map(&:subject).group_by(&:class).
|
|
47
|
+
entries.filter_map(&:subject).group_by(&:class).each do |type, subjects|
|
|
48
|
+
next unless type.reflect_on_association(:repository)
|
|
49
|
+
|
|
48
50
|
ActiveRecord::Associations::Preloader.new(records: subjects, associations: :repository).call
|
|
49
51
|
end
|
|
50
52
|
end
|
|
@@ -53,8 +55,10 @@ module Shiplog
|
|
|
53
55
|
@entry = Entry.find(params[:id])
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
# An announcement's month, release date and area follow from its period: only its text is edited.
|
|
56
59
|
def entry_params
|
|
57
|
-
params.expect(entry: %i[title body released_on changelog_kind area_id])
|
|
60
|
+
permitted = params.expect(entry: %i[title body released_on changelog_kind area_id])
|
|
61
|
+
@entry.announcement? ? permitted.slice(:title, :body) : permitted
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def page_number
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
# The newsletter's subscribers (the visitors who subscribed through Shiplog's form; the host's users are its
|
|
4
|
+
# own): counts per locale and status, a searchable paginated list, removal, and a CSV export (.csv, the
|
|
5
|
+
# search applied; cells that look like formulas are neutralised, see Newsletter::Csv).
|
|
6
|
+
class NewsletterSubscribersController < ApplicationController
|
|
7
|
+
PER_PAGE = 100
|
|
8
|
+
MAX_PAGE = 100_000
|
|
9
|
+
|
|
10
|
+
before_action :require_newsletter
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@query = params[:q].is_a?(String) ? params[:q].squish.first(NewsletterSubscriber::EMAIL_MAX_LENGTH) : ""
|
|
14
|
+
scope = NewsletterSubscriber.search(@query).newest_first
|
|
15
|
+
respond_to do |format|
|
|
16
|
+
format.html do
|
|
17
|
+
@counts = NewsletterSubscriber.counts_by_locale
|
|
18
|
+
@page = page_number
|
|
19
|
+
records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
20
|
+
@more = records.size > PER_PAGE
|
|
21
|
+
@subscribers = records.first(PER_PAGE)
|
|
22
|
+
end
|
|
23
|
+
format.csv do
|
|
24
|
+
send_data Newsletter::Csv.generate(scope), type: "text/csv; charset=utf-8", disposition: "attachment",
|
|
25
|
+
filename: "shiplog-newsletter-subscribers-#{Date.current.iso8601}.csv"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def destroy
|
|
31
|
+
subscriber = NewsletterSubscriber.find(params[:id])
|
|
32
|
+
subscriber.destroy!
|
|
33
|
+
redirect_to newsletter_subscribers_path(q: params[:q].presence), notice: t("shiplog.admin.newsletter.removed", email: subscriber.email)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def require_newsletter
|
|
39
|
+
head :not_found unless Shiplog.newsletter?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def page_number
|
|
43
|
+
Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
module Shiplog
|
|
2
|
-
# The permalink page of a published feature or change: /changelog/:slug.
|
|
2
|
+
# The permalink page of a published feature or change, or of a month's announcement: /changelog/:slug.
|
|
3
3
|
class ChangelogEntriesController < PublicController
|
|
4
4
|
def show
|
|
5
|
-
@entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug])
|
|
5
|
+
@entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug]) || published_announcement
|
|
6
6
|
return pass_to_host_routes unless @entry
|
|
7
7
|
|
|
8
8
|
notify_changelog_seen
|
|
9
|
+
return show_announcement if @entry.announcement?
|
|
10
|
+
|
|
9
11
|
@newer = @entry.newer_changelog_entry
|
|
10
12
|
@older = @entry.older_changelog_entry
|
|
11
13
|
preload_translations(@entry, @newer, @older)
|
|
@@ -15,6 +17,16 @@ module Shiplog
|
|
|
15
17
|
|
|
16
18
|
private
|
|
17
19
|
|
|
20
|
+
# An approved announcement with this slug, when Shiplog renders announcement pages (config.announcements).
|
|
21
|
+
def published_announcement
|
|
22
|
+
Entry.published_announcements.find_by(slug: params[:slug]) if Shiplog.announcement_pages?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show_announcement
|
|
26
|
+
preload_translations(@entry)
|
|
27
|
+
render :announcement if stale?(etag: [ *shiplog_etag_parts, @entry.id, @entry.updated_at, shiplog_translation_etag_parts([ @entry ]) ])
|
|
28
|
+
end
|
|
29
|
+
|
|
18
30
|
# The engine is usually mounted at "/", so /changelog/:slug can shadow a host route below the changelog
|
|
19
31
|
# path (/changelog/archive, declared after the mount). An unknown slug answers 404 with X-Cascade: pass,
|
|
20
32
|
# which makes the router try the routes after the engine's mount; if none matches, the host's own 404 shows.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module Shiplog
|
|
2
|
-
# The Atom feed of the latest published features and changes
|
|
2
|
+
# The Atom feed of the latest published features and changes, and of the announcements when Shiplog renders
|
|
3
|
+
# their pages: /changelog.atom.
|
|
3
4
|
class ChangelogFeedsController < PublicController
|
|
4
5
|
LIMIT = 50
|
|
5
6
|
|
|
@@ -7,7 +8,10 @@ module Shiplog
|
|
|
7
8
|
before_action :set_area
|
|
8
9
|
|
|
9
10
|
def show
|
|
10
|
-
scope = Entry.published_changelog
|
|
11
|
+
scope = Entry.published_changelog
|
|
12
|
+
# Announcements have no area: a feed filtered by area leaves them out.
|
|
13
|
+
scope = scope.or(Entry.published_announcements) if Shiplog.announcement_pages? && !@area
|
|
14
|
+
scope = scope.newest_first
|
|
11
15
|
scope = scope.where(area_id: @area.id) if @area
|
|
12
16
|
@entries = scope.includes(:area).with_images_loaded.limit(LIMIT).to_a
|
|
13
17
|
@entries.select!(&:permalink?) # no slug (or an invalid one) means no stable id: left out
|
|
@@ -21,14 +21,24 @@ module Shiplog
|
|
|
21
21
|
@months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
|
|
22
22
|
@result_count = scope.count if @query
|
|
23
23
|
shown = @months.values.flatten
|
|
24
|
+
@announcements = announcements_for(@months.keys)
|
|
24
25
|
preload_translations(shown)
|
|
25
26
|
fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @areas.map(&:id), @query, @page, @older,
|
|
26
27
|
shown.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
|
|
27
|
-
shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown)
|
|
28
|
+
shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown),
|
|
29
|
+
@announcements.values.map { |announcement| [ announcement.id, announcement.slug, announcement.updated_at ] } ]
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
34
|
+
# The approved announcements of the months shown, by the month's first day (none while announcements are off).
|
|
35
|
+
def announcements_for(months)
|
|
36
|
+
return {} unless Shiplog.announcements? && months.any?
|
|
37
|
+
|
|
38
|
+
Entry.published_announcements.where(released_on: months.map(&:end_of_month))
|
|
39
|
+
.index_by { |announcement| announcement.released_on.beginning_of_month }
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# The areas to offer as filter links: those with published entries, in the admin's order, and only when there
|
|
33
43
|
# are at least two (a single area filters nothing).
|
|
34
44
|
def filter_areas
|
|
@@ -5,7 +5,8 @@ module Shiplog
|
|
|
5
5
|
"shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
|
|
6
6
|
"admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
|
|
7
7
|
"board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" },
|
|
8
|
-
"inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" }
|
|
8
|
+
"inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" },
|
|
9
|
+
"once.js" => { path: ASSET_DIRECTORY.join("once.js"), type: "text/javascript" }
|
|
9
10
|
}.freeze
|
|
10
11
|
|
|
11
12
|
# The version segment busts caches; stale versions are 404 so they are never cached as the current file.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The double opt-in link: a page with a button (GET changes nothing, link scanners prefetch), and the POST
|
|
4
|
+
# that confirms. The token is signed and expires after 7 days (Newsletter::Tokens); an invalid one is a 404.
|
|
5
|
+
# Rendered in the subscriber's language, whatever the page's URL.
|
|
6
|
+
class ConfirmationsController < NewsletterController
|
|
7
|
+
around_action :with_subscriber
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
@subscriber.confirm!
|
|
14
|
+
render :create
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def with_subscriber
|
|
20
|
+
@subscriber = Tokens.subscriber_for(params[:token])
|
|
21
|
+
return render_invalid_link unless @subscriber
|
|
22
|
+
|
|
23
|
+
I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) { yield }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# A subscriber's preferences, reached through the token of the newsletter's footer, without an account:
|
|
4
|
+
# change the language, unsubscribe, resubscribe (the token proves the mailbox, so no new confirmation), and
|
|
5
|
+
# "delete my data", which removes the row. The token is rotated after each change (the next page uses the
|
|
6
|
+
# new one; the link in the old email stops working). Rendered in the subscriber's language, and still
|
|
7
|
+
# available while config.newsletter is off, so an old email's link keeps working.
|
|
8
|
+
class PreferencesController < NewsletterController
|
|
9
|
+
skip_before_action :require_newsletter
|
|
10
|
+
before_action :require_newsletter_tables
|
|
11
|
+
around_action :with_subscriber
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# locale (one of the allowed ones) and/or subscribed ("0" unsubscribes from both lists, "1" resubscribes).
|
|
17
|
+
# The notice is worded in the language just chosen, which the next page is in.
|
|
18
|
+
def update
|
|
19
|
+
notice = "saved"
|
|
20
|
+
case params[:subscribed]
|
|
21
|
+
when "0"
|
|
22
|
+
Newsletter.unsubscribe(@subscriber.email)
|
|
23
|
+
notice = "unsubscribed"
|
|
24
|
+
when "1"
|
|
25
|
+
@subscriber.resubscribe!
|
|
26
|
+
notice = "resubscribed"
|
|
27
|
+
end
|
|
28
|
+
@subscriber.update!(locale: NewsletterSubscriber.resolve_locale(params[:locale])) if params[:locale].is_a?(String)
|
|
29
|
+
@subscriber.rotate_token!
|
|
30
|
+
I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) do
|
|
31
|
+
redirect_to newsletter_preferences_path(@subscriber.token), notice: t("shiplog.newsletter.preferences.#{notice}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy
|
|
36
|
+
@subscriber.destroy!
|
|
37
|
+
render :destroy
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def with_subscriber
|
|
43
|
+
@subscriber = NewsletterSubscriber.find_by(token: params[:token].to_s)
|
|
44
|
+
return render_invalid_link unless @subscriber
|
|
45
|
+
|
|
46
|
+
I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) { yield }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The subscribe form's target and the "check your inbox" page it leads to.
|
|
4
|
+
class SubscriptionsController < NewsletterController
|
|
5
|
+
PER_IP = 20
|
|
6
|
+
PER_EMAIL = 3
|
|
7
|
+
# All confirmation emails together, an hour: a ceiling on what a distributed bot can make Shiplog send.
|
|
8
|
+
GLOBAL = 300
|
|
9
|
+
WINDOW = 1.hour
|
|
10
|
+
# The honeypot's field name: nothing a browser autofills (no "website", "url", "phone"...).
|
|
11
|
+
HONEYPOT = :nl_extra
|
|
12
|
+
|
|
13
|
+
# The form sits on public pages that are served with ETags: a 304 revisits a cached page, whose CSRF token
|
|
14
|
+
# may belong to another session or be expired, and a host layout may cache it further. So the form sends no
|
|
15
|
+
# token and this action checks none: it changes no session state, and nothing happens until the address
|
|
16
|
+
# confirms by email (double opt-in), so a forged POST can at most send someone a confirmation email, which
|
|
17
|
+
# the rate limits bound.
|
|
18
|
+
skip_forgery_protection only: :create
|
|
19
|
+
|
|
20
|
+
# Rails.cache counts the attempts (a null store counts nothing: the limits are then off, as in tests). The
|
|
21
|
+
# per-address count is keyed by a digest of the normalised address (Newsletter.rate_limit_key), so the cache
|
|
22
|
+
# holds no address and "a+tag@gmail.com" and "a.b@gmail.com" count as one.
|
|
23
|
+
rate_limit to: PER_IP, within: WINDOW, by: -> { request.remote_ip }, with: -> { rate_limited }, name: "ip", only: :create
|
|
24
|
+
rate_limit to: PER_EMAIL, within: WINDOW, by: -> { submitted_email.present? ? Newsletter.rate_limit_key(submitted_email) : request.remote_ip }, with: -> { rate_limited }, name: "email", only: :create
|
|
25
|
+
rate_limit to: GLOBAL, within: WINDOW, by: -> { "all" }, with: -> { rate_limited }, name: "global", only: :create
|
|
26
|
+
|
|
27
|
+
# The same redirect for a new, pending, confirmed or unsubscribed address (and for a bot that filled the
|
|
28
|
+
# honeypot): the form cannot tell who is subscribed. Only an address that is not an email gets the form back.
|
|
29
|
+
def create
|
|
30
|
+
return redirect_to(newsletter_subscription_path) if params[HONEYPOT].present?
|
|
31
|
+
|
|
32
|
+
subscriber = NewsletterSubscriber.subscribe(submitted_email, locale: I18n.locale, source: submitted_source)
|
|
33
|
+
if subscriber
|
|
34
|
+
redirect_to newsletter_subscription_path
|
|
35
|
+
else
|
|
36
|
+
@error = t("shiplog.newsletter.form.invalid_email", default: "Enter a valid email address.")
|
|
37
|
+
@email = submitted_email
|
|
38
|
+
render :show, status: :unprocessable_content
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# "Check your inbox to confirm."
|
|
43
|
+
def show
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def submitted_email
|
|
49
|
+
value = params[:email]
|
|
50
|
+
value.is_a?(String) ? value.strip.downcase.first(NewsletterSubscriber::EMAIL_MAX_LENGTH + 1) : ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The page the visitor subscribed on: the form's `source` when it is a same-site path, else the referrer's
|
|
54
|
+
# path (NewsletterSubscriber.clean_source has the last word).
|
|
55
|
+
def submitted_source
|
|
56
|
+
value = params[:source]
|
|
57
|
+
return value if value.is_a?(String) && NewsletterSubscriber.clean_source(value)
|
|
58
|
+
|
|
59
|
+
referer = request.referer.to_s
|
|
60
|
+
URI.parse(referer).path if referer.present?
|
|
61
|
+
rescue URI::InvalidURIError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rate_limited
|
|
66
|
+
render "shiplog/newsletter/rate_limited", status: :too_many_requests
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The unsubscribe link of every newsletter, for the host's users and Shiplog's subscribers alike: the token is
|
|
4
|
+
# the encrypted email (Newsletter::Tokens), so Shiplog needs no row for a host user until they unsubscribe.
|
|
5
|
+
# GET shows a page with a button; POST unsubscribes the address from both lists (Newsletter.unsubscribe,
|
|
6
|
+
# which keeps a suppression row and calls config.on_newsletter_unsubscribe). The POST is also the RFC 8058
|
|
7
|
+
# one-click target the List-Unsubscribe-Post header names: the mail provider sends it with the body
|
|
8
|
+
# "List-Unsubscribe=One-Click" and no CSRF token, so the action checks none; the token in the URL is what
|
|
9
|
+
# authorises it. Still available while config.newsletter is off: an old email's link must keep working.
|
|
10
|
+
class UnsubscriptionsController < NewsletterController
|
|
11
|
+
skip_before_action :require_newsletter
|
|
12
|
+
before_action :require_newsletter_tables
|
|
13
|
+
skip_forgery_protection only: :create
|
|
14
|
+
|
|
15
|
+
around_action :with_email
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
Newsletter.unsubscribe(@email)
|
|
22
|
+
render :create
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def with_email
|
|
28
|
+
@email = Tokens.email_for(params[:token])
|
|
29
|
+
return render_invalid_link unless @email
|
|
30
|
+
|
|
31
|
+
subscriber = NewsletterSubscriber.find_by_email(@email)
|
|
32
|
+
I18n.with_locale(Newsletter.i18n_locale(subscriber ? subscriber.locale : I18n.locale)) { yield }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Parent of the newsletter's public pages (subscribe, confirm, preferences, unsubscribe), all under /shiplog/
|
|
3
|
+
# so a host that constrains the engine's mount to its own paths lets them through. Subscribing and confirming
|
|
4
|
+
# answer 404 while the newsletter is off; the unsubscribe and preferences pages keep working as long as the
|
|
5
|
+
# tables exist (a link in an old email must still unsubscribe). None is ever cached: they are personal (a
|
|
6
|
+
# token in the URL) or carry a fresh CSRF token.
|
|
7
|
+
#
|
|
8
|
+
# None of them changes state on a GET: email link scanners prefetch links, so a link in an email opens a page
|
|
9
|
+
# with a button, and the button POSTs. The one-click unsubscribe (RFC 8058) is the exception by design: the
|
|
10
|
+
# mail provider POSTs it (see Newsletter::UnsubscriptionsController).
|
|
11
|
+
class NewsletterController < PublicController
|
|
12
|
+
helper Shiplog::NewsletterHelper
|
|
13
|
+
|
|
14
|
+
before_action :require_newsletter
|
|
15
|
+
before_action :no_store
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def require_newsletter
|
|
20
|
+
head :not_found unless Shiplog.newsletter?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# For the pages that outlive config.newsletter: the tables must be there.
|
|
24
|
+
def require_newsletter_tables
|
|
25
|
+
head :not_found unless Newsletter.available?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The page for a link that does not verify (tampered, expired, used, or a deleted subscriber). A 404 carries
|
|
29
|
+
# no hint of which.
|
|
30
|
+
def render_invalid_link
|
|
31
|
+
render "shiplog/newsletter/invalid", status: :not_found
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -11,8 +11,18 @@ module Shiplog
|
|
|
11
11
|
helper Shiplog::PermalinkHelper
|
|
12
12
|
helper Shiplog::StructuredDataHelper
|
|
13
13
|
helper Shiplog::HeadHelper
|
|
14
|
+
helper Shiplog::NewsletterHelper
|
|
14
15
|
layout -> { Shiplog.config.public_layout }
|
|
15
16
|
|
|
17
|
+
# Marks the controllers of the public pages, for shiplog_head_tags: checking this instead of the class keeps
|
|
18
|
+
# host pages from autoloading Shiplog::PublicController (and the host's parent controller).
|
|
19
|
+
def shiplog_public_page? = true
|
|
20
|
+
|
|
21
|
+
# The entry a permalink or announcement page shows (nil on the other pages), for config.on_changelog_seen and
|
|
22
|
+
# host layouts: `controller.shiplog_entry&.announcement?` tells an announcement visit apart.
|
|
23
|
+
def shiplog_entry = @entry
|
|
24
|
+
helper_method :shiplog_entry
|
|
25
|
+
|
|
16
26
|
private
|
|
17
27
|
|
|
18
28
|
# Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
|
|
@@ -134,7 +134,12 @@
|
|
|
134
134
|
.shiplog-admin .shiplog-admin-images__controls { display: grid; flex: 1; gap: 0.5rem; min-width: 16rem; }
|
|
135
135
|
.shiplog-admin .shiplog-admin__visually-hidden { border: 0; clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; }
|
|
136
136
|
.shiplog-admin .shiplog-area-row__count { text-align: right; }
|
|
137
|
+
.shiplog-admin .shiplog-admin-preview { background: var(--sa-bg); border-radius: 0.5rem; overflow-wrap: anywhere; padding: 0.75rem 1rem; }
|
|
138
|
+
.shiplog-admin .shiplog-admin-export { display: inline-flex; gap: 0.5rem; margin-left: 0.75rem; }
|
|
137
139
|
.shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
|
|
140
|
+
.shiplog-admin .shiplog-admin-counts { margin-bottom: 1rem; width: auto; }
|
|
141
|
+
.shiplog-admin .shiplog-admin-counts__number { text-align: right; }
|
|
142
|
+
.shiplog-admin .shiplog-admin-newsletter__progress { margin: 0 0 0.5rem; }
|
|
138
143
|
|
|
139
144
|
.shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|
|
140
145
|
.shiplog-admin .shiplog-board__title { font-size: 1.125rem; margin: 0 0 0.5rem; }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Forms marked data-shiplog-once (the newsletter's Send, Send test and Retry) submit once: their buttons are
|
|
2
|
+
// disabled and marked busy as soon as the form is submitted, so a double click sends one request. The server is
|
|
3
|
+
// idempotent anyway (delivery rows are claimed atomically); this only spares the admin a confusing second request.
|
|
4
|
+
// Buttons come back when the page is restored from the back/forward cache.
|
|
5
|
+
const SELECTOR = "form[data-shiplog-once]"
|
|
6
|
+
|
|
7
|
+
document.addEventListener("submit", (event) => {
|
|
8
|
+
const form = event.target
|
|
9
|
+
if (!(form instanceof HTMLFormElement) || !form.matches(SELECTOR)) return
|
|
10
|
+
|
|
11
|
+
if (form.dataset.shiplogSubmitted === "true") {
|
|
12
|
+
event.preventDefault()
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
form.dataset.shiplogSubmitted = "true"
|
|
16
|
+
form.setAttribute("aria-busy", "true")
|
|
17
|
+
// Disabled after the submit event, so the clicked button's name and value are still sent.
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
form.querySelectorAll("button, input[type=submit]").forEach((button) => { button.disabled = true })
|
|
20
|
+
}, 0)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
window.addEventListener("pageshow", (event) => {
|
|
24
|
+
if (!event.persisted) return
|
|
25
|
+
|
|
26
|
+
document.querySelectorAll(SELECTOR).forEach((form) => {
|
|
27
|
+
delete form.dataset.shiplogSubmitted
|
|
28
|
+
form.removeAttribute("aria-busy")
|
|
29
|
+
form.querySelectorAll("button, input[type=submit]").forEach((button) => { button.disabled = false })
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -285,6 +285,34 @@
|
|
|
285
285
|
.shiplog-permalink__back:focus-visible,
|
|
286
286
|
.shiplog-permalink__nav-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
|
|
287
287
|
|
|
288
|
+
.shiplog-announcement {
|
|
289
|
+
background: var(--sl-card);
|
|
290
|
+
border: 1px solid var(--sl-border);
|
|
291
|
+
border-left: 4px solid var(--sl-accent);
|
|
292
|
+
border-radius: var(--sl-radius);
|
|
293
|
+
padding: calc(var(--sl-space) * 1.25);
|
|
294
|
+
}
|
|
295
|
+
.shiplog-announcement .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
|
|
296
|
+
.shiplog-announcement__title { font-size: 1.5rem; line-height: 1.25; margin: 0.5rem 0 var(--sl-space); }
|
|
297
|
+
.shiplog-announcement__body { line-height: 1.6; }
|
|
298
|
+
.shiplog-announcement__body h3,
|
|
299
|
+
.shiplog-announcement__body h4,
|
|
300
|
+
.shiplog-announcement__body h5 { line-height: 1.3; margin: calc(var(--sl-space) * 1.5) 0 0.5rem; }
|
|
301
|
+
.shiplog-announcement__body h3 { font-size: 1.125rem; }
|
|
302
|
+
.shiplog-announcement__body h4 { font-size: 1rem; }
|
|
303
|
+
.shiplog-announcement__body p,
|
|
304
|
+
.shiplog-announcement__body ul,
|
|
305
|
+
.shiplog-announcement__body ol,
|
|
306
|
+
.shiplog-announcement__body blockquote { margin: 0.5rem 0; }
|
|
307
|
+
.shiplog-announcement__body ul,
|
|
308
|
+
.shiplog-announcement__body ol { padding-left: 1.25rem; }
|
|
309
|
+
.shiplog-announcement__body a { color: var(--sl-accent); }
|
|
310
|
+
.shiplog-announcement__body img { border-radius: var(--sl-radius); height: auto; max-width: 100%; }
|
|
311
|
+
.shiplog-announcement__body blockquote { border-left: 3px solid var(--sl-border); color: var(--sl-muted); margin-left: 0; padding-left: var(--sl-space); }
|
|
312
|
+
.shiplog-month__announcement { margin: -0.25rem 0 0.75rem; }
|
|
313
|
+
.shiplog-month__announcement-link { color: var(--sl-accent); font-weight: 600; }
|
|
314
|
+
.shiplog-month__announcement-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
|
|
315
|
+
|
|
288
316
|
.shiplog-entry__area {
|
|
289
317
|
color: var(--sl-muted);
|
|
290
318
|
font-size: 0.75rem;
|
|
@@ -336,6 +364,50 @@
|
|
|
336
364
|
.shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
|
|
337
365
|
.shiplog-search__empty { color: var(--sl-muted); margin: 0; }
|
|
338
366
|
|
|
367
|
+
/* Newsletter: the subscribe form on the changelog and announcement pages, and the confirm / preferences /
|
|
368
|
+
unsubscribe pages (.shiplog-newsletter--page). */
|
|
369
|
+
.shiplog-newsletter {
|
|
370
|
+
background: var(--sl-card);
|
|
371
|
+
border: 1px solid var(--sl-border);
|
|
372
|
+
border-radius: var(--sl-radius);
|
|
373
|
+
margin: calc(var(--sl-space) * 2) 0;
|
|
374
|
+
padding: var(--sl-space) calc(var(--sl-space) * 1.25);
|
|
375
|
+
}
|
|
376
|
+
.shiplog-newsletter__title { font-size: 1.125rem; line-height: 1.3; margin: 0 0 0.25rem; }
|
|
377
|
+
.shiplog-newsletter__hint { color: var(--sl-muted); font-size: 0.875rem; margin: 0 0 0.75rem; }
|
|
378
|
+
.shiplog-newsletter__form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
|
379
|
+
.shiplog-newsletter__label { font-weight: 500; }
|
|
380
|
+
.shiplog-newsletter__input {
|
|
381
|
+
flex: 1 1 14rem;
|
|
382
|
+
min-width: 0;
|
|
383
|
+
font: inherit;
|
|
384
|
+
color: inherit;
|
|
385
|
+
background: var(--sl-bg);
|
|
386
|
+
border: 1px solid var(--sl-border);
|
|
387
|
+
border-radius: var(--sl-radius);
|
|
388
|
+
padding: 0.4rem 0.75rem;
|
|
389
|
+
}
|
|
390
|
+
.shiplog-newsletter__button {
|
|
391
|
+
font: inherit;
|
|
392
|
+
color: #fff;
|
|
393
|
+
background: var(--sl-accent);
|
|
394
|
+
border: 1px solid var(--sl-accent);
|
|
395
|
+
border-radius: var(--sl-radius);
|
|
396
|
+
padding: 0.4rem 1rem;
|
|
397
|
+
cursor: pointer;
|
|
398
|
+
}
|
|
399
|
+
.shiplog-newsletter__button--secondary { color: var(--sl-fg); background: transparent; border-color: var(--sl-border); }
|
|
400
|
+
.shiplog-newsletter__input:focus-visible,
|
|
401
|
+
.shiplog-newsletter__button:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
|
|
402
|
+
.shiplog-newsletter__notice { margin: 0 0 0.75rem; }
|
|
403
|
+
.shiplog-newsletter__notice--error { color: #cf222e; }
|
|
404
|
+
.shiplog-newsletter__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: var(--sl-space); }
|
|
405
|
+
.shiplog-newsletter__actions form { margin: 0; }
|
|
406
|
+
/* The honeypot: out of view for people (never display:none, which some bots detect), labelled for nobody. */
|
|
407
|
+
.shiplog-newsletter__trap { height: 1px; left: -10000px; overflow: hidden; position: absolute; top: auto; width: 1px; }
|
|
408
|
+
.shiplog-newsletter--page { margin-top: 0; }
|
|
409
|
+
.shiplog-newsletter--page p { margin: 0 0 0.75rem; }
|
|
410
|
+
|
|
339
411
|
@media (max-width: 40rem) {
|
|
340
412
|
.shiplog-page { padding: var(--sl-space) 0.75rem; }
|
|
341
413
|
.shiplog-page__title { font-size: 1.5rem; }
|
|
@@ -9,8 +9,10 @@ module Shiplog
|
|
|
9
9
|
subject.is_a?(Commit) ? subject.message.lines.drop(1).join.strip : subject.body.to_s
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# A commit's short sha, a period's month, or "owner/name#123".
|
|
12
13
|
def source_reference(subject)
|
|
13
14
|
return subject.sha.first(7) if subject.is_a?(Commit)
|
|
15
|
+
return subject.title if subject.is_a?(Period)
|
|
14
16
|
|
|
15
17
|
"#{subject.repository.full_name}##{subject.number}"
|
|
16
18
|
end
|
|
@@ -9,7 +9,7 @@ module Shiplog
|
|
|
9
9
|
# shiplog/head/_open_graph og:* and twitter:*
|
|
10
10
|
# shiplog/head/_feed the Atom alternate link
|
|
11
11
|
# shiplog/head/_structured_data the JSON-LD script
|
|
12
|
-
# Each receives the locals `head` (a Hash: :kind, :title, :
|
|
12
|
+
# Each receives the locals `head` (a Hash: :kind, :title, :entry, :canonical, :image, :description), `description`,
|
|
13
13
|
# `canonical`, `robots`, `open_graph` and `twitter` (Hashes of tag name => content), `feed_url`, `feed_title`
|
|
14
14
|
# and `json_ld`. Whatever `except:` removed arrives as nil or an empty Hash.
|
|
15
15
|
module HeadHelper
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The newsletter's subscribe form. The changelog and announcement pages render it while config.newsletter is
|
|
3
|
+
# on; a host renders it on any page with `helper Shiplog::NewsletterHelper` and `<%= shiplog_newsletter_form %>`
|
|
4
|
+
# (or `render "shiplog/newsletter/form"` once the helper is included). Nothing is rendered while the newsletter
|
|
5
|
+
# is off. Uses the engine's own route helpers, or the proxy of the host's mount in host views (see SwitcherHelper).
|
|
6
|
+
module NewsletterHelper
|
|
7
|
+
include Shiplog::SwitcherHelper
|
|
8
|
+
|
|
9
|
+
# The form (and the bundled stylesheet once per host page, unless config.stylesheet is false). `source` is
|
|
10
|
+
# recorded with the subscription as the page it was made on; the request's path by default.
|
|
11
|
+
def shiplog_newsletter_form(source: nil)
|
|
12
|
+
return "".html_safe unless Shiplog.newsletter?
|
|
13
|
+
|
|
14
|
+
stylesheet = if Shiplog.config.stylesheet && !@_shiplog_stylesheet_emitted
|
|
15
|
+
@_shiplog_stylesheet_emitted = true
|
|
16
|
+
tag.link(rel: "stylesheet", nonce: content_security_policy_nonce,
|
|
17
|
+
href: shiplog_routes.frontend_path(version: Shiplog::VERSION, name: "shiplog.css"))
|
|
18
|
+
end
|
|
19
|
+
safe_join([ stylesheet, render("shiplog/newsletter/form", source:) ])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The form's action, in the engine's pages and the host's alike.
|
|
23
|
+
def shiplog_newsletter_subscription_path = shiplog_routes.newsletter_subscription_path
|
|
24
|
+
|
|
25
|
+
# The page the subscription is recorded as made on: the given source, else the request's path.
|
|
26
|
+
def shiplog_newsletter_source(source) = (source.presence || request.path).to_s.first(NewsletterSubscriber::SOURCE_MAX_LENGTH)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The inline styles of the newsletter templates: one place for the palette (light colours that email clients
|
|
3
|
+
# invert sanely in dark mode, no pure black) and the pieces an overriding template can reuse (buttons, cards).
|
|
4
|
+
module NewsletterMailerHelper
|
|
5
|
+
PALETTE = {
|
|
6
|
+
page: "#f3f4f6", card: "#ffffff", text: "#1f2328", muted: "#59636e", border: "#d1d9e0", soft: "#f6f8fa"
|
|
7
|
+
}.freeze
|
|
8
|
+
FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif".freeze
|
|
9
|
+
RADIUS = "10px".freeze
|
|
10
|
+
|
|
11
|
+
def shiplog_mail_palette = PALETTE
|
|
12
|
+
|
|
13
|
+
def shiplog_mail_font = FONT
|
|
14
|
+
|
|
15
|
+
# A button: a one-cell table (Outlook ignores padding on links) with the accent as background. `margin` is the
|
|
16
|
+
# table's CSS margin.
|
|
17
|
+
def shiplog_mail_button(label, url, accent, margin: "24px 0 0")
|
|
18
|
+
return if url.blank?
|
|
19
|
+
|
|
20
|
+
tag.table(role: "presentation", cellpadding: "0", cellspacing: "0", border: "0", style: "margin:#{margin};") do
|
|
21
|
+
tag.tr(tag.td(style: "background-color:#{accent};border-radius:6px;") do
|
|
22
|
+
link_to(label, url, style: "display:inline-block;padding:11px 20px;font-family:#{FONT};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;")
|
|
23
|
+
end)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A highlight's screenshot: full width, rounded top corners, sized from the blob's metadata when known.
|
|
28
|
+
def shiplog_mail_screenshot(highlight)
|
|
29
|
+
return unless highlight.image?
|
|
30
|
+
|
|
31
|
+
options = { alt: highlight.image_alt, width: 600, border: 0,
|
|
32
|
+
style: "display:block;width:100%;max-width:600px;height:auto;border:0;border-radius:#{RADIUS} #{RADIUS} 0 0;" }
|
|
33
|
+
options[:height] = (600.0 * highlight.image_height / highlight.image_width).round if highlight.image_width.to_i.positive? && highlight.image_height.to_i.positive?
|
|
34
|
+
link_to(image_tag(highlight.image_url, **options), highlight.url, style: "display:block;text-decoration:none;")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|