Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
168 commits
Select commit Hold shift + click to select a range
204a52b
Add redesigned registration flow at /register
sethherr Jul 13, 2026
9b47fe7
Use new registration_flow origin for /register b_params
sethherr Jul 13, 2026
3c06796
Convert register partials to Registrations::New components
sethherr Jul 13, 2026
5c8b70d
Use Form::Combobox for the /register manufacturer field
sethherr Jul 13, 2026
1322740
Use existing serials/ images in the register serial panel
sethherr Jul 13, 2026
2aac86d
Move register Stimulus controllers to registrations/new/
sethherr Jul 13, 2026
0501395
Remove the e-vehicle attestation step from /register
sethherr Jul 13, 2026
d1ab9ad
Finish anonymous registrations via an email confirmation link
sethherr Jul 13, 2026
fc49fe8
Match the design colors on /register: purple accents, gray full-bleed…
sethherr Jul 13, 2026
c4af8da
Use the envelope icon in the register details alert
sethherr Jul 13, 2026
d4cb46f
UI::Alert icon: takes rendered markup instead of an asset path
sethherr Jul 13, 2026
6a975e1
Always show the check icon on register complete, matching the design
sethherr Jul 14, 2026
54a73d5
Match the design's optional badge: muted gray pill, 11px semibold
sethherr Jul 14, 2026
3395b66
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 14, 2026
dd0c435
Purple confirmation alert + muted From step 1 badge on /register details
sethherr Jul 14, 2026
e211467
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 14, 2026
e672aa9
Remove the required asterisk from the register email label
sethherr Jul 14, 2026
d99974b
Purple Where's-my-serial link, matching the design
sethherr Jul 14, 2026
5eb6424
Add additional color rows to /register details
sethherr Jul 14, 2026
e6cc14a
Step 1 vehicle-type select with embed-style propulsion reveal; model …
sethherr Jul 14, 2026
4be3b68
Fold the muted badge style into gray
sethherr Jul 14, 2026
a0b7230
Remove the propulsion type checkboxes from /register
sethherr Jul 14, 2026
395e454
Reword the step 1 confirmation-link note
sethherr Jul 14, 2026
f36101d
Vehicle type combobox with muted parentheticals
sethherr Jul 14, 2026
13d3fcf
Frame size unit dropdown: inches or cm
sethherr Jul 14, 2026
d7d9d1a
Show the selected vehicle type's parenthetical in muted text
sethherr Jul 14, 2026
82bdb9e
Route the registration flow as resource :register
sethherr Jul 14, 2026
06867c5
Render the step-1 email as a p styled like an input
sethherr Jul 14, 2026
4c7a99b
Match the updated registration flow design
sethherr Jul 15, 2026
abad074
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 15, 2026
b2144cd
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 15, 2026
0bff05a
Use Form::ComboboxManufacturer for the register manufacturer field
sethherr Jul 15, 2026
3be92a9
Simplify pass on the registration flow
sethherr Jul 15, 2026
7bde7c6
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 15, 2026
7d6458d
Match bikes/new serial handling: Missing serial + made-without-serial…
sethherr Jul 15, 2026
7c50689
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 15, 2026
721b840
Keep the register manufacturer combobox to frame makers
sethherr Jul 15, 2026
6581eea
Restyle UI::Header to the redesign heading and use it in the register…
sethherr Jul 20, 2026
6b82382
Use UI::Header on the register complete page
sethherr Jul 20, 2026
ae8fd30
Move the register views' content into page-level view components
sethherr Jul 20, 2026
5041925
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 20, 2026
0f9cc49
Use the UI::Forms combobox namespace in the register flow
sethherr Jul 20, 2026
b6c0a7a
Replace the registration prototype with the latest single-file version
sethherr Jul 20, 2026
b7a51b1
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 23, 2026
b01d17f
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 25, 2026
848d0e9
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 26, 2026
d82ac14
Update register-your-bike design prototype (e-vehicle acknowledgment …
sethherr Jul 26, 2026
dd05ec7
Merge branch 'main' into sethherr/new-registration-flow
sethherr Jul 26, 2026
ebfc71c
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 27, 2026
277eb0d
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 27, 2026
bfcfad7
Use UI::Forms components for the register flow fields (#3885)
sethherr Jul 27, 2026
b52a4c0
Render register components from the controller
sethherr Jul 27, 2026
f5fc3c8
Collapse register details/confirm/complete into show
sethherr Jul 27, 2026
d13b3f2
Move register flow components and controllers under register/
sethherr Jul 27, 2026
0a79c17
Center UI::Button content horizontally
sethherr Jul 27, 2026
92952ff
Require manufacturer and vehicle type on the register start form
sethherr Jul 27, 2026
e4c8331
Report the blank register email as a base error
sethherr Jul 27, 2026
b097c92
Persist register details drafts across reloads
sethherr Jul 27, 2026
9d9dc64
Disable register submit buttons with a spinner while submitting
sethherr Jul 27, 2026
c4486d3
Carry the b_param token through tokenized step_1/step_2 urls
sethherr Jul 27, 2026
34417a3
Clean stale never-submitted registrations after 3 days
sethherr Jul 27, 2026
7122c8e
Move the submit spinner onto the button itself
sethherr Jul 27, 2026
7ad6006
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 27, 2026
5183df0
Wrap the start form comboboxes in UI::Forms::Group
sethherr Jul 27, 2026
58989a6
Add ActiveStorage/R2 backend for PublicImage alongside CarrierWave
sethherr Jul 28, 2026
446c1a1
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 28, 2026
bed25fa
Drop the dead activestorage branch from local_file?
sethherr Jul 28, 2026
c15c6c9
Merge branch 'sethherr/keep-small-300x300' into direct-upload-actives…
sethherr Jul 28, 2026
0f7e42d
Correct the track_variants tradeoff, normalize image_url size
sethherr Jul 28, 2026
e38b944
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 28, 2026
4a1138c
Upload the register step_2 photo direct to storage
sethherr Jul 28, 2026
3b4235f
Prefill the async manufacturer combobox from the form object
sethherr Jul 28, 2026
f20e306
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 28, 2026
e571321
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 28, 2026
7fbd8ca
Reap unattached ActiveStorage blobs after 30 days
sethherr Jul 28, 2026
7cf6707
Let ProcessPublicImageJob own the blob analysis
sethherr Jul 28, 2026
bc3c947
Simplify the register flow after review
sethherr Jul 28, 2026
ad5ba0f
Lock down the direct uploads endpoint
sethherr Jul 28, 2026
8eb8895
Use theme purple for the progress bar fill
sethherr Jul 28, 2026
2275ca3
Trim the duplicated AnalyzeJob rationale to one place
sethherr Jul 28, 2026
29f51dd
Merge remote-tracking branch 'origin/sethherr/new-registration-flow' …
sethherr Jul 28, 2026
8e9ea6f
Split the direct upload endpoints by how they authorize
sethherr Jul 28, 2026
a104f0a
Added R2 keys to VCR ignore
sethherr Jul 28, 2026
fba588a
Collapse the step routes into show ?step params
sethherr Jul 28, 2026
2e667f4
Rename the step components to Register::Step1 and Register::Step2
sethherr Jul 28, 2026
93c50cc
Cover EXIF stripping end to end with a real iPhone HEIC
sethherr Jul 28, 2026
9e77521
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 28, 2026
384dfe9
Permit HEIC uploads through the activestorage path
sethherr Jul 28, 2026
b69c5cd
Move the register integration spec under registration/
sethherr Jul 28, 2026
736d2b2
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 28, 2026
d3938f1
Use the ui--collapse identifier in the register serial section
sethherr Jul 28, 2026
848f540
Simplify the step router after review
sethherr Jul 28, 2026
6ac0b97
Fold the two-tone display into UI::Forms::Combobox as rich_display:
sethherr Jul 28, 2026
d8b65d1
Upload to R2 for real in the register integration spec
sethherr Jul 28, 2026
aaf80da
Extract the R2 storage swap into a shared context
sethherr Jul 28, 2026
da03da5
Merge remote-tracking branch 'origin/sethherr/new-registration-flow' …
sethherr Jul 28, 2026
a125990
Point start_registration at the ?step= urls
sethherr Jul 28, 2026
049b70f
Point the test R2 bucket at the specs that were using dev's
sethherr Jul 28, 2026
2a70659
Install libheif's HEVC encoder in the CI and review-app images
sethherr Jul 28, 2026
daa43b4
Drop the duplicate submitting button preview
sethherr Jul 28, 2026
9aeab8a
Register flow: finished step, service extraction, and step 2 polish
sethherr Jul 28, 2026
3e65866
Mark images processed only once the variants exist, convert HEIC to webp
sethherr Jul 28, 2026
6fc8fdf
Convert TIFF to webp alongside HEIC
sethherr Jul 28, 2026
d1b550c
Simplify the register service after review
sethherr Jul 28, 2026
b4f1ca7
Derive the attached fixture's content type in the factory
sethherr Jul 28, 2026
33a2f3a
Restore the bike_book cassettes swept into the previous commit
sethherr Jul 28, 2026
f010171
Merge remote-tracking branch 'origin/sethherr/new-registration-flow' …
sethherr Jul 28, 2026
9421215
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 28, 2026
9bd6693
Revert the libheif HEVC encoder from the images
sethherr Jul 28, 2026
ef988c9
Pull the shared UI updates out of the register flow branch
sethherr Jul 28, 2026
867ff02
Submit the form when enter closes nothing in a combobox
sethherr Jul 28, 2026
659d7f0
Render the alert's info icon from icons/info.svg
sethherr Jul 28, 2026
8e3dea1
Register flow: organization support, bikes/new field parity, and polish
sethherr Jul 28, 2026
c2cd537
Stack the combobox rich display, add the autofocus controller
sethherr Jul 28, 2026
538c445
Show the combobox rich display after a small-viewport selection
sethherr Jul 28, 2026
112b417
Restore a persisted combobox draft over the rendered selection
sethherr Jul 28, 2026
ba806dd
Register flow: keep the registration type across a reload, and review…
sethherr Jul 28, 2026
44fa449
Merge remote-tracking branch 'origin/sethherr/pull-ui-skills-updates'…
sethherr Jul 28, 2026
5844fb9
Repaint the combobox overlay only when what it shows changes
sethherr Jul 28, 2026
023c24a
Register flow: review fixes, and an email param for register/new
sethherr Jul 29, 2026
27160b0
Register flow: stop sending the partial registration email
sethherr Jul 29, 2026
375b8aa
Register flow: drop partial_email_sent_to, and a duplicate button pre…
sethherr Jul 29, 2026
222c3ea
Merge remote-tracking branch 'origin/main' into sethherr/new-registra…
sethherr Jul 29, 2026
841748e
Merge remote-tracking branch 'origin/sethherr/new-registration-flow' …
sethherr Jul 29, 2026
fc9db29
Follow show_photo? gating the upload behind an account
sethherr Jul 29, 2026
aaffefd
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
72cdffc
Merge remote-tracking branch 'origin/main' into direct-upload-actives…
sethherr Jul 29, 2026
d23d645
Drop the anonymous direct upload endpoint
sethherr Jul 29, 2026
12216a8
Restore what the main merge resurrected
sethherr Jul 29, 2026
b67ae03
Fall back to the real storage domains in BlobUrl
sethherr Jul 29, 2026
e5b32c1
Upload the register photo through UI::Forms::FileUpload
sethherr Jul 29, 2026
afe78e7
Teach merge-conflicts to check what merged cleanly
sethherr Jul 29, 2026
fe0b9ef
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
8966848
Restore the unauthenticated register photo upload
sethherr Jul 29, 2026
8da80a9
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 29, 2026
5a5892b
Simplify after review
sethherr Jul 29, 2026
43496f7
Pull the storage config and upload validation from direct-upload-acti…
sethherr Jul 29, 2026
0c22914
Stamp the minting owner into blob metadata
sethherr Jul 29, 2026
d62b8f7
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 29, 2026
2422078
Simplify pass on the activestorage backend
sethherr Jul 29, 2026
7236bc9
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
5105baf
Move the blob record stamps into a binx_data jsonb column
sethherr Jul 29, 2026
e007ef1
Allow the non-production storage hosts in the CSP img-src
sethherr Jul 29, 2026
52ca0a8
Tidy the activestorage config
sethherr Jul 29, 2026
26ca5b7
Pin that a spoofed direct-upload content_type is rejected
sethherr Jul 29, 2026
b6b1e97
Keep every frame when stripping an animated image
sethherr Jul 29, 2026
03ae0af
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 29, 2026
522daa3
Use the dimensions let for the animated gif metadata
sethherr Jul 29, 2026
a9b34ff
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
4f5b327
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
fa0cd1f
Read the alert image_id stamp from binx_data
sethherr Jul 29, 2026
3f6abee
Merge remote-tracking branch 'origin/main' into direct-upload-actives…
sethherr Jul 29, 2026
437df03
Reset the re-recorded cassettes to main
sethherr Jul 29, 2026
14ffb8b
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 29, 2026
f1f53c6
Reset the re-recorded cassettes to the base
sethherr Jul 29, 2026
97102e7
Keep the processing flags in binx_data, and every gif frame
sethherr Jul 29, 2026
2c0cfee
Merge remote-tracking branch 'origin/main' into sethherr/keep-small-3…
sethherr Jul 29, 2026
6ee7ceb
Route the lightbox link through image_url, and simplify pass
sethherr Jul 29, 2026
93ec1cb
Merge remote-tracking branch 'origin/sethherr/keep-small-300x300' int…
sethherr Jul 29, 2026
410348b
Correct the metadata comments for the binx_data move
sethherr Jul 29, 2026
d2af42b
Align the file upload control with its field label
sethherr Jul 29, 2026
cb2afc4
Preview the picked image before it finishes uploading
sethherr Jul 30, 2026
5a3273a
Merge remote-tracking branch 'origin/main' into direct-upload-actives…
sethherr Jul 30, 2026
7b90419
Let UI::Forms::Group label the register photo field
sethherr Jul 30, 2026
eb9e137
Drop the comment narrating the confirmation gate's removal
sethherr Jul 30, 2026
035aef9
Post the photo's bytes when JS doesn't strip the field's name
sethherr Jul 30, 2026
18b5b13
One example per distinct setup in the direct upload specs
sethherr Jul 30, 2026
a926a60
End an upload that stalls, and show how far it has got
sethherr Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ jobs:
TRANSLATION_BRANCH: main
TRANSLATIONS_SYNC_APP_ID: ${{ secrets.TRANSLATIONS_SYNC_APP_ID }}
RETRY_FLAKY: true
# spec/integration/register_spec.rb uploads to the bikeindex-test bucket for real - the
# only way to exercise a presigned cross-origin PUT. Absent, that one example skips.
R2_TEST_ACCESS_KEY: ${{ secrets.R2_TEST_ACCESS_KEY }}
R2_TEST_ACCESS_KEY_SECRET: ${{ secrets.R2_TEST_ACCESS_KEY_SECRET }}
R2_TEST_ENDPOINT: ${{ secrets.R2_TEST_ENDPOINT }}
KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC }}
KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }}
KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }}
Expand Down
34 changes: 15 additions & 19 deletions app/components/register/step2/component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -111,26 +111,22 @@
</div>
</div>

