Skip to content

Commit 647e747

Browse files
Add QR Code pushes (#3418)
* Add QR Code pushes * Fix the length in the qr code form * Add tests for QR Code pushes * Update settings for qr codes * Add a comment for QR pushes * Add more routes for unified API controllers used for pushes * Add a locale for max length of qr pushes * Remove an unnecessary space from config file of settings * Fix a test of QR pushes * Update the words used for QR code pushes * Remove an unnecessary method defined before * Remove an unnecessary action of PushesController * Update a class used to get its attribute name * Fix url helpers usage for tests of QR code pushes * Fix an if condition used in show view of PushesController * Fix a few texts of a new qr push form * Update a cookie key used to save settings of new qr push form * Update tests to follow recent dashboard updates * Remove an unnecessary import * Fix indentations of a view --------- Co-authored-by: Peter Giacomo Lombardo <pglombardo@hey.com>
1 parent 8cf8cca commit 647e747

31 files changed

+2082
-17
lines changed

app/controllers/api/v1/pushes_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def show
8484
param :expire_after_views, Integer, desc: "Expire secret link and delete after this many views."
8585
param :deletable_by_viewer, %w[true false], desc: "Allow users to delete passwords once retrieved."
8686
param :retrieval_step, %w[true false], desc: "Helps to avoid chat systems and URL scanners from eating up views."
87-
param :kind, %w[text file url], desc: "The kind of push to create. Defaults to 'text'.", required: false
87+
param :kind, %w[text file url qr], desc: "The kind of push to create. Defaults to 'text'.", required: false
8888
end
8989
formats ["JSON"]
9090
description <<-EOS
@@ -95,6 +95,7 @@ def show
9595
* Text/password (default)
9696
* File attachments (requires authentication & subscription)
9797
* URLs
98+
* QR codes
9899
99100
=== Required Parameters
100101
@@ -153,6 +154,10 @@ def create
153154
@push = Push.new(push_params)
154155

155156
if !push_params[:kind].present?
157+
# These are used to determine the default kind based on the request path
158+
# for old push records. Their paths are generated based on their kind.
159+
# And, QR code pushes are created by using `/p/` path.
160+
# So, it is not necessary to check for a special path.
156161
@push.kind = if request.path.include?("/f.json")
157162
"file"
158163
elsif request.path.include?("/r.json")

app/controllers/pushes_controller.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ def create
127127
@files_tab = true
128128
elsif @push.kind == "url"
129129
@url_tab = true
130+
elsif @push.kind == "qr"
131+
@qr_tab = true
130132
else
131133
@text_tab = true
132134
end
@@ -263,6 +265,9 @@ def set_kind_by_tab
263265
elsif params["tab"] == "url"
264266
@push.kind = "url"
265267
@url_tab = true
268+
elsif params["tab"] == "qr"
269+
@push.kind = "qr"
270+
@qr_tab = true
266271
else
267272
@push.kind = "text"
268273
@text_tab = true
@@ -291,6 +296,8 @@ def check_allowed
291296
"file"
292297
when "url"
293298
"url"
299+
when "qr"
300+
"qr"
294301
else
295302
"text"
296303
end
@@ -319,6 +326,16 @@ def check_allowed
319326
else
320327
redirect_to root_path, notice: t("pushes.url_pushes_disabled")
321328
end
329+
330+
when "qr"
331+
# QR code pushes only enabled when logins are enabled.
332+
if Settings.enable_logins && Settings.enable_qr_pushes
333+
unless %w[preliminary passphrase access show expire].include?(action_name)
334+
authenticate_user!
335+
end
336+
else
337+
redirect_to root_path, notice: t("pushes.qr_pushes_disabled")
338+
end
322339
when "text"
323340
unless %w[new create preview print_preview preliminary passphrase access show expire].include?(action_name)
324341
authenticate_user!

app/models/push.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
require "addressable/uri"
44

55
class Push < ApplicationRecord
6-
enum :kind, [:text, :file, :url], validate: true
6+
enum :kind, [:text, :file, :url, :qr], validate: true
77

88
validate :check_enabled_push_kinds
99
validates :url_token, presence: true, uniqueness: true
@@ -16,6 +16,7 @@ class Push < ApplicationRecord
1616
create.after_validation :check_payload_for_text, if: :text?
1717
create.after_validation :check_files_for_file, if: :file?
1818
create.after_validation :check_payload_for_url, if: :url?
19+
create.after_validation :check_payload_for_qr, if: :qr?
1920
end
2021

2122
belongs_to :user, optional: true
@@ -135,6 +136,17 @@ def check_payload_for_url
135136
end
136137
end
137138

139+
def check_payload_for_qr
140+
if payload.present?
141+
# If the push is a QR code, max payload length is 1024 characters
142+
if payload.length > 1024
143+
errors.add(:payload, t("pushes.create.qr_max_length", count: 1024))
144+
end
145+
else
146+
errors.add(:payload, I18n.t("pushes.create.payload_required"))
147+
end
148+
end
149+
138150
def set_expire_limits
139151
self.expire_after_days ||= settings_for_kind.expire_after_days_default
140152
self.expire_after_views ||= settings_for_kind.expire_after_views_default
@@ -177,6 +189,8 @@ def settings_for_kind
177189
Settings.url
178190
elsif file?
179191
Settings.files
192+
elsif qr?
193+
Settings.qr
180194
end
181195
end
182196

@@ -188,6 +202,10 @@ def check_enabled_push_kinds
188202
if kind == "url" && !(Settings.enable_logins && Settings.enable_url_pushes)
189203
errors.add(:kind, I18n.t("pushes.url_pushes_disabled"))
190204
end
205+
206+
if kind == "qr" && !(Settings.enable_logins && Settings.enable_qr_pushes)
207+
errors.add(:kind, I18n.t("pushes.qr_pushes_disabled"))
208+
end
191209
end
192210

193211
def set_default_attributes

app/views/pushes/_qr_form.html.erb

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<% title(_('Securely Send a QR Code')) %>
2+
3+
<div class='container'
4+
data-controller="knobs pwgen passwords form"
5+
data-knobs-tab-name-value="qr"
6+
data-knobs-lang-day-value="<%= _('Day') %>"
7+
data-knobs-lang-days-value="<%= _('Days') %>"
8+
data-knobs-default-days-value="<%= @push.settings_for_kind.expire_after_days_default %>"
9+
data-knobs-lang-view-value="<%= _('View') %>"
10+
data-knobs-lang-views-value="<%= _('Views') %>"
11+
data-knobs-default-views-value="<%= @push.settings_for_kind.expire_after_views_default %>"
12+
data-knobs-lang-save-value="<%= _('Save') %>"
13+
data-knobs-lang-saved-value="<%= _('Saved!') %>"
14+
data-knobs-default-retrieval-step-value="<%= @push.settings_for_kind.retrieval_step_default %>"
15+
data-knobs-default-deletable-by-viewer-value="<%= @push.settings_for_kind.deletable_pushes_default %>"
16+
data-pwgen-use-numbers-default-value="<%= Settings.gen.has_numbers %>"
17+
data-pwgen-title-cased-default-value="<%= Settings.gen.title_cased %>"
18+
data-pwgen-use-separators-default-value="<%= Settings.gen.use_separators %>"
19+
data-pwgen-consonants-default-value="<%= Settings.gen.consonants %>"
20+
data-pwgen-vowels-default-value="<%= Settings.gen.vowels %>"
21+
data-pwgen-separators-default-value="<%= Settings.gen.separators %>"
22+
data-pwgen-min-syllable-length-default-value="<%= Settings.gen.min_syllable_length %>"
23+
data-pwgen-max-syllable-length-default-value="<%= Settings.gen.max_syllable_length %>"
24+
data-pwgen-syllables-count-default-value="<%= Settings.gen.syllables_count %>"
25+
data-knobs-ga-enabled-value="<%= ENV.key?('GA_ENABLE') %>">
26+
<%= render partial: "shared/topnav" %>
27+
<%= form_for @push, data: { action: 'form#submit' } do |f| %>
28+
<%= f.hidden_field :kind, value: :qr %>
29+
30+
<div class='row'>
31+
<div class='col'>
32+
<%= f.text_area(:payload, { class: "form-control",
33+
rows: 4,
34+
placeholder: _('Enter up to 1024 characters...'),
35+
autocomplete: "off",
36+
spellcheck: "false",
37+
maxlength: 1024,
38+
autofocus: true,
39+
required: true,
40+
"data-pwgen-target" => "payloadInput",
41+
"data-passwords-target" => "payloadInput",
42+
"data-action" => "input->passwords#updateCharacterCount"
43+
}) %>
44+
<div class='position-relative'>
45+
<div id="the-count" class="position-absolute bottom-0 end-0 m-2 px-3 opacity-75">
46+
<span id="current" data-passwords-target="currentChars">0</span>
47+
<span id="maximum" data-passwords-target="maximumChars">/ 1024 <%= _('Characters') %></span>
48+
</div>
49+
</div>
50+
</div>
51+
</div>
52+
<div class='row'>
53+
<div class='col-12 col-sm-8 p-3'>
54+
<div class='row'>
55+
<div><%= _('Expire secret link and delete after:') %></div>
56+
<div class='col-10'>
57+
<%= range_field_tag("push_expire_after_days", @push.settings_for_kind.expire_after_days_default,
58+
{ :name => "push[expire_after_days]",
59+
:class => "form-range",
60+
:min => @push.settings_for_kind.expire_after_days_min,
61+
:max => @push.settings_for_kind.expire_after_days_max,
62+
:step => "1",
63+
"data-action" => "change->knobs#updateDaysSlider input->knobs#updateDaysSlider",
64+
"data-knobs-target" => "daysRange"
65+
}) %>
66+
</div>
67+
<div class='col-2'>
68+
<div class="form-text" data-knobs-target="daysRangeLabel"><%= @push.settings_for_kind.expire_after_days_default %> <%= _('Days') %></div>
69+
</div>
70+
</div>
71+
<div class='row'>
72+
<div class='col-10'>
73+
<%= range_field_tag("push_expire_after_views", @push.settings_for_kind.expire_after_views_default,
74+
{ :name => "push[expire_after_views]",
75+
:class => "form-range",
76+
:min => @push.settings_for_kind.expire_after_views_min,
77+
:max => @push.settings_for_kind.expire_after_views_max,
78+
:step => "1",
79+
"data-action" => "change->knobs#updateViewsSlider input->knobs#updateViewsSlider",
80+
"data-knobs-target" => "viewsRange"
81+
}) %>
82+
</div>
83+
84+
<div class='col-2'>
85+
<div class="form-text" data-knobs-target="viewsRangeLabel"><%= @push.settings_for_kind.expire_after_views_default %> <%= _('Views') %></div>
86+
</div>
87+
</div>
88+
<div class='row'>
89+
<div class='col'>
90+
<p class='text-center form-text'><%= _('(whichever comes first)') %></p>
91+
</div>
92+
</div>
93+
94+
<div class='row mb-3'>
95+
<div class='col'>
96+
<div class="list-group mx-0">
97+
<% if @push.settings_for_kind.enable_retrieval_step %>
98+
<label class="list-group-item d-flex gap-2">
99+
<%= check_box_tag "push[retrieval_step]", nil, @push.settings_for_kind.retrieval_step_default,
100+
{ class: 'form-check-input flex-shrink-0',
101+
"data-knobs-target" => "retrievalStepCheckbox" } %>
102+
<span>
103+
<%= _('Use a 1-click retrieval step') %>
104+
<small class="d-block text-muted"><%= _('Helps to avoid chat systems and URL scanners from eating up views.') %></small>
105+
</span>
106+
</label>
107+
<% end %>
108+
<% if @push.settings_for_kind.enable_deletable_pushes %>
109+
<label class="list-group-item d-flex gap-2">
110+
<%= check_box_tag "push[deletable_by_viewer]", nil, @push.settings_for_kind.deletable_pushes_default,
111+
{ class: 'form-check-input flex-shrink-0',
112+
"data-knobs-target" => "deletableByViewerCheckbox" } %>
113+
<span>
114+
<%= _('Allow immediate deletion') %>
115+
<small class="d-block text-muted"><%= _('Allow users to delete this push once retrieved.') %></small>
116+
</span>
117+
</label>
118+
<% end %>
119+
</div>
120+
</div>
121+
</div>
122+
<div class='row mb-3'>
123+
<div class='col'>
124+
<div class="input-group">
125+
<span class="input-group-text"><%= _('Passphrase Lockdown') %></span>
126+
<%= f.text_field(:passphrase, { class: "form-control",
127+
autocomplete: "off",
128+
placeholder: _('Optional: Require recipients to enter a passphrase to view this push') }) %>
129+
</div>
130+
</div>
131+
</div>
132+
<div class='row'>
133+
<div class='col'>
134+
<p class='mb-3'>
135+
<div id='cookie-save'>
136+
<a data-action="click->knobs#saveSettings" href="#"><%= _('Save') %></a> <%= _('the above settings as the page default.') %>
137+
</div>
138+
</p>
139+
</div>
140+
</div>
141+
</div>
142+
<div class='col-12 col-sm-4 p-3 mt-3'>
143+
<div class="row mb-3">
144+
<div class="btn-group mb-3" role="group" aria-label="Password Generator button group with nested dropdown">
145+
<button class="btn btn-secondary w-75" type="button"
146+
id='generate_password'
147+
data-knobs-target="generatePasswordButton"
148+
data-action="pwgen#producePassword passwords#updateCharacterCount"><em class="bi bi-cpu"></em> <%= _('Generate Password') %></button>
149+
<button class="btn btn-secondary" type="button" id='configure_generator'
150+
data-action="pwgen#configureGenerator"
151+
data-bs-toggle="modal" data-bs-target="#configureModal">
152+
<em class="bi bi-gear"></em>
153+
</button>
154+
</div>
155+
<p class='fst-italic fw-light'><%= _('Use the button above to generate a random password.') %></p>
156+
</div>
157+
<% if user_signed_in? %>
158+
<div class='row mb-3'>
159+
<div class="input-group">
160+
<span class="input-group-text"><%= Push.human_attribute_name(:name) %></span>
161+
<%= f.text_field(:name, { class: "form-control",
162+
placeholder: _('Optional'),
163+
autocomplete: "off" }) %>
164+
</div>
165+
<div class="form-text" id="basic-addon4"><%= _('A name shown in the dashboard, notifications and emails.') %></div>
166+
</div>
167+
<div class='row mb-3'>
168+
<div class="input-group">
169+
<span class="input-group-text"><%= _('Reference Note') %></span>
170+
<%= f.text_area(:note, { class: "form-control",
171+
rows: 1,
172+
placeholder: _('Optional'),
173+
autocomplete: "off" }) %>
174+
</div>
175+
<div class="form-text" id="basic-addon4"><%= _('Encrypted and visible only to you') %></div>
176+
</div>
177+
<% end %>
178+
<div class='row my-3 px-5'> <hr> </div>
179+
<div class='row mb-3'>
180+
<p class='fst-italic'><%= _('Tip: Only enter a password into the box. Other identifying information can compromise security.') %></p>
181+
<p class='fst-italic fw-light'><%= _('All passwords are encrypted prior to storage and are available to only those with the secret link. Once expired, encrypted passwords are unequivocally deleted from the database.') %></p>
182+
</div>
183+
</div>
184+
</div>
185+
<div class='row'>
186+
<div class='col'>
187+
<p class='my-3'>
188+
<button class="form-control btn btn-primary" type="submit" data-form-target="pushit" data-disable-with="Pushing..."><%= _('Push It!') %></button>
189+
</p>
190+
</div>
191+
</div>
192+
<% end %>
193+
194+
<%= render partial: 'shared/pw_generator_modal', cached: true %>
195+
196+
</div>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div class='text-center m-3'>
2+
<p class=""><strong><%= _('Please obtain and securely store this content in a secure manner, such as in a password manager.') %></strong></p>
3+
<% if Settings.pw.enable_blur %>
4+
<p class="text-muted"><%= _('Your password is blurred out. Click below to reveal it.') %></p>
5+
<% end %>
6+
<%= render partial: 'shared/copy_button', cached: true %>
7+
</div>
8+
9+
<% if payload.chomp.match?(/\n/) || payload.length > 100 %>
10+
<div class='payload <%= blur_css_class %> notranslate px-5 border-top border-bottom border-5 w-100 bg-white d-flex justify-content-center' id='push_payload' translate='no' data-copy-target="payloadDiv"><pre class='text-break my-5'><%= payload %></pre></div>
11+
<% else %>
12+
<div class='payload <%= blur_css_class %> notranslate px-5 border-top border-bottom border-5 w-100 bg-white fs-2' id='push_payload' translate='no' data-copy-target="payloadDiv"><pre class='w-100 text-break text-wrap my-5 text-center'><%= payload %></pre></div>
13+
<% end %>

app/views/pushes/_show_qr.html.erb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<div class='text-center m-3'>
2+
<p class=""><strong><%= _('Please obtain and securely store this content in a secure manner, such as in a password manager.') %></strong></p>
3+
<%= qr_code(push.payload) %>
4+
<div>

app/views/pushes/index.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
<%= _('File(s)') %>
5656
<% elsif push.url? %>
5757
<%= _('URL') %>
58+
<% elsif push.qr? %>
59+
<%= _('QR Code') %>
5860
<% else %>
5961
<%= _('Text') %>
6062
<% end %>

app/views/pushes/new.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@
66
<%= render "files_form", push: @push %>
77
<% elsif @url_tab %>
88
<%= render "url_form", push: @push %>
9+
<% elsif @qr_tab %>
10+
<%= render "qr_form", push: @push %>
911
<% end %>

app/views/pushes/show.html.erb

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,23 +60,15 @@
6060
</div>
6161
</div>
6262
</div>
63-
<% elsif @push.text? %>
63+
<% elsif @push.text? || @push.qr? %>
6464
<div class="container-fluid h-100 mx-0 py-0 px-0" data-controller="copy passwords" data-copy-lang-copied-value="<%= _('Copied!') %>">
6565
<div class="d-flex flex-column min-vh-100 justify-content-center align-items-center">
66-
<% unless @push.payload.blank? %>
67-
<div class='text-center m-3'>
68-
<p class=""><strong><%= _('Please obtain and securely store this content in a secure manner, such as in a password manager.') %></strong></p>
69-
<% if Settings.pw.enable_blur %>
70-
<p class="text-muted"><%= _('Your password is blurred out. Click below to reveal it.') %></p>
71-
<% end %>
72-
<%= render partial: 'shared/copy_button', cached: true %>
73-
</div>
74-
75-
<% if @payload.chomp.match?(/\n/) || @payload.length > 100 %>
76-
<div class='payload <%= @blur_css_class %> notranslate px-5 border-top border-bottom border-5 w-100 bg-white d-flex justify-content-center' id='push_payload' translate='no' data-copy-target="payloadDiv"><pre class='text-break my-5'><%= @payload %></pre></div>
77-
<% else %>
78-
<div class='payload <%= @blur_css_class %> notranslate px-5 border-top border-bottom border-5 w-100 bg-white fs-2' id='push_payload' translate='no' data-copy-target="payloadDiv"><pre class='w-100 text-break text-wrap my-5 text-center'><%= @payload %></pre></div>
79-
<% end %>
66+
<% if @push.payload.present? %>
67+
<% if @push.text? %>
68+
<%= render partial: 'pushes/show_payload', locals: { payload: @payload, blur_css_class: @blur_css_class } %>
69+
<% elsif @push.qr? %>
70+
<%= render partial: 'pushes/show_qr', locals: { push: @push } %>
71+
<% end %>
8072
<% end %>
8173
<div class='text-center m-3'>
8274
<p class="text-muted mt-5">

0 commit comments

Comments
 (0)