bible270 1.11.1 → 1.12.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 +37 -2
- data/README.md +27 -1
- data/app/controllers/bible270/admin_controller.rb +11 -14
- data/app/controllers/bible270/profiles_controller.rb +5 -7
- data/app/helpers/bible270/plan_helper.rb +14 -8
- data/app/models/bible270/comment.rb +2 -2
- data/app/models/bible270/reader.rb +22 -10
- data/app/views/bible270/admin/show.html.erb +2 -1
- data/app/views/bible270/profiles/edit.html.erb +2 -1
- data/app/views/bible270/shared/_avatar_ui.html.erb +85 -0
- data/app/views/bible270/shared/_styles.html.erb +5 -0
- data/app/views/layouts/bible270/application.html.erb +1 -0
- data/lib/bible270/avatar_backfill.rb +93 -0
- data/lib/bible270/avatar_images.rb +159 -0
- data/lib/bible270/avatar_optimizer.rb +257 -0
- data/lib/bible270/avatars.rb +2 -2
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +2 -0
- data/lib/tasks/bible270_avatars.rake +21 -0
- metadata +34 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a699484a74364bfd2ca703e50a1ab634d9449691f9242acee726d2fbc5a27afc
|
|
4
|
+
data.tar.gz: 17711f1bf0c60d00bb7b1cb0545e218a808e93226da8627c10c16f20c3f4473a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66ed48de38122303a4fe50cfbdbf6251334ee9c67bb82be501a95c3c5d14fdcaa63f5356d4db1d7cf518307870fdc0e08b9045ea23e956a55f20ce7d46e771e9
|
|
7
|
+
data.tar.gz: 6230d31ac89feb6a6d830c9ca2009edcb266ca9886be87a57bc6b1fed378f0580d0e76ae5ec4c51353b3751b1a52c214eca5dbf016eddbeecaa740abed162626
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,41 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- load avatars eagerly without forced asynchronous decoding, while retaining attachment preloading and bounded database queries
|
|
10
|
+
- recover failed avatar requests with up to two automatic retries, refreshing same-origin uploaded-image redirects without modifying signed provider URLs
|
|
11
|
+
- show accessible initials instead of broken images when avatars fail, including cached failures and images inserted during navigation or conversations
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- automatically create 256×256, center-cropped static WebP avatars on Profile and Admin uploads, with orientation correction, color conversion, transparency preservation, and metadata stripping; animated images use their first frame
|
|
16
|
+
- preserve the current original upload separately and display the optimized thumbnail only after successful processing and storage, without request-time image processing
|
|
17
|
+
- validate actual image data and enforce a 40-megapixel limit in addition to the existing configurable upload byte limit (2 MiB by default)
|
|
18
|
+
- add `bible270:avatars:optimize` to backfill existing uploaded avatars, with `DRY_RUN=1`, `FORCE=1`, bounded batches, rerun-safe version tracking, and protection against concurrent picture changes
|
|
19
|
+
|
|
20
|
+
Avatar processing requires libvips with PNG, JPEG, GIF and WebP support on upload/backfill hosts; the gem now depends on `image_processing` and `ruby-vips`. No additional database migration is required for hosts already using Active Storage. Existing avatars continue displaying their originals until optimized; remote provider images are unchanged.
|
|
21
|
+
|
|
22
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.1...main)**
|
|
23
|
+
|
|
24
|
+
## [1.11.1] - 2026-10-22
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- simplify artwork on phone-sized screens: hide decorative scripture and word reflections, lower landscape scenes, and reduce fine detail while preserving desktop artwork
|
|
29
|
+
- further mute Ocean's mobile colors and wave lines while keeping the shoreline and gulls
|
|
30
|
+
- use a soft moonlight reflection on phones and retain opaque foreground trees in the simplified Forest scene
|
|
31
|
+
- draw Forest trees as direct SVG paths with explicit green fills instead of reused SVG instances
|
|
32
|
+
- increase the space between the app title and compact theme selector
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- declare the selected color scheme early in the page and synchronize root settings and metadata on theme changes and Turbo navigation
|
|
37
|
+
- request `color-scheme: only light` for Papyrus, Meadow, and Ocean while preserving System's device-following behavior
|
|
38
|
+
|
|
39
|
+
Browser website-darkening features can still override theme colors. In iOS Brave, disabling Night Mode resolved the reported darkened light themes and white Forest trees.
|
|
40
|
+
|
|
41
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.0...v1.11.1)**
|
|
8
42
|
|
|
9
43
|
## [1.11.0] - 2026-10-22
|
|
10
44
|
|
|
@@ -897,7 +931,8 @@ This is the initial public release.
|
|
|
897
931
|
|
|
898
932
|
* Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
|
|
899
933
|
|
|
900
|
-
[Unreleased]: https://github.com/avonderluft/bible270/compare/v1.11.
|
|
934
|
+
[Unreleased]: https://github.com/avonderluft/bible270/compare/v1.11.1...HEAD
|
|
935
|
+
[1.11.1]: https://github.com/avonderluft/bible270/releases/tag/v1.11.1
|
|
901
936
|
[1.11.0]: https://github.com/avonderluft/bible270/releases/tag/v1.11.0
|
|
902
937
|
[1.10.2]: https://github.com/avonderluft/bible270/releases/tag/v1.10.2
|
|
903
938
|
[1.10.1]: https://github.com/avonderluft/bible270/releases/tag/v1.10.1
|
data/README.md
CHANGED
|
@@ -21,14 +21,40 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
|
|
|
21
21
|
|
|
22
22
|
## Requirements
|
|
23
23
|
|
|
24
|
-
- **Ruby** >= 3.2.
|
|
24
|
+
- **Ruby** >= 3.2. Engine code is plain Ruby; avatar processing uses native libvips through `ruby-vips`. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
|
|
25
25
|
- **Rails** >= 7.2. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
|
|
26
26
|
- A host app, and Turbo if you want check-offs without a page reload.
|
|
27
|
+
- For picture uploads and avatar backfills: **libvips** with PNG, JPEG, GIF and WebP support. The gem supplies the `image_processing` and `ruby-vips` Ruby dependencies. Install libvips on every upload/backfill host (for example, `sudo apt-get install libvips-tools` on Ubuntu or `sudo pacman -S libvips` on Arch). Native processing is loaded only when optimizing an image; existing pages and provider avatars do not require it.
|
|
27
28
|
|
|
28
29
|
Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
|
|
29
30
|
forms remain the fallback. Repeated checkoff and like requests converge on the intended state rather
|
|
30
31
|
than accidentally reversing it.
|
|
31
32
|
|
|
33
|
+
## Avatar optimization
|
|
34
|
+
|
|
35
|
+
Profile and Admin picture uploads produce a **256×256 static WebP thumbnail at quality 85** before switching the displayed avatar. Images are auto-oriented, center-cropped, converted to sRGB, and stripped of metadata; transparency is preserved. Animated GIF/WebP uploads use the first frame. Small images are upscaled to the same dimensions. Invalid or corrupt image data is rejected, even if its filename or declared type looks valid.
|
|
36
|
+
|
|
37
|
+
Uploads are limited to **2 MiB by default** (`config.avatar_max_bytes`) and **40 million pixels** in the selected frame/container canvas. The pixel limit bounds dimensions, not total processor memory. A processing or storage failure leaves the previous picture and profile details unchanged.
|
|
38
|
+
|
|
39
|
+
The current original remains in the `avatar` attachment, including its original metadata; the display image is stored separately as `avatar_thumbnail`. This preserves the source for future reprocessing, not a history of past pictures. Removing/replacing a picture detaches the old pair; Active Storage's purge jobs clean up their files. Keep the host's Active Job worker running for that cleanup. Original retention increases storage use even though avatar downloads become smaller. No new database migration is needed if Active Storage is already installed.
|
|
40
|
+
|
|
41
|
+
Existing uploaded avatars continue displaying their originals until optimized. Run the following **from the host Rails application**, with its normal database/storage credentials and production environment when appropriate:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# Metadata-only preview: no image downloads or attachment changes
|
|
45
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize DRY_RUN=1
|
|
46
|
+
|
|
47
|
+
# Process existing uploaded reader avatars, sequentially in bounded batches
|
|
48
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize BATCH_SIZE=100
|
|
49
|
+
|
|
50
|
+
# Rebuild even current thumbnails from the retained originals
|
|
51
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize FORCE=1
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The task skips current thumbnails, protects concurrent avatar changes, continues after per-image failures, and exits nonzero if any fail. It prints reader IDs, counts, and original/optimized display-byte totals (not total storage savings). Correct failed files, limits, storage access, or processor dependencies and rerun. `BATCH_SIZE` defaults to 100 and must be between 1 and 1000. Dry-run checks eligibility only; it does not verify file availability or decodability. Take a normal database/storage backup before a production backfill. The task reads originals and writes thumbnails to the configured storage service; remote storage can incur request and transfer costs. It does **not** download remote sign-in-provider avatars or modify unrelated host attachments.
|
|
55
|
+
|
|
56
|
+
For host code that uploads reader pictures, use `reader.attach_avatar(upload)` or `Bible270::AvatarImages.update!(reader, attributes, upload: upload)` rather than attaching directly with Active Storage, which bypasses this processing pipeline. Uploads and backfills must run **outside a caller-owned database transaction**: the service owns the commit boundary so a later outer rollback cannot orphan already-uploaded files. Failed cleanup retains unattached blob records with their storage keys for retry; check logged blob IDs and include these in the host's unattached-blob maintenance.
|
|
57
|
+
|
|
32
58
|
## Install
|
|
33
59
|
|
|
34
60
|
```ruby
|
|
@@ -229,23 +229,20 @@ module Bible270
|
|
|
229
229
|
# Name and picture together, mirroring what a reader can change about
|
|
230
230
|
# themselves on their own profile.
|
|
231
231
|
def update_profile
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
232
|
+
attributes = {}
|
|
233
|
+
if params[:first_name].present? || params[:last_name].present?
|
|
234
|
+
attributes = Names.normalize(params[:first_name], params[:last_name])
|
|
235
|
+
unless attributes
|
|
236
|
+
return redirect_to admin_reader_path(@reader), alert: 'Needs both a first and last name.'
|
|
237
237
|
end
|
|
238
|
-
if problems.empty? && params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
|
|
239
|
-
problems << (@reader.errors[:avatar].first || 'a valid image')
|
|
240
|
-
end
|
|
241
|
-
raise ActiveRecord::Rollback if problems.any?
|
|
242
238
|
end
|
|
243
239
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
AvatarImages.update!(@reader, attributes, upload: params[:avatar].presence)
|
|
241
|
+
redirect_to admin_reader_path(@reader), notice: 'Updated.'
|
|
242
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
243
|
+
redirect_to admin_reader_path(@reader), alert: e.message
|
|
244
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
|
|
245
|
+
redirect_to admin_reader_path(@reader), alert: 'The profile could not be saved. Please try again.'
|
|
249
246
|
end
|
|
250
247
|
|
|
251
248
|
def update_theme
|
|
@@ -35,6 +35,10 @@ module Bible270
|
|
|
35
35
|
|
|
36
36
|
save_profile(names.merge(passage_source: source))
|
|
37
37
|
redirect_to profile_path, notice: 'Your profile has been updated.'
|
|
38
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
39
|
+
current_reader.attachment_changes.clear if Reader.avatar_uploads?
|
|
40
|
+
current_reader.reload
|
|
41
|
+
render_profile_error(e.message)
|
|
38
42
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
|
39
43
|
message = e.record ? e.record.errors.full_messages.to_sentence : nil
|
|
40
44
|
# Reload does not discard Active Storage's deferred attachment changes.
|
|
@@ -103,13 +107,7 @@ module Bible270
|
|
|
103
107
|
attributes[:daily_reminder_time] = @daily_reminder_time
|
|
104
108
|
end
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
current_reader.assign_attributes(attributes)
|
|
108
|
-
# Unlike attach, assignment never saves early. Active Storage persists the
|
|
109
|
-
# attachment with the reader and uploads only after a successful commit.
|
|
110
|
-
current_reader.avatar = params[:avatar] if params[:avatar].present?
|
|
111
|
-
current_reader.save!
|
|
112
|
-
end
|
|
110
|
+
AvatarImages.update!(current_reader, attributes, upload: params[:avatar].presence)
|
|
113
111
|
end
|
|
114
112
|
|
|
115
113
|
def render_profile_error(message)
|
|
@@ -154,19 +154,25 @@ module Bible270
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def b270_avatar_src(reader)
|
|
157
|
-
return main_app.rails_blob_path(reader.
|
|
157
|
+
return main_app.rails_blob_path(reader.display_avatar, only_path: true) if reader.avatar_uploaded?
|
|
158
158
|
|
|
159
159
|
reader.avatar_url.presence
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
def b270_avatar(reader, size: 72, loading: '
|
|
162
|
+
def b270_avatar(reader, size: 72, loading: 'eager')
|
|
163
163
|
src = b270_avatar_src(reader)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
dimensions = "width:#{size}px;height:#{size}px;line-height:#{size}px"
|
|
165
|
+
unless src
|
|
166
|
+
return content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
|
|
167
|
+
style: dimensions, role: 'img', 'aria-label': reader.display_name
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
content_tag :span, class: 'b270-avatar-frame', style: dimensions,
|
|
171
|
+
role: 'img', 'aria-label': reader.display_name do
|
|
172
|
+
image = image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
|
|
173
|
+
loading: loading, data: { b270_avatar: true, b270_avatar_uploaded: reader.avatar_uploaded? }
|
|
174
|
+
fallback = content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback', 'aria-hidden': true
|
|
175
|
+
safe_join([image, fallback])
|
|
170
176
|
end
|
|
171
177
|
end
|
|
172
178
|
|
|
@@ -53,10 +53,10 @@ module Bible270
|
|
|
53
53
|
# listing them again at the top level would show each twice.
|
|
54
54
|
scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
|
|
55
55
|
scope :with_reader_avatars, -> {
|
|
56
|
-
Reader.avatar_uploads? ? includes(reader:
|
|
56
|
+
Reader.avatar_uploads? ? includes(reader: Reader::AVATAR_ASSOCIATIONS) : includes(:reader)
|
|
57
57
|
}
|
|
58
58
|
scope :with_thread_details, -> {
|
|
59
|
-
reply_reader = Reader.avatar_uploads? ? { reader:
|
|
59
|
+
reply_reader = Reader.avatar_uploads? ? { reader: Reader::AVATAR_ASSOCIATIONS } : :reader
|
|
60
60
|
with_reader_avatars.includes({ likes: :reader }, { replies: [reply_reader, { likes: :reader }] })
|
|
61
61
|
}
|
|
62
62
|
scope :threads_for_day, ->(d) { for_day(d).with_thread_details }
|
|
@@ -15,11 +15,16 @@ module Bible270
|
|
|
15
15
|
# to keep working there — readers simply can't upload, and any avatar from
|
|
16
16
|
# their sign-in provider is used instead.
|
|
17
17
|
AVATAR_UPLOADS = respond_to?(:has_one_attached)
|
|
18
|
-
|
|
18
|
+
if AVATAR_UPLOADS
|
|
19
|
+
has_one_attached :avatar
|
|
20
|
+
has_one_attached :avatar_thumbnail
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
AVATAR_ASSOCIATIONS = { avatar_attachment: :blob, avatar_thumbnail_attachment: :blob }.freeze
|
|
19
24
|
|
|
20
25
|
def self.avatar_uploads? = AVATAR_UPLOADS
|
|
21
26
|
|
|
22
|
-
scope :with_avatar, -> { avatar_uploads? ? includes(
|
|
27
|
+
scope :with_avatar, -> { avatar_uploads? ? includes(AVATAR_ASSOCIATIONS) : all }
|
|
23
28
|
|
|
24
29
|
# Fires for every way a reader comes into existence — email sign-in, OmniAuth,
|
|
25
30
|
# or a bridged host user — rather than in each controller, so no path can
|
|
@@ -426,22 +431,29 @@ module Bible270
|
|
|
426
431
|
self.class.avatar_uploads? && avatar.attached?
|
|
427
432
|
end
|
|
428
433
|
|
|
434
|
+
def avatar_optimized?
|
|
435
|
+
avatar_uploaded? && avatar_thumbnail.attached? &&
|
|
436
|
+
avatar_thumbnail.blob.metadata['bible270_avatar_source_id'] == avatar.blob.id &&
|
|
437
|
+
avatar_thumbnail.blob.metadata['bible270_avatar_version'] == AvatarOptimizer::VERSION
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def display_avatar
|
|
441
|
+
avatar_optimized? ? avatar_thumbnail : avatar
|
|
442
|
+
end
|
|
443
|
+
|
|
429
444
|
# Returns false and sets an error when the upload isn't acceptable.
|
|
430
445
|
def attach_avatar(upload)
|
|
431
446
|
return false unless self.class.avatar_uploads?
|
|
432
447
|
|
|
433
|
-
|
|
434
|
-
if problem
|
|
435
|
-
errors.add(:avatar, problem)
|
|
436
|
-
return false
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
avatar.attach(upload)
|
|
448
|
+
AvatarImages.update!(self, {}, upload: upload)
|
|
440
449
|
true
|
|
450
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
451
|
+
errors.add(:avatar, e.message)
|
|
452
|
+
false
|
|
441
453
|
end
|
|
442
454
|
|
|
443
455
|
def remove_avatar!
|
|
444
|
-
|
|
456
|
+
AvatarImages.remove!(self)
|
|
445
457
|
true
|
|
446
458
|
end
|
|
447
459
|
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
<span>Picture</span>
|
|
24
24
|
<%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
|
|
25
25
|
<span class="b270-hint" style="margin:0">
|
|
26
|
-
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes)
|
|
26
|
+
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
|
|
27
|
+
Automatically center-cropped to a square. Animated pictures use the first frame.
|
|
27
28
|
</span>
|
|
28
29
|
</label>
|
|
29
30
|
<% if @reader.avatar_uploaded? %>
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
<span>Picture</span>
|
|
16
16
|
<%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
|
|
17
17
|
<span class="b270-hint" style="margin:0">
|
|
18
|
-
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes)
|
|
18
|
+
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
|
|
19
|
+
Automatically center-cropped to a square. Animated pictures use the first frame.
|
|
19
20
|
</span>
|
|
20
21
|
</label>
|
|
21
22
|
<% if current_reader.avatar_uploaded? %>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
2
|
+
(() => {
|
|
3
|
+
if (window.Bible270AvatarUI) {
|
|
4
|
+
window.Bible270AvatarUI.initialize();
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const selector = "img[data-b270-avatar]";
|
|
9
|
+
const states = new WeakMap();
|
|
10
|
+
const delays = [500, 1500];
|
|
11
|
+
|
|
12
|
+
function stateFor(image) {
|
|
13
|
+
const source = image.getAttribute("src");
|
|
14
|
+
let state = states.get(image);
|
|
15
|
+
if (!state || state.current !== source) {
|
|
16
|
+
if (state) clearTimeout(state.timer);
|
|
17
|
+
state = { original: source, current: source, attempts: 0, timer: null };
|
|
18
|
+
states.set(image, state);
|
|
19
|
+
}
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loaded(image) {
|
|
24
|
+
const state = stateFor(image);
|
|
25
|
+
clearTimeout(state.timer);
|
|
26
|
+
state.timer = null;
|
|
27
|
+
image.removeAttribute("data-b270-avatar-failed");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function failed(image) {
|
|
31
|
+
image.setAttribute("data-b270-avatar-failed", "");
|
|
32
|
+
const state = stateFor(image);
|
|
33
|
+
if (!state.original || state.timer !== null || state.attempts >= delays.length) return;
|
|
34
|
+
|
|
35
|
+
state.timer = setTimeout(() => {
|
|
36
|
+
state.timer = null;
|
|
37
|
+
if (!image.isConnected || image.getAttribute("src") !== state.current) return;
|
|
38
|
+
if (image.complete && image.naturalWidth > 0) {
|
|
39
|
+
loaded(image);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
state.attempts += 1;
|
|
43
|
+
let source = state.original;
|
|
44
|
+
// Refresh our blob redirect, which may have cached an expired storage URL.
|
|
45
|
+
// Never alter provider URLs: their query parameters may be signed.
|
|
46
|
+
const url = new URL(source, document.baseURI);
|
|
47
|
+
if (image.dataset.b270AvatarUploaded === "true" && url.origin === window.location.origin) {
|
|
48
|
+
url.searchParams.set("_b270_avatar_retry", `${Date.now()}-${state.attempts}`);
|
|
49
|
+
source = url.href;
|
|
50
|
+
}
|
|
51
|
+
state.current = source;
|
|
52
|
+
image.setAttribute("src", source);
|
|
53
|
+
}, delays[state.attempts]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function inspect(image) {
|
|
57
|
+
if (!image.complete) return;
|
|
58
|
+
if (image.naturalWidth > 0) loaded(image);
|
|
59
|
+
else failed(image);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initialize(root = document) {
|
|
63
|
+
if (root.matches?.(selector)) inspect(root);
|
|
64
|
+
root.querySelectorAll?.(selector).forEach(inspect);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Image events do not bubble. Capture also handles images inserted by replies
|
|
68
|
+
// and Turbo; the scan catches cached failures before this script was installed.
|
|
69
|
+
document.addEventListener("load", (event) => {
|
|
70
|
+
if (event.target.matches?.(selector)) loaded(event.target);
|
|
71
|
+
}, true);
|
|
72
|
+
document.addEventListener("error", (event) => {
|
|
73
|
+
if (event.target.matches?.(selector)) failed(event.target);
|
|
74
|
+
}, true);
|
|
75
|
+
document.addEventListener("turbo:load", () => initialize());
|
|
76
|
+
document.addEventListener("turbo:render", () => initialize());
|
|
77
|
+
window.addEventListener("pageshow", () => initialize());
|
|
78
|
+
new MutationObserver((records) => {
|
|
79
|
+
records.forEach((record) => record.addedNodes.forEach((node) => initialize(node)));
|
|
80
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
81
|
+
|
|
82
|
+
window.Bible270AvatarUI = { initialize };
|
|
83
|
+
initialize();
|
|
84
|
+
})();
|
|
85
|
+
</script>
|
|
@@ -424,6 +424,11 @@
|
|
|
424
424
|
.b270-completers{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;font-size:13px;color:var(--muted)}
|
|
425
425
|
.b270-avatar{border-radius:50%;object-fit:cover;display:inline-block;text-align:center;background:var(--teal-fill);color:var(--on-accent);font-size:12px;font-weight:600}
|
|
426
426
|
.b270-avatar-fallback{border-radius:50%}
|
|
427
|
+
.b270-avatar-frame{position:relative;display:inline-block;vertical-align:middle;flex-shrink:0}
|
|
428
|
+
.b270-avatar-frame>img{display:block}
|
|
429
|
+
.b270-avatar-frame>.b270-avatar-fallback{position:absolute;inset:0;visibility:hidden}
|
|
430
|
+
.b270-avatar-frame>img[data-b270-avatar-failed]{visibility:hidden}
|
|
431
|
+
.b270-avatar-frame>img[data-b270-avatar-failed]+.b270-avatar-fallback{visibility:visible}
|
|
427
432
|
|
|
428
433
|
/* comments */
|
|
429
434
|
.b270-section-h{font-family:"Spectral",serif;font-size:20px;font-weight:600;margin:34px 0 14px}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
<%= render 'bible270/shared/day_index', expanded: content_for?(:expand_day_index) unless content_for?(:hide_day_index) %>
|
|
34
34
|
<%= b270_footer %>
|
|
35
35
|
</div>
|
|
36
|
+
<%= render "bible270/shared/avatar_ui" %>
|
|
36
37
|
<%= render "bible270/shared/theme_ui" %>
|
|
37
38
|
<%= render "bible270/shared/scripture_popups" %>
|
|
38
39
|
<%= render "bible270/shared/video_embeds" %>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
class AvatarBackfill
|
|
5
|
+
DEFAULT_BATCH_SIZE = 100
|
|
6
|
+
MAX_BATCH_SIZE = 1000
|
|
7
|
+
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(dry_run: false, force: false, batch_size: DEFAULT_BATCH_SIZE, output: $stdout)
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
@force = force
|
|
13
|
+
@batch_size = validate_batch_size(batch_size)
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# processed counts successful optimizations, not all visited readers. Byte
|
|
18
|
+
# totals cover successful/current derivatives only, never failed or raced work.
|
|
19
|
+
def run
|
|
20
|
+
check_storage!
|
|
21
|
+
summary = { processed: 0, skipped: 0, changed: 0, failed: 0, eligible: 0,
|
|
22
|
+
original_bytes: 0, optimized_bytes: 0 }
|
|
23
|
+
|
|
24
|
+
Reader.with_avatar.joins(:avatar_attachment).find_each(batch_size: @batch_size) do |reader|
|
|
25
|
+
if @dry_run
|
|
26
|
+
eligible = @force || !reader.avatar_optimized?
|
|
27
|
+
summary[eligible ? :eligible : :skipped] += 1
|
|
28
|
+
@output.puts "reader_id=#{reader.id} #{eligible ? 'eligible' : 'skipped'}"
|
|
29
|
+
else
|
|
30
|
+
process(reader, summary)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if @dry_run
|
|
35
|
+
@output.puts "DRY RUN: eligible=#{summary[:eligible]} skipped=#{summary[:skipped]} " \
|
|
36
|
+
'processed=0 changed=0 failed=0'
|
|
37
|
+
else
|
|
38
|
+
@output.puts "processed=#{summary[:processed]} skipped=#{summary[:skipped]} " \
|
|
39
|
+
"changed=#{summary[:changed]} failed=#{summary[:failed]}"
|
|
40
|
+
@output.puts "Display bytes (optimized/skipped readers): original=#{summary[:original_bytes]} " \
|
|
41
|
+
"optimized=#{summary[:optimized_bytes]}. Originals retained; not total storage savings."
|
|
42
|
+
end
|
|
43
|
+
summary
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_batch_size(value)
|
|
49
|
+
valid = value.is_a?(Integer) || (value.is_a?(String) && value.match?(%r{\A[0-9]+\z}))
|
|
50
|
+
size = Integer(value, 10) if valid && value.is_a?(String)
|
|
51
|
+
size = value if value.is_a?(Integer)
|
|
52
|
+
return size if valid && size.between?(1, MAX_BATCH_SIZE)
|
|
53
|
+
|
|
54
|
+
raise Error, "BATCH_SIZE must be a positive integer between 1 and #{MAX_BATCH_SIZE}."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_storage!
|
|
58
|
+
return if Reader.avatar_uploads? && defined?(ActiveStorage::Blob) &&
|
|
59
|
+
defined?(ActiveStorage::Attachment) && ActiveStorage::Blob.table_exists? &&
|
|
60
|
+
ActiveStorage::Attachment.table_exists?
|
|
61
|
+
|
|
62
|
+
raise Error, 'Avatar backfill requires Active Storage. Enable active_storage/engine, ' \
|
|
63
|
+
'configure a storage service, and run bin/rails active_storage:install and bin/rails db:migrate in the host app.'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process(reader, summary)
|
|
67
|
+
result = AvatarImages.optimize_existing!(reader, force: @force)
|
|
68
|
+
case result.fetch(:status)
|
|
69
|
+
when :optimized, :skipped
|
|
70
|
+
summary[result[:status] == :optimized ? :processed : :skipped] += 1
|
|
71
|
+
summary[:original_bytes] += result.fetch(:before_bytes)
|
|
72
|
+
summary[:optimized_bytes] += result.fetch(:after_bytes)
|
|
73
|
+
when :changed
|
|
74
|
+
summary[:changed] += 1
|
|
75
|
+
else
|
|
76
|
+
raise Error, 'Avatar optimizer returned an unsupported status.'
|
|
77
|
+
end
|
|
78
|
+
@output.puts "reader_id=#{reader.id} #{result[:status]}"
|
|
79
|
+
rescue AvatarOptimizer::Error
|
|
80
|
+
record_failure(reader, summary, 'image processing failed; check the source image and libvips codec support')
|
|
81
|
+
rescue AvatarImages::Error
|
|
82
|
+
record_failure(reader, summary, 'storage failed; check storage service configuration and access')
|
|
83
|
+
rescue ActiveRecord::ActiveRecordError
|
|
84
|
+
record_failure(reader, summary, 'database failed; check database availability and record integrity')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def record_failure(reader, summary, reason)
|
|
88
|
+
summary[:failed] += 1
|
|
89
|
+
# Exception messages, causes and record inspections can expose private data.
|
|
90
|
+
@output.puts "reader_id=#{reader.id} failed: #{reason}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'avatar_optimizer'
|
|
4
|
+
|
|
5
|
+
module Bible270
|
|
6
|
+
# Originals and their display thumbnails are separate, immutable blobs. All
|
|
7
|
+
# storage work finishes before attachment changes become visible to readers.
|
|
8
|
+
class AvatarImages
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def update!(reader, attributes, upload: nil)
|
|
13
|
+
raise Error, 'Picture uploads require Active Storage.' if upload && !reader.class.avatar_uploads?
|
|
14
|
+
|
|
15
|
+
require_own_transaction!(reader) if upload
|
|
16
|
+
|
|
17
|
+
reader.assign_attributes(attributes)
|
|
18
|
+
raise ActiveRecord::RecordInvalid, reader unless reader.valid?
|
|
19
|
+
return reader.save! unless upload
|
|
20
|
+
|
|
21
|
+
blobs = []
|
|
22
|
+
AvatarOptimizer.open(upload.tempfile.path, content_type: upload.content_type, byte_size: upload.size) do |output|
|
|
23
|
+
# Do not let Active Storage decode the unsanitized original again in
|
|
24
|
+
# an analysis job. Validation/conversion already ran through our safe
|
|
25
|
+
# pipeline, and originals are retained for reprocessing, not variants.
|
|
26
|
+
original = store_blob(blobs, io: upload.tempfile, filename: upload.original_filename,
|
|
27
|
+
content_type: upload.content_type.to_s.downcase.split(';').first.to_s.strip,
|
|
28
|
+
metadata: { 'analyzed' => true })
|
|
29
|
+
thumbnail = store_thumbnail(blobs, output, original)
|
|
30
|
+
pending_attributes = reader.changes.transform_values(&:last)
|
|
31
|
+
reader.class.transaction do
|
|
32
|
+
# Reload under the row lock: cached (even nil) attachments may have
|
|
33
|
+
# changed during processing. Replacing a stale association can leave
|
|
34
|
+
# multiple attachments with the same has_one_attached name.
|
|
35
|
+
reader.class.lock.find(reader.id)
|
|
36
|
+
reader.attachment_changes.delete('avatar')
|
|
37
|
+
reader.attachment_changes.delete('avatar_thumbnail')
|
|
38
|
+
reader.reload
|
|
39
|
+
reader.assign_attributes(pending_attributes)
|
|
40
|
+
reader.avatar = original
|
|
41
|
+
reader.avatar_thumbnail = thumbnail
|
|
42
|
+
reader.save!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
discard_unattached(blobs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def optimize_existing!(reader, force: false)
|
|
50
|
+
return result(:skipped) unless reader.avatar_uploaded?
|
|
51
|
+
|
|
52
|
+
require_own_transaction!(reader)
|
|
53
|
+
original = reader.avatar.blob
|
|
54
|
+
if !force && reader.avatar_optimized?
|
|
55
|
+
return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
problem = Avatars.problem_with(content_type: original.content_type, byte_size: original.byte_size)
|
|
59
|
+
raise AvatarOptimizer::Error, "Avatar #{problem}." if problem
|
|
60
|
+
|
|
61
|
+
blobs = []
|
|
62
|
+
thumbnail = download_original(original) do |input|
|
|
63
|
+
AvatarOptimizer.open(input.path, content_type: original.content_type, byte_size: original.byte_size) do |output|
|
|
64
|
+
store_thumbnail(blobs, output, original)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
reader.with_lock do
|
|
68
|
+
unless reader.avatar_uploaded? && reader.avatar.blob.id == original.id
|
|
69
|
+
return result(:changed, original.byte_size)
|
|
70
|
+
end
|
|
71
|
+
if !force && reader.avatar_optimized?
|
|
72
|
+
return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
reader.avatar_thumbnail = thumbnail
|
|
76
|
+
reader.save!
|
|
77
|
+
end
|
|
78
|
+
result(:optimized, original.byte_size, thumbnail.byte_size)
|
|
79
|
+
ensure
|
|
80
|
+
discard_unattached(blobs)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def remove!(reader)
|
|
84
|
+
return unless reader.class.avatar_uploads?
|
|
85
|
+
|
|
86
|
+
# Detach atomically; Active Storage purges both old blobs after commit.
|
|
87
|
+
reader.with_lock do
|
|
88
|
+
reader.avatar = nil
|
|
89
|
+
reader.avatar_thumbnail = nil
|
|
90
|
+
reader.save!
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def require_own_transaction!(reader)
|
|
97
|
+
return unless [reader.class, ActiveStorage::Blob].any? do |model|
|
|
98
|
+
model.connection_pool.with_connection(&:transaction_open?)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Files cannot roll back with an outer database transaction. Reject that
|
|
102
|
+
# usage before creating blobs rather than leaking files on a later abort.
|
|
103
|
+
raise Error, 'Avatar uploads and optimization must run outside an existing database transaction.'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def download_original(original, &)
|
|
107
|
+
original.open(&)
|
|
108
|
+
rescue AvatarOptimizer::Error, Error
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
Rails.logger.error("Bible270 avatar download failed (#{e.class}).")
|
|
112
|
+
raise Error, 'The original picture could not be read. Check storage availability and file integrity.'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def result(status, before_bytes = 0, after_bytes = 0)
|
|
116
|
+
{ status: status, before_bytes: before_bytes, after_bytes: after_bytes }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def store_thumbnail(blobs, output, original)
|
|
120
|
+
store_blob(blobs, io: output, filename: 'avatar.webp', content_type: 'image/webp',
|
|
121
|
+
service_name: original.service_name,
|
|
122
|
+
metadata: { 'bible270_avatar_version' => AvatarOptimizer::VERSION,
|
|
123
|
+
'bible270_avatar_source_id' => original.id,
|
|
124
|
+
'width' => AvatarOptimizer::SIZE, 'height' => AvatarOptimizer::SIZE,
|
|
125
|
+
'analyzed' => true })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def store_blob(blobs, **attributes)
|
|
129
|
+
attributes.fetch(:io).rewind
|
|
130
|
+
# Register the blob before uploading so a partial storage failure can be
|
|
131
|
+
# cleaned up too. create_and_upload! would not return that failed blob.
|
|
132
|
+
blob = ActiveStorage::Blob.create_after_unfurling!(**attributes, identify: false)
|
|
133
|
+
blobs << blob
|
|
134
|
+
blob.upload_without_unfurling(attributes.fetch(:io))
|
|
135
|
+
blob
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Rails.logger.error("Bible270 avatar storage failed (#{e.class}).")
|
|
138
|
+
raise Error, 'Your picture could not be stored. Please try again.'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def discard_unattached(blobs)
|
|
142
|
+
Array(blobs).each do |blob|
|
|
143
|
+
# An after-commit callback may raise after a successful save. Never
|
|
144
|
+
# remove blobs that have already become somebody's live attachment.
|
|
145
|
+
blob.with_lock do
|
|
146
|
+
next if blob.attachments.exists?
|
|
147
|
+
|
|
148
|
+
# Unlike purge, delete storage first. If deletion fails, retain the
|
|
149
|
+
# unattached row (service/key) so maintenance can retry cleanup.
|
|
150
|
+
blob.delete
|
|
151
|
+
blob.destroy!
|
|
152
|
+
end
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
Rails.logger.error("Bible270 unused avatar blob #{blob.id} cleanup failed (#{e.class}); retry unattached-blob cleanup.")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
require_relative 'configuration'
|
|
6
|
+
|
|
7
|
+
module Bible270
|
|
8
|
+
# File-only avatar processing; no Rails, Active Storage, or original-file writes.
|
|
9
|
+
class AvatarOptimizer
|
|
10
|
+
VERSION = '1'
|
|
11
|
+
SIZE = 256
|
|
12
|
+
# Bounds the first frame's decoded pixels before materializing it. This is not
|
|
13
|
+
# a process-memory cap: channels, bit depth, and working buffers also use RAM.
|
|
14
|
+
MAX_PIXELS = 40_000_000
|
|
15
|
+
# PNG text is discarded without inflation. PNG permits only one iCCP chunk,
|
|
16
|
+
# so rejecting duplicates makes this an aggregate expanded ICC limit too.
|
|
17
|
+
# This bounds that metadata attack, not total Ruby/native process memory.
|
|
18
|
+
MAX_PNG_ICC_BYTES = 4 * 1024 * 1024
|
|
19
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
|
|
20
|
+
private_constant :PNG_SIGNATURE
|
|
21
|
+
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
DECODERS = {
|
|
25
|
+
'VipsForeignLoadPngBuffer' => 'image/png',
|
|
26
|
+
'VipsForeignLoadJpegBuffer' => 'image/jpeg',
|
|
27
|
+
'VipsForeignLoadNsgifBuffer' => 'image/gif',
|
|
28
|
+
'VipsForeignLoadWebpBuffer' => 'image/webp'
|
|
29
|
+
}.freeze
|
|
30
|
+
private_constant :DECODERS
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Yields a readable, rewound WebP Tempfile and returns the block's result.
|
|
34
|
+
# The output is closed/unlinked even on exceptions or non-local block exits.
|
|
35
|
+
# Input must remain stable while being read; originals remain caller-owned.
|
|
36
|
+
def open(path, content_type:, byte_size:)
|
|
37
|
+
raise ArgumentError, 'AvatarOptimizer.open requires a block' unless block_given?
|
|
38
|
+
|
|
39
|
+
type = content_type.to_s.downcase.split(';').first.to_s.strip
|
|
40
|
+
data = read_source(path, type, byte_size)
|
|
41
|
+
data = sanitize_png(data) if data.start_with?(PNG_SIGNATURE)
|
|
42
|
+
load_backend!
|
|
43
|
+
output = new_output
|
|
44
|
+
write_output(data, type, output.path)
|
|
45
|
+
output.rewind
|
|
46
|
+
yield output
|
|
47
|
+
ensure
|
|
48
|
+
output&.close!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def read_source(path, content_type, byte_size)
|
|
54
|
+
unless Avatars.acceptable_type?(content_type)
|
|
55
|
+
raise Error, 'Avatar must be a PNG, JPEG, GIF or WebP image.'
|
|
56
|
+
end
|
|
57
|
+
unless byte_size.is_a?(Integer) && byte_size.positive?
|
|
58
|
+
raise Error, 'Avatar byte size must be a positive integer; upload the file again.'
|
|
59
|
+
end
|
|
60
|
+
raise Error, 'Avatar must be a readable regular file; upload it again.' unless File.file?(path)
|
|
61
|
+
|
|
62
|
+
limit = Avatars.max_bytes
|
|
63
|
+
size = File.size(path)
|
|
64
|
+
if size > limit || byte_size > limit
|
|
65
|
+
raise Error, "Avatar must be no larger than #{Avatars.human_size(limit)}."
|
|
66
|
+
end
|
|
67
|
+
if size.zero? || size != byte_size
|
|
68
|
+
raise Error, 'Avatar file size does not match the upload; upload it again.'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A bounded snapshot avoids path-option injection, decoder TOCTOU, and
|
|
72
|
+
# unbounded reads if a file grows between stat and read.
|
|
73
|
+
data = File.binread(path, limit + 1)
|
|
74
|
+
unless data.bytesize == size
|
|
75
|
+
raise Error, 'Avatar file changed while being read; upload it again.'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
data
|
|
79
|
+
rescue SystemCallError, IOError, ArgumentError, TypeError
|
|
80
|
+
raise Error, 'Avatar file could not be read; upload a readable file again.'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sanitize_png(data)
|
|
84
|
+
sanitized = PNG_SIGNATURE.dup
|
|
85
|
+
seen = {}
|
|
86
|
+
image_data_ended = false
|
|
87
|
+
each_png_chunk(data) do |type, payload, encoded|
|
|
88
|
+
invalid_png! unless seen['IHDR'] || type == 'IHDR'
|
|
89
|
+
image_data_ended = true if seen['IDAT'] && type != 'IDAT'
|
|
90
|
+
invalid_png! if type == 'IDAT' && image_data_ended
|
|
91
|
+
check_png_chunk(type, payload, seen)
|
|
92
|
+
seen[type] = true if %w[IHDR PLTE IDAT iCCP eXIf].include?(type)
|
|
93
|
+
# Keep eXIf and bounded iCCP for orientation/colour conversion. All
|
|
94
|
+
# text (including compressed XMP/raw profiles) is unnecessary here.
|
|
95
|
+
sanitized << encoded unless %w[tEXt zTXt iTXt].include?(type)
|
|
96
|
+
end
|
|
97
|
+
sanitized
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def check_png_chunk(type, payload, seen)
|
|
101
|
+
invalid_png! if %w[IHDR PLTE iCCP eXIf].include?(type) && seen[type]
|
|
102
|
+
case type
|
|
103
|
+
when 'IHDR'
|
|
104
|
+
invalid_png! unless payload.bytesize == 13
|
|
105
|
+
check_dimensions(*payload.unpack('N2'))
|
|
106
|
+
when 'PLTE'
|
|
107
|
+
invalid_png! if seen['IDAT']
|
|
108
|
+
when 'iCCP'
|
|
109
|
+
invalid_png! if seen['PLTE'] || seen['IDAT']
|
|
110
|
+
check_png_profile(payload)
|
|
111
|
+
when 'IEND'
|
|
112
|
+
invalid_png! unless seen['IDAT'] && payload.empty?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def each_png_chunk(data)
|
|
117
|
+
offset = PNG_SIGNATURE.bytesize
|
|
118
|
+
while offset < data.bytesize
|
|
119
|
+
invalid_png! if data.bytesize - offset < 12
|
|
120
|
+
length = data.byteslice(offset, 4).unpack1('N')
|
|
121
|
+
invalid_png! if length > 0x7fffffff || length > data.bytesize - offset - 12
|
|
122
|
+
type = data.byteslice(offset + 4, 4)
|
|
123
|
+
invalid_png! unless type.match?(%r{\A[A-Za-z]{2}[A-Z][A-Za-z]\z}n)
|
|
124
|
+
payload = data.byteslice(offset + 8, length)
|
|
125
|
+
crc = data.byteslice(offset + 8 + length, 4).unpack1('N')
|
|
126
|
+
invalid_png! unless Zlib.crc32(payload, Zlib.crc32(type)) == crc
|
|
127
|
+
|
|
128
|
+
yield type, payload, data.byteslice(offset, length + 12)
|
|
129
|
+
offset += length + 12
|
|
130
|
+
if type == 'IEND'
|
|
131
|
+
invalid_png! unless offset == data.bytesize
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
invalid_png! # Missing IEND, including an incomplete final chunk.
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def png_profile_data(payload)
|
|
139
|
+
separator = payload.index("\0")
|
|
140
|
+
invalid_png! unless separator && separator.between?(1, 79)
|
|
141
|
+
name = payload.byteslice(0, separator)
|
|
142
|
+
unless name.match?(%r{\A[\x20-\x7e\xa1-\xff]+\z}n) &&
|
|
143
|
+
!name.start_with?(' ') && !name.end_with?(' ') && !name.include?(' ')
|
|
144
|
+
invalid_png!
|
|
145
|
+
end
|
|
146
|
+
invalid_png! unless payload.getbyte(separator + 1)&.zero?
|
|
147
|
+
compressed = payload.byteslice((separator + 2)..)
|
|
148
|
+
invalid_png! if compressed.nil? || compressed.empty?
|
|
149
|
+
compressed
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def check_png_profile(payload)
|
|
153
|
+
compressed = png_profile_data(payload)
|
|
154
|
+
inflater = Zlib::Inflate.new
|
|
155
|
+
expanded = 0
|
|
156
|
+
offset = 0
|
|
157
|
+
while offset < compressed.bytesize && !inflater.finished?
|
|
158
|
+
part = compressed.byteslice(offset, 1024)
|
|
159
|
+
# Block-form inflate yields small output buffers instead of allocating
|
|
160
|
+
# the whole expanded profile. Never use inflate/finish without a block.
|
|
161
|
+
inflater.inflate(part) do |block|
|
|
162
|
+
expanded += block.bytesize
|
|
163
|
+
if expanded > MAX_PNG_ICC_BYTES
|
|
164
|
+
raise Error, 'Avatar PNG colour profile exceeds the 4 MiB expanded limit; re-export it with a smaller profile.'
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
offset += part.bytesize
|
|
168
|
+
end
|
|
169
|
+
# Reject truncation, concatenated streams, trailing junk and empty profiles;
|
|
170
|
+
# otherwise the native decoder might interpret different bytes than we did.
|
|
171
|
+
invalid_png! unless inflater.finished? && inflater.total_in == compressed.bytesize && expanded.positive?
|
|
172
|
+
rescue Zlib::Error
|
|
173
|
+
invalid_png!
|
|
174
|
+
ensure
|
|
175
|
+
inflater&.close
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def invalid_png!
|
|
179
|
+
raise Error, 'Avatar PNG has invalid chunk structure, checksums or colour metadata; re-export it and upload it again.'
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def load_backend!
|
|
183
|
+
require 'image_processing/vips'
|
|
184
|
+
rescue LoadError, StandardError
|
|
185
|
+
raise Error, 'Avatar processing is unavailable. Ask the administrator to install image_processing, ruby-vips and libvips with PNG, JPEG, GIF and WebP support.'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def new_output
|
|
189
|
+
Tempfile.new(['bible270-avatar-', '.webp'], binmode: true)
|
|
190
|
+
rescue SystemCallError, IOError
|
|
191
|
+
raise Error, 'Avatar output could not be created. Ask the administrator to check temporary storage.'
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def write_output(data, content_type, destination)
|
|
195
|
+
decoder = Vips.vips_foreign_find_load_buffer(data, data.bytesize)
|
|
196
|
+
actual_type = DECODERS[decoder]
|
|
197
|
+
unless actual_type
|
|
198
|
+
raise Error, 'Avatar data must decode as PNG, JPEG, GIF or WebP; re-export the image and upload it again.'
|
|
199
|
+
end
|
|
200
|
+
unless actual_type == content_type
|
|
201
|
+
raise Error, 'Avatar format does not match its content type; re-export the image and upload it again.'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
check_canvas_dimensions(data, actual_type)
|
|
205
|
+
options = { access: :sequential, fail_on: :warning }
|
|
206
|
+
options.merge!(page: 0, n: 1) if %w[image/gif image/webp].include?(actual_type)
|
|
207
|
+
image = Vips::Image.new_from_buffer(data, '', **options)
|
|
208
|
+
check_dimensions(image.width, image.height)
|
|
209
|
+
|
|
210
|
+
# libvips is lazy: materialize the entire selected frame so corruption
|
|
211
|
+
# outside the eventual crop is not silently accepted.
|
|
212
|
+
# ruby-vips 2.3's copy_memory wraps NULL without checking it on decode
|
|
213
|
+
# failure. Check the native result before constructing/using an Image.
|
|
214
|
+
decoded = Vips.vips_image_copy_memory(image)
|
|
215
|
+
raise Vips::Error if decoded.null?
|
|
216
|
+
|
|
217
|
+
image = Vips::Image.new(decoded).autorot
|
|
218
|
+
image = image.icc_transform('srgb', embedded: true) if image.get_typeof('icc-profile-data').positive?
|
|
219
|
+
image = image.colourspace(:srgb)
|
|
220
|
+
# Page-height survives first-frame loading; do not let the saver interpret
|
|
221
|
+
# the resized image as a new animation. Remaining metadata is stripped on
|
|
222
|
+
# save, after orientation and embedded-profile conversion have been applied.
|
|
223
|
+
image.remove('page-height')
|
|
224
|
+
ImageProcessing::Vips.source(image)
|
|
225
|
+
.resize_to_fill(SIZE, SIZE, sharpen: false)
|
|
226
|
+
.convert('webp')
|
|
227
|
+
.saver(quality: 85, strip: true)
|
|
228
|
+
.call(destination: destination)
|
|
229
|
+
rescue Error
|
|
230
|
+
raise
|
|
231
|
+
rescue StandardError
|
|
232
|
+
# Native errors can contain private paths and uploaded metadata. Never
|
|
233
|
+
# expose them, and keep this rescue outside the caller's block.
|
|
234
|
+
raise Error, 'Avatar could not be decoded or converted. Re-export a valid PNG, JPEG, GIF or WebP; if this persists, ask the administrator to check libvips codec and colour-profile support.'
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def check_dimensions(width, height)
|
|
238
|
+
return if width.positive? && height.positive? && width * height <= MAX_PIXELS
|
|
239
|
+
|
|
240
|
+
raise Error, "Avatar exceeds the #{MAX_PIXELS / 1_000_000}-megapixel limit; resize it before uploading."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def check_canvas_dimensions(data, type)
|
|
244
|
+
# Check container canvases as well as the decoder's frame dimensions.
|
|
245
|
+
# GIF decoders may ignore an oversized logical screen, while WebP may
|
|
246
|
+
# reject a canvas/frame mismatch before exposing its header dimensions.
|
|
247
|
+
if type == 'image/gif' && data.bytesize >= 10
|
|
248
|
+
check_dimensions(*data.byteslice(6, 4).unpack('v2'))
|
|
249
|
+
elsif type == 'image/webp' && data.bytesize >= 30 && data.byteslice(12, 4) == 'VP8X'
|
|
250
|
+
width = "#{data.byteslice(24, 3)}\0".unpack1('V') + 1
|
|
251
|
+
height = "#{data.byteslice(27, 3)}\0".unpack1('V') + 1
|
|
252
|
+
check_dimensions(width, height)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/bible270/avatars.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Bible270
|
|
|
7
7
|
module Avatars
|
|
8
8
|
CONTENT_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Original-upload byte limit. AvatarOptimizer separately limits decoded
|
|
11
|
+
# dimensions and produces the smaller display image; originals are retained.
|
|
12
12
|
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
|
|
13
13
|
|
|
14
14
|
module_function
|
data/lib/bible270/version.rb
CHANGED
data/lib/bible270.rb
CHANGED
|
@@ -9,6 +9,8 @@ require 'bible270/names'
|
|
|
9
9
|
require 'bible270/mentions'
|
|
10
10
|
require 'bible270/comment_formatter'
|
|
11
11
|
require 'bible270/avatars'
|
|
12
|
+
require 'bible270/avatar_images'
|
|
13
|
+
require 'bible270/avatar_backfill'
|
|
12
14
|
require 'bible270/translations'
|
|
13
15
|
require 'bible270/favicon'
|
|
14
16
|
require 'bible270/configuration'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bible270/avatar_backfill'
|
|
4
|
+
|
|
5
|
+
namespace :bible270 do
|
|
6
|
+
namespace :avatars do
|
|
7
|
+
desc 'Optimize uploaded reader avatars (DRY_RUN=1, FORCE=1, BATCH_SIZE=100; maximum 1000)'
|
|
8
|
+
task optimize: :environment do
|
|
9
|
+
summary = Bible270::AvatarBackfill.new(
|
|
10
|
+
dry_run: ENV['DRY_RUN'] == '1',
|
|
11
|
+
force: ENV['FORCE'] == '1',
|
|
12
|
+
batch_size: ENV.fetch('BATCH_SIZE', Bible270::AvatarBackfill::DEFAULT_BATCH_SIZE)
|
|
13
|
+
).run
|
|
14
|
+
if summary[:failed].positive?
|
|
15
|
+
raise Bible270::AvatarBackfill::Error,
|
|
16
|
+
"Avatar backfill failed for #{summary[:failed]} reader(s). Review the reader IDs and failure categories above, correct the problem, and rerun.",
|
|
17
|
+
cause: nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bible270
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -9,6 +9,20 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: image_processing
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.14'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.14'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: json
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,6 +71,20 @@ dependencies:
|
|
|
57
71
|
- - ">="
|
|
58
72
|
- !ruby/object:Gem::Version
|
|
59
73
|
version: '7.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: ruby-vips
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '2.3'
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '2.3'
|
|
60
88
|
- !ruby/object:Gem::Dependency
|
|
61
89
|
name: turbo-rails
|
|
62
90
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,6 +200,7 @@ files:
|
|
|
172
200
|
- app/views/bible270/readers/progress.html.erb
|
|
173
201
|
- app/views/bible270/readers/show.html.erb
|
|
174
202
|
- app/views/bible270/sessions/new.html.erb
|
|
203
|
+
- app/views/bible270/shared/_avatar_ui.html.erb
|
|
175
204
|
- app/views/bible270/shared/_comment_drafts.html.erb
|
|
176
205
|
- app/views/bible270/shared/_comment_notification_choices.html.erb
|
|
177
206
|
- app/views/bible270/shared/_day_completion_dove.html.erb
|
|
@@ -221,6 +250,9 @@ files:
|
|
|
221
250
|
- db/migrate/20260101000022_change_all_comment_notices_default_to_true.rb
|
|
222
251
|
- db/migrate/20260101000023_add_display_mode_to_bible270_readers.rb
|
|
223
252
|
- lib/bible270.rb
|
|
253
|
+
- lib/bible270/avatar_backfill.rb
|
|
254
|
+
- lib/bible270/avatar_images.rb
|
|
255
|
+
- lib/bible270/avatar_optimizer.rb
|
|
224
256
|
- lib/bible270/avatars.rb
|
|
225
257
|
- lib/bible270/comment_formatter.rb
|
|
226
258
|
- lib/bible270/configuration.rb
|
|
@@ -240,6 +272,7 @@ files:
|
|
|
240
272
|
- lib/generators/bible270/install/install_generator.rb
|
|
241
273
|
- lib/generators/bible270/install/templates/bible270.rb.tt
|
|
242
274
|
- lib/generators/bible270/install/templates/omniauth.rb.tt
|
|
275
|
+
- lib/tasks/bible270_avatars.rake
|
|
243
276
|
- lib/tasks/bible270_migrations.rake
|
|
244
277
|
- lib/tasks/bible270_notifications.rake
|
|
245
278
|
- lib/tasks/bible270_readers.rake
|