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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -4
  3. data/README.md +178 -7
  4. data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
  5. data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
  6. data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
  7. data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
  8. data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
  9. data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
  10. data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
  11. data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
  12. data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
  13. data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
  14. data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
  15. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  16. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  17. data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
  18. data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
  19. data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
  20. data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
  21. data/app/controllers/shiplog/newsletter_controller.rb +34 -0
  22. data/app/controllers/shiplog/public_controller.rb +10 -0
  23. data/app/frontend/shiplog/admin.css +5 -0
  24. data/app/frontend/shiplog/once.js +31 -0
  25. data/app/frontend/shiplog/shiplog.css +72 -0
  26. data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
  27. data/app/helpers/shiplog/head_helper.rb +1 -1
  28. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  29. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  30. data/app/helpers/shiplog/permalink_helper.rb +24 -5
  31. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  32. data/app/jobs/shiplog/announce_job.rb +30 -0
  33. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  34. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  35. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  36. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  37. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  38. data/app/models/shiplog/announcements.rb +34 -0
  39. data/app/models/shiplog/area_detector.rb +2 -1
  40. data/app/models/shiplog/change_candidates.rb +4 -0
  41. data/app/models/shiplog/drafter.rb +4 -2
  42. data/app/models/shiplog/entry.rb +76 -6
  43. data/app/models/shiplog/markdown.rb +233 -0
  44. data/app/models/shiplog/newsletter/content.rb +172 -0
  45. data/app/models/shiplog/newsletter/csv.rb +32 -0
  46. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  47. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  48. data/app/models/shiplog/newsletter.rb +180 -0
  49. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  50. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  51. data/app/models/shiplog/period.rb +129 -0
  52. data/app/models/shiplog/public_links.rb +47 -0
  53. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  54. data/app/models/shiplog/rewriter.rb +23 -3
  55. data/app/models/shiplog/translator/prompt.rb +2 -2
  56. data/app/models/shiplog/translator.rb +1 -1
  57. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  58. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  59. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  60. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  61. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  62. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  63. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  64. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  65. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  66. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  67. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  68. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  69. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  70. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  72. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  74. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  75. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  76. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  78. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  80. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  82. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  83. data/config/admin_routes.rb +12 -0
  84. data/config/locales/en.yml +147 -0
  85. data/config/locales/es.yml +147 -0
  86. data/config/routes.rb +13 -0
  87. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  88. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  89. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  90. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  91. data/lib/shiplog/classes.rb +14 -1
  92. data/lib/shiplog/configuration.rb +135 -3
  93. data/lib/shiplog/engine.rb +2 -0
  94. data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
  95. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  96. data/lib/shiplog/version.rb +1 -1
  97. data/lib/shiplog.rb +33 -0
  98. data/lib/tasks/shiplog.rake +22 -0
  99. metadata +57 -2
@@ -42,9 +42,11 @@ module Shiplog
42
42
 
43
43
  private
44
44
 
45
- # Every subject type (issue, pull request, commit) belongs to a repository; load them once per type.
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).each_value do |subjects|
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: /changelog.atom.
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.newest_first
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, :url, :entry, :image, :canonical), `description`,
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