<% if show_photo? %>
<div class="tw:mb-4">
<span class="twlabel">
<%= translation(".attach_photo", cycle_type:) %>
<%= optional_badge %>
</span>

<%= render UI::Forms::FileUpload::Component.new(form_builder: f, attribute: :image,
accept: ImageUploader.permitted_extensions) %>
<%= render UI::Forms::Group::Component.new(form_builder: f, attribute: :image,
label_text: translation(".attach_photo", cycle_type:)) do %>
<%# The token rides the query string - DirectUpload posts to this url verbatim %>
<%= render UI::Forms::FileUpload::Component.new(form_builder: f, attribute: :image,
accept: ImageUploader.permitted_extensions,
direct_upload_url: register_direct_uploads_path(b_param_token: @b_param.id_token)) %>

<p
class="
tw:mt-1.5 tw:flex tw:items-start tw:gap-1.5 tw:text-xs
tw:text-gray-500 tw:dark:text-gray-400
"
>
<%= helpers.inline_svg_tag("icons/info.svg", class: "tw:mt-px tw:h-3.5 tw:w-3.5 tw:shrink-0", aria_hidden: true) %>
<%= translation(".database_public_note") %>
</p>
</div>
<p
class="
tw:mt-1.5 tw:flex tw:items-start tw:gap-1.5 tw:text-xs
tw:text-gray-500 tw:dark:text-gray-400
"
>
<%= helpers.inline_svg_tag("icons/info.svg", class: "tw:mt-px tw:h-3.5 tw:w-3.5 tw:shrink-0", aria_hidden: true) %>
<%= translation(".database_public_note") %>
</p>
<% end %>

