shiplog 0.3.2 → 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 +36 -0
- data/README.md +170 -2
- 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 +6 -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/newsletter_helper.rb +28 -0
- data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
- data/app/helpers/shiplog/permalink_helper.rb +20 -2
- 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 +132 -1
- data/lib/shiplog/engine.rb +2 -0
- data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +32 -0
- data/lib/tasks/shiplog.rake +22 -0
- metadata +56 -1
|
@@ -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,12 +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
|
|
|
16
17
|
# Marks the controllers of the public pages, for shiplog_head_tags: checking this instead of the class keeps
|
|
17
18
|
# host pages from autoloading Shiplog::PublicController (and the host's parent controller).
|
|
18
19
|
def shiplog_public_page? = true
|
|
19
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
|
+
|
|
20
26
|
private
|
|
21
27
|
|
|
22
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
|
|
@@ -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
|
|
@@ -37,13 +37,30 @@ module Shiplog
|
|
|
37
37
|
path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Where a month heading's "Read the summary" link goes: the announcement's page (config.announcements = :page),
|
|
41
|
+
# or what config.announcement_url returns for it (:external; evaluated in the view with the announcement and
|
|
42
|
+
# the locale). Nil, so no link, when there is none, it raises or it is not a safe link.
|
|
43
|
+
def shiplog_announcement_href(announcement)
|
|
44
|
+
return shiplog_entry_path(announcement) if Shiplog.announcement_pages?
|
|
45
|
+
|
|
46
|
+
callable = Shiplog.config.announcement_url
|
|
47
|
+
return unless Shiplog.config.announcements == :external && callable
|
|
48
|
+
|
|
49
|
+
value = callable.is_a?(Proc) ? instance_exec(announcement, I18n.locale, &callable) : callable.call(announcement, I18n.locale)
|
|
50
|
+
value if Shiplog::Configuration.safe_url?(value)
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
Rails.logger.error("[shiplog] config.announcement_url failed for entry #{announcement.id}: #{error.class}: #{error.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
# The id of a month's section on the changelog list.
|
|
41
57
|
def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
|
|
42
58
|
|
|
43
59
|
# The body, on one line and cut for a meta description. Plain text: tag helpers escape it. `escape: false`
|
|
44
60
|
# keeps the cut text raw (not HTML-escaped) for JSON, which escapes it its own way.
|
|
45
61
|
def shiplog_entry_description(entry, escape: true)
|
|
46
|
-
|
|
62
|
+
text = entry.announcement? ? Shiplog::Markdown.to_text(entry.display_body) : entry.display_body.to_s
|
|
63
|
+
truncate(text.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
|
|
47
64
|
end
|
|
48
65
|
|
|
49
66
|
# The Open Graph meta tags of a permalink page, for `content_for :shiplog_og` (superseded by shiplog_head_tags).
|
|
@@ -103,7 +120,8 @@ module Shiplog
|
|
|
103
120
|
url = shiplog_image_url(image)
|
|
104
121
|
tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
|
|
105
122
|
end
|
|
106
|
-
|
|
123
|
+
text = entry.announcement? ? Shiplog::Markdown.to_html(entry.display_body) : simple_format(h(entry.display_body))
|
|
124
|
+
safe_join([ text, *images ], "\n")
|
|
107
125
|
end
|
|
108
126
|
|
|
109
127
|
# An entry's images in order, or none while screenshots are off.
|
|
@@ -5,5 +5,9 @@ module Shiplog
|
|
|
5
5
|
module UnseenHelper
|
|
6
6
|
# The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
|
|
7
7
|
def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
|
|
8
|
+
|
|
9
|
+
# The number of approved monthly announcements approved after `since` (all of them while it is nil), at most 99:
|
|
10
|
+
# the rare signal for a badge, where shiplog_unseen_count counts every daily entry.
|
|
11
|
+
def shiplog_unseen_announcements_count(since) = Shiplog.unseen_announcements_count(since:)
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Calls config.on_announcement_approved with an approved announcement, and sends the
|
|
3
|
+
# `announcement_approved.shiplog` notification, once its translations into every configured locale are current
|
|
4
|
+
# (or cannot be made: no rewriter). Enqueued when an announcement is approved and after each translation of one;
|
|
5
|
+
# whichever run finds it ready announces it, once per approval (Entry#announce!).
|
|
6
|
+
#
|
|
7
|
+
# The hook runs inside the announcement's row lock and transaction: keep it fast (enqueue your own job). A hook
|
|
8
|
+
# that raises, or a worker that dies mid-hook, leaves the announcement unannounced, and the retry calls it again;
|
|
9
|
+
# after the last attempt the error is logged and the admin can "Notify now". `force: true` (that button) does not
|
|
10
|
+
# wait for translations.
|
|
11
|
+
class AnnounceJob < ApplicationJob
|
|
12
|
+
ATTEMPTS = 3
|
|
13
|
+
EVENT = "announcement_approved.shiplog".freeze
|
|
14
|
+
|
|
15
|
+
retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
|
|
16
|
+
Rails.logger.error("[shiplog] Giving up announcing entry #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
|
|
17
|
+
end
|
|
18
|
+
discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
|
|
19
|
+
|
|
20
|
+
def perform(entry, force: false)
|
|
21
|
+
return unless Shiplog.announcements?
|
|
22
|
+
|
|
23
|
+
entry.announce!(force:) do
|
|
24
|
+
ActiveSupport::Notifications.instrument(EVENT, announcement: entry) do
|
|
25
|
+
Shiplog.config.on_announcement_approved&.call(entry)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Drafts the announcement of a month (the previous one by default) and enqueues its rewrite. Schedule it once a
|
|
3
|
+
# month, early in the month (see the README's Scheduling section); running it again is harmless.
|
|
4
|
+
class DraftAnnouncementJob < ApplicationJob
|
|
5
|
+
def perform(month = nil)
|
|
6
|
+
Announcements.draft(month: month || Date.current.prev_month)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Housekeeping of the newsletter's tables, to schedule weekly like ScanChangesJob (see the README's Scheduling
|
|
3
|
+
# section): deletes the subscribers who never confirmed within 30 days (NewsletterSubscriber::PENDING_FOR: an
|
|
4
|
+
# address typed by someone else, or a confirmation email that never arrived) and the delivery rows older than
|
|
5
|
+
# 180 days (NewsletterDelivery::RETENTION: the idempotency record of a send long done). Confirmed and
|
|
6
|
+
# unsubscribed rows are never touched. Returns { subscribers:, deliveries: } counts.
|
|
7
|
+
class PurgeNewsletterJob < ApplicationJob
|
|
8
|
+
def perform
|
|
9
|
+
subscribers = NewsletterSubscriber.purge_pending
|
|
10
|
+
deliveries = NewsletterDelivery.purge
|
|
11
|
+
Rails.logger.info("[shiplog] Purged #{subscribers} unconfirmed newsletter #{subscribers == 1 ? "subscriber" : "subscribers"} and #{deliveries} old delivery #{deliveries == 1 ? "row" : "rows"}")
|
|
12
|
+
{ subscribers:, deliveries: }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|