<div class="tw:mb-4" data-controller="ui--collapse register--serial">
Expand Down
6 changes: 0 additions & 6 deletions app/components/register/step2/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ def cycle_type
@b_param.type
end

# An upload needs an account behind it, so an anonymous registrant gets the
# option once the confirmation link proves the address is theirs
def show_photo?
@current_user.present? || @b_param.email_confirmed?
end

# A signed-in registration creates the bike straight from this step, so only
# an anonymous one is ever waiting on the address being confirmed
def awaiting_confirmation?
Expand Down
2 changes: 2 additions & 0 deletions app/components/ui/forms/file_upload/component.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ en:
no_file_chosen: No file chosen
take_picture: Take picture
upload: Upload
upload_failed: upload failed
uploading: uploading
8 changes: 8 additions & 0 deletions app/components/ui/forms/file_upload/component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<div
data-controller="ui--forms--file-upload"
data-ui--forms--file-upload-placeholder-value="<%= @placeholder %>"
<%# Blank unless the caller named an endpoint - the controller only uploads when it has one %>
data-ui--forms--file-upload-url-value="<%= @direct_upload_url %>"
data-ui--forms--file-upload-uploading-value="<%= translation(".uploading") %>"
data-ui--forms--file-upload-failed-value="<%= translation(".upload_failed") %>"
data-action="drop->ui--forms--file-upload#drop dragover@document->ui--forms--file-upload#dragOver dragleave@document->ui--forms--file-upload#endDrag drop@document->ui--forms--file-upload#endDrag"
>
<%#
Expand Down Expand Up @@ -42,6 +46,10 @@
<%# sr-only keeps the native input focusable and in the accessibility tree; the label below is the visible, clickable button. %>
<%= @form_builder.file_field(@attribute, @html_options) %>

<% if @signed_id_field %>
<%= hidden_field_tag @signed_id_field, nil, data: {"ui--forms--file-upload-target": "signedId"} %>
<% end %>

<%# Stimulus has no default event for a label, so name click explicitly. %>
<%= @form_builder.label @attribute, label_content, class: @label_classes, data: {action: "click->ui--forms--file-upload#chooseFile"} %>

Expand Down
9 changes: 8 additions & 1 deletion app/components/ui/forms/file_upload/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
module UI
module Forms
module FileUpload
# direct_upload_url: with JS, uploads on pick and posts the blob's signed id instead of
# the bytes. The field still renders as an ordinary one, so it posts the file when JS
# doesn't run - the controller drops its name only once it's driving the upload.
class Component < ApplicationComponent
# mb-0 cancels legacy bootstrap's `label` margin, which items-center would
# otherwise center along with the button next to it.
LABEL_CLASSES = "tw:mb-0 tw:whitespace-nowrap tw:peer-focus-visible:ring-3 tw:peer-focus-visible:ring-blue-500/40"

def initialize(form_builder:, attribute:, accept: nil, camera: nil, html_options: {})
def initialize(form_builder:, attribute:, accept: nil, camera: nil, direct_upload_url: nil, html_options: {})
@form_builder = form_builder
@attribute = attribute
@placeholder = translation(".no_file_chosen")
Expand All @@ -21,6 +24,10 @@ def initialize(form_builder:, attribute:, accept: nil, camera: nil, html_options
@attachment_url = attached_url
@thumbnail_url = thumbnail_version_url || @attachment_url

@direct_upload_url = direct_upload_url
# Carries the blob the browser uploaded. Scoped to the form builder like every other
# field here, so two of these on one page don't collide on the same param
@signed_id_field = "#{form_builder.object_name}[#{attribute}_signed_id]" if direct_upload_url.present?
@html_options = {
class: "tw:peer tw:sr-only",
accept: accept_list.join(",").presence,
Expand Down
45 changes: 45 additions & 0 deletions app/controllers/direct_uploads/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

# ActiveStorage's own direct uploads controller accepts any content type, at any size, from
# anyone. Both endpoints below it hold uploads to what PublicImage can serve; each subclass
# adds who it will accept them from.
module DirectUploads
class BaseController < ActiveStorage::DirectUploadsController
# Subclasses prepend their authorization, so it runs before this - binx_data can rely
# on whoever is asking having been resolved
before_action :require_permitted_file

# create_before_direct_upload! takes explicit keywords, so binx_data can't ride along in
# blob_args. Otherwise the same as ActiveStorage's.
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
blob.update!(binx_data:) if binx_data.present?
render json: direct_upload_json(blob)
end

private

# Refuses on what the browser declares, before any bytes are written - the presigned URL is
# signed against these values, so S3 holds the upload to the size it caps. PublicImage's
# validation is what checks the stored bytes, once there are some to check.
def require_permitted_file
return if PublicImage.file_permitted?(**blob_args.slice(:content_type, :byte_size))

head :unprocessable_entity
end

# ActiveStorage permits the client's metadata. Nothing reads it - analyze writes the real
# values and our own keys live in binx_data - so drop it rather than persist client input.
def blob_args
super.merge(metadata: {})
end

def binx_data
{}
end

def current_user
@current_user ||= AuthRestriction.user_from(request)
end
end
end
14 changes: 14 additions & 0 deletions app/controllers/direct_uploads_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

# Shadows ActiveStorage's own direct uploads route (see config/routes.rb) so the stock
# controller isn't reachable. Signed-in surfaces upload here; the registration flow has its
# own endpoint because it runs before there's an account to check.
class DirectUploadsController < DirectUploads::BaseController
prepend_before_action :require_current_user

private

def require_current_user
head :forbidden if current_user.blank?
end
end
28 changes: 28 additions & 0 deletions app/controllers/register/direct_uploads_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Register
# A registration's photo is picked before there's an account to attach it to, so the b_param
# token stands in for a session. Anyone can start a registration, so this is scoping and
# attribution rather than a gate - the hourly per-IP throttle in rack_attack.rb is the teeth.
class DirectUploadsController < DirectUploads::BaseController
prepend_before_action :require_registration

private

def require_registration
head :forbidden if b_param.blank?
end

def b_param
return @b_param if defined?(@b_param)

@b_param = BikeServices::Register.find_token(user: current_user, params_token: params[:b_param_token])
end

# A signed id is a bearer token, so without this any registration could claim any blob.
# BParam#image_blob only hands back one stamped with its own id.
def binx_data
{"b_param_id" => b_param.id}
end
end
end
4 changes: 3 additions & 1 deletion app/controllers/register_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ def create
end

def update
# Both read straight from params - update_params is stored as json, which an upload can't be
BikeServices::Register.save_step_2(@b_param, user: current_user,
image: params.dig(:bike, :image), bike_params: update_params)
image: params.dig(:bike, :image), image_signed_id: params.dig(:bike, :image_signed_id),
bike_params: update_params)
if BikeServices::Register.creator_available?(@b_param)
redirect_after_bike_creation(BikeServices::Register.create_bike(@b_param, ip_address: forwarded_ip_address))
else
Expand Down
108 changes: 106 additions & 2 deletions app/javascript/controllers/ui/forms/file_upload_controller.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import { Controller } from '@hotwired/stimulus'
import { DirectUpload } from '@rails/activestorage'
import { collapse } from 'utils/collapse_utils'

// Connects to data-controller='ui--forms--file-upload'
// Shows the selected filename (or a count for multiple files) in the field, previews an
// image pick, and frames the controls as a drop target while a file is dragged over the page.
// With a url value, uploads the pick straight to storage and posts its signed blob id.
export default class extends Controller {
static targets = ['input', 'filename', 'dropZone', 'preview', 'previewImage']
static values = { placeholder: String }
static targets = ['input', 'filename', 'dropZone', 'preview', 'previewImage', 'signedId']
// stall: how long without progress before the upload is treated as dead
static values = {
placeholder: String,
url: String,
uploading: String,
failed: String,
stall: { type: Number, default: 30000 }
}

connect () {
this.boundHold = this.hold.bind(this)
this.form?.addEventListener('submit', this.boundHold)
// The field posts its own bytes until this runs, which is what makes the form work
// without JS - once we're uploading, the signed id is what the form carries instead.
if (this.urlValue) this.inputTarget.removeAttribute('name')
}

disconnect () {
this.form?.removeEventListener('submit', this.boundHold)
clearTimeout(this.stallTimer)
this.releaseObjectUrl()
}

get form () {
return this.element.closest('form')
}

// Both buttons open the one input; `capture` is what sends it to the camera.
takePicture () {
this.inputTarget.setAttribute('capture', 'environment')
Expand Down Expand Up @@ -75,6 +98,7 @@ export default class extends Controller {
? this.placeholderValue
: files.length === 1 ? files[0].name : `${files.length} files`
this.showPreview(files[0])
if (this.urlValue && files[0]) this.upload(files[0])
}

// Reads the file the browser already holds, so the preview lands on the pick rather than
Expand Down Expand Up @@ -105,6 +129,86 @@ export default class extends Controller {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}

// Only reached with a direct_upload_url, where the form carries the blob's signed id
// rather than the bytes.
upload (file) {
this.abortUpload() // Picking again shouldn't leave the discarded file uploading
this.signedIdTarget.value = ''
this.uploadingFile = file
this.status(file, this.uploadingValue)

const upload = new DirectUpload(file, this.urlValue, this)
this.currentUpload = upload
this.pending = new Promise((resolve) => { this.settle = resolve })
upload.create((error, blob) => {
if (this.currentUpload !== upload) return // A newer pick owns the field now

if (!error) this.signedIdTarget.value = blob.signed_id
this.status(file, error && this.failedValue)
this.finish()
})
this.watchForStall()
}

// Every ending runs through here, because a submit waiting on `pending` only moves when
// it settles - including the endings DirectUpload never reports.
finish () {
clearTimeout(this.stallTimer)
this.currentUpload = null
this.pending = null
this.settle?.()
this.settle = null
}

// DirectUpload listens for load and error, not abort, so an aborted upload never reaches
// its callback - without settling it here a held submit would wait on it forever.
abortUpload () {
if (!this.currentUpload) return

this.xhr?.abort()
this.finish()
}

// A connection that stops moving never errors, so nothing else would end the upload -
// the form would sit disabled behind a spinner until the page was reloaded.
watchForStall () {
clearTimeout(this.stallTimer)
this.stallTimer = setTimeout(() => {
this.signedIdTarget.value = ''
this.status(this.uploadingFile, this.failedValue)
this.abortUpload()
}, this.stallValue)
}

status (file, suffix) {
this.filenameTarget.textContent = suffix ? `${file.name} — ${suffix}` : file.name
}

// DirectUpload delegate hook - the handle that makes a discarded upload cancellable
directUploadWillStoreFileWithXHR (xhr) {
this.xhr = xhr
xhr.upload.addEventListener('progress', (event) => this.showProgress(event))
}

// Bytes moving is also what proves the connection is alive, so this restarts the stall watch
showProgress (event) {
this.watchForStall()
if (!event.lengthComputable) return

const percent = Math.round((event.loaded / event.total) * 100)
this.status(this.uploadingFile, `${this.uploadingValue} ${percent}%`)
}

// Submitting mid-upload would drop the file, so hold the form until the blob lands.
// A failed upload submits anyway - the rest of the form matters more.
async hold (event) {
if (!this.pending) return

event.preventDefault()
await this.pending
this.form.requestSubmit()
}
}

// Dragged text and page elements fire these events too; only files matter here.
Expand Down
15 changes: 15 additions & 0 deletions app/models/b_param.rb
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,21 @@ def confirm_email!
email_confirmed? || update(params: params.merge("email_confirmed_at" => Time.current))
end

# An ActiveStorage blob the browser uploaded straight to the bucket. Held as a signed id
# rather than an attachment so the blob's only owner is the PublicImage created from it -
# a b_param attachment would purge the blob out from under the bike when it's cleaned up.
def image_signed_id
params["image_signed_id"]
end

# nil once CleanUnattachedBlobsJob has reaped it, which a late registration has to survive.
# Only a blob this registration minted - a signed id is a bearer token, so without the stamp
# any registration could claim any other's photo.
def image_blob
blob = ActiveStorage::Blob.find_signed(image_signed_id)
blob if blob&.binx_data&.dig("b_param_id") == id
end

def primary_frame_color
primary_frame_color_id.present? && Color.find_by_id(primary_frame_color_id)&.name
end
Expand Down
Loading
Loading