Skip to content

Add QR Code pushes #3418

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
80d0c8d
Add QR Code pushes
ozovalihasan Jun 2, 2025
996b1a6
Fix the length in the qr code form
ozovalihasan Jun 2, 2025
de2863e
Add tests for QR Code pushes
ozovalihasan Jun 2, 2025
354e2dc
Update settings for qr codes
ozovalihasan Jun 3, 2025
a35196f
Add a comment for QR pushes
ozovalihasan Jun 3, 2025
fc83115
Add more routes for unified API controllers used for pushes
ozovalihasan Jun 3, 2025
8c1b446
Add a locale for max length of qr pushes
ozovalihasan Jun 3, 2025
8824d12
Remove an unnecessary space from config file of settings
ozovalihasan Jun 3, 2025
31e67f5
Fix a test of QR pushes
ozovalihasan Jun 3, 2025
930e342
Update the words used for QR code pushes
ozovalihasan Jun 3, 2025
f952816
Remove an unnecessary method defined before
ozovalihasan Jun 3, 2025
760d2ad
Remove an unnecessary action of PushesController
ozovalihasan Jun 3, 2025
49f17cc
Update a class used to get its attribute name
ozovalihasan Jun 3, 2025
f7f3348
Fix url helpers usage for tests of QR code pushes
ozovalihasan Jun 3, 2025
1470767
Fix an if condition used in show view of PushesController
ozovalihasan Jun 3, 2025
ca3d3c7
Merge branch 'master' into add-qr-pushes
pglombardo Jun 5, 2025
56bb631
Fix a few texts of a new qr push form
ozovalihasan Jun 5, 2025
77d558e
Update a cookie key used to save settings of new qr push form
ozovalihasan Jun 5, 2025
373569d
Merge branch 'master' into add-qr-pushes
pglombardo Jun 6, 2025
fd42c5a
Update tests to follow recent dashboard updates
pglombardo Jun 6, 2025
bef8cec
Remove an unnecessary import
ozovalihasan Jun 7, 2025
4d78105
Fix indentations of a view
ozovalihasan Jun 7, 2025
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
7 changes: 6 additions & 1 deletion app/controllers/api/v1/pushes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def show
param :expire_after_views, Integer, desc: "Expire secret link and delete after this many views."
param :deletable_by_viewer, %w[true false], desc: "Allow users to delete passwords once retrieved."
param :retrieval_step, %w[true false], desc: "Helps to avoid chat systems and URL scanners from eating up views."
param :kind, %w[text file url], desc: "The kind of push to create. Defaults to 'text'.", required: false
param :kind, %w[text file url qr], desc: "The kind of push to create. Defaults to 'text'.", required: false
end
formats ["JSON"]
description <<-EOS
Expand All @@ -95,6 +95,7 @@ def show
* Text/password (default)
* File attachments (requires authentication & subscription)
* URLs
* QR codes

=== Required Parameters

Expand Down Expand Up @@ -153,6 +154,10 @@ def create
@push = Push.new(push_params)

if !push_params[:kind].present?
# These are used to determine the default kind based on the request path
# for old push records. Their paths are generated based on their kind.
# And, QR code pushes are created by using `/p/` path.
# So, it is not necessary to check for a special path.
@push.kind = if request.path.include?("/f.json")
"file"
elsif request.path.include?("/r.json")
Expand Down
24 changes: 23 additions & 1 deletion app/controllers/pushes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class PushesController < BaseController
include SetPushAttributes
include LogEvents

before_action :set_push, except: %i[new create index]
before_action :set_push, except: %i[new create index qr_preview]
before_action :check_allowed

def show
Expand Down Expand Up @@ -127,6 +127,8 @@ def create
@files_tab = true
elsif @push.kind == "url"
@url_tab = true
elsif @push.kind == "qr"
@qr_tab = true
else
@text_tab = true
end
Expand All @@ -150,6 +152,11 @@ def print_preview
render action: "print_preview", layout: "naked"
end

def qr_preview
@content = params[:content]
render partial: "qr_preview", layout: false
end

def preliminary
# This password may have expired since the last view. Validate the password
# expiration before doing anything.
Expand Down Expand Up @@ -263,6 +270,9 @@ def set_kind_by_tab
elsif params["tab"] == "url"
@push.kind = "url"
@url_tab = true
elsif params["tab"] == "qr"
@push.kind = "qr"
@qr_tab = true
else
@push.kind = "text"
@text_tab = true
Expand Down Expand Up @@ -291,6 +301,8 @@ def check_allowed
"file"
when "url"
"url"
when "qr"
"qr"
else
"text"
end
Expand Down Expand Up @@ -319,6 +331,16 @@ def check_allowed
else
redirect_to root_path, notice: t("pushes.url_pushes_disabled")
end

when "qr"
# QR code pushes only enabled when logins are enabled.
if Settings.enable_logins && Settings.enable_qr_pushes
unless %w[preliminary passphrase access show expire].include?(action_name)
authenticate_user!
end
else
redirect_to root_path, notice: t("pushes.qr_pushes_disabled")
end
when "text"
unless %w[new create preview print_preview preliminary passphrase access show expire].include?(action_name)
authenticate_user!
Expand Down
12 changes: 12 additions & 0 deletions app/helpers/pushes_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "rqrcode"

module PushesHelper
def filesize(size)
units = %w[B KiB MiB GiB TiB Pib EiB ZiB]
Expand All @@ -12,4 +14,14 @@ def filesize(size)

format("%.1f #{units[exp]}", size.to_f / (1024**exp))
end

def qr_code(url)
RQRCode::QRCode.new(url).as_svg(
offset: 0,
color: :currentColor,
shape_rendering: "crispEdges",
module_size: 6,
standalone: true
).html_safe
end
end
20 changes: 19 additions & 1 deletion app/models/push.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "addressable/uri"

class Push < ApplicationRecord
enum :kind, [:text, :file, :url], validate: true
enum :kind, [:text, :file, :url, :qr], validate: true

validate :check_enabled_push_kinds
validates :url_token, presence: true, uniqueness: true
Expand All @@ -16,6 +16,7 @@ class Push < ApplicationRecord
create.after_validation :check_payload_for_text, if: :text?
create.after_validation :check_files_for_file, if: :file?
create.after_validation :check_payload_for_url, if: :url?
create.after_validation :check_payload_for_qr, if: :qr?
end

belongs_to :user, optional: true
Expand Down Expand Up @@ -135,6 +136,17 @@ def check_payload_for_url
end
end

def check_payload_for_qr
if payload.present?
# If the push is a QR code, max payload length is 1024 characters
if payload.length > 1024
errors.add(:payload, t("pushes.create.qr_max_length", count: 1024))
end
else
errors.add(:payload, I18n.t("pushes.create.payload_required"))
end
end

def set_expire_limits
self.expire_after_days ||= settings_for_kind.expire_after_days_default
self.expire_after_views ||= settings_for_kind.expire_after_views_default
Expand Down Expand Up @@ -177,6 +189,8 @@ def settings_for_kind
Settings.url
elsif file?
Settings.files
elsif qr?
Settings.qr
end
end

Expand All @@ -188,6 +202,10 @@ def check_enabled_push_kinds
if kind == "url" && !(Settings.enable_logins && Settings.enable_url_pushes)
errors.add(:kind, I18n.t("pushes.url_pushes_disabled"))
end

if kind == "qr" && !(Settings.enable_logins && Settings.enable_qr_pushes)
errors.add(:kind, I18n.t("pushes.qr_pushes_disabled"))
end
end

def set_default_attributes
Expand Down
196 changes: 196 additions & 0 deletions app/views/pushes/_qr_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<% title(_('Securely Send a Password')) %>

<div class='container'
data-controller="knobs pwgen passwords form"
data-knobs-tab-name-value="password"
data-knobs-lang-day-value="<%= _('Day') %>"
data-knobs-lang-days-value="<%= _('Days') %>"
data-knobs-default-days-value="<%= @push.settings_for_kind.expire_after_days_default %>"
data-knobs-lang-view-value="<%= _('View') %>"
data-knobs-lang-views-value="<%= _('Views') %>"
data-knobs-default-views-value="<%= @push.settings_for_kind.expire_after_views_default %>"
data-knobs-lang-save-value="<%= _('Save') %>"
data-knobs-lang-saved-value="<%= _('Saved!') %>"
data-knobs-default-retrieval-step-value="<%= @push.settings_for_kind.retrieval_step_default %>"
data-knobs-default-deletable-by-viewer-value="<%= @push.settings_for_kind.deletable_pushes_default %>"
data-pwgen-use-numbers-default-value="<%= Settings.gen.has_numbers %>"
data-pwgen-title-cased-default-value="<%= Settings.gen.title_cased %>"
data-pwgen-use-separators-default-value="<%= Settings.gen.use_separators %>"
data-pwgen-consonants-default-value="<%= Settings.gen.consonants %>"
data-pwgen-vowels-default-value="<%= Settings.gen.vowels %>"
data-pwgen-separators-default-value="<%= Settings.gen.separators %>"
data-pwgen-min-syllable-length-default-value="<%= Settings.gen.min_syllable_length %>"
data-pwgen-max-syllable-length-default-value="<%= Settings.gen.max_syllable_length %>"
data-pwgen-syllables-count-default-value="<%= Settings.gen.syllables_count %>"
data-knobs-ga-enabled-value="<%= ENV.key?('GA_ENABLE') %>">
<%= render partial: "shared/topnav" %>
<%= form_for @push, data: { action: 'form#submit' } do |f| %>
<%= f.hidden_field :kind, value: :qr %>

<div class='row'>
<div class='col'>
<%= f.text_area(:payload, { class: "form-control",
rows: 4,
placeholder: _('Enter the Password or Text to push...'),
autocomplete: "off",
spellcheck: "false",
maxlength: 1024,
autofocus: true,
required: true,
"data-pwgen-target" => "payloadInput",
"data-passwords-target" => "payloadInput",
"data-action" => "input->passwords#updateCharacterCount"
}) %>
<div class='position-relative'>
<div id="the-count" class="position-absolute bottom-0 end-0 m-2 px-3 opacity-75">
<span id="current" data-passwords-target="currentChars">0</span>
<span id="maximum" data-passwords-target="maximumChars">/ 1024 <%= _('Characters') %></span>
</div>
</div>
</div>
</div>
<div class='row'>
<div class='col-12 col-sm-8 p-3'>
<div class='row'>
<div><%= _('Expire secret link and delete after:') %></div>
<div class='col-10'>
<%= range_field_tag("push_expire_after_days", @push.settings_for_kind.expire_after_days_default,
{ :name => "push[expire_after_days]",
:class => "form-range",
:min => @push.settings_for_kind.expire_after_days_min,
:max => @push.settings_for_kind.expire_after_days_max,
:step => "1",
"data-action" => "change->knobs#updateDaysSlider input->knobs#updateDaysSlider",
"data-knobs-target" => "daysRange"
}) %>
</div>
<div class='col-2'>
<div class="form-text" data-knobs-target="daysRangeLabel"><%= @push.settings_for_kind.expire_after_days_default %> <%= _('Days') %></div>
</div>
</div>
<div class='row'>
<div class='col-10'>
<%= range_field_tag("push_expire_after_views", @push.settings_for_kind.expire_after_views_default,
{ :name => "push[expire_after_views]",
:class => "form-range",
:min => @push.settings_for_kind.expire_after_views_min,
:max => @push.settings_for_kind.expire_after_views_max,
:step => "1",
"data-action" => "change->knobs#updateViewsSlider input->knobs#updateViewsSlider",
"data-knobs-target" => "viewsRange"
}) %>
</div>

<div class='col-2'>
<div class="form-text" data-knobs-target="viewsRangeLabel"><%= @push.settings_for_kind.expire_after_views_default %> <%= _('Views') %></div>
</div>
</div>
<div class='row'>
<div class='col'>
<p class='text-center form-text'><%= _('(whichever comes first)') %></p>
</div>
</div>

<div class='row mb-3'>
<div class='col'>
<div class="list-group mx-0">
<% if @push.settings_for_kind.enable_retrieval_step %>
<label class="list-group-item d-flex gap-2">
<%= check_box_tag "push[retrieval_step]", nil, @push.settings_for_kind.retrieval_step_default,
{ class: 'form-check-input flex-shrink-0',
"data-knobs-target" => "retrievalStepCheckbox" } %>
<span>
<%= _('Use a 1-click retrieval step') %>
<small class="d-block text-muted"><%= _('Helps to avoid chat systems and URL scanners from eating up views.') %></small>
</span>
</label>
<% end %>
<% if @push.settings_for_kind.enable_deletable_pushes %>
<label class="list-group-item d-flex gap-2">
<%= check_box_tag "push[deletable_by_viewer]", nil, @push.settings_for_kind.deletable_pushes_default,
{ class: 'form-check-input flex-shrink-0',
"data-knobs-target" => "deletableByViewerCheckbox" } %>
<span>
<%= _('Allow immediate deletion') %>
<small class="d-block text-muted"><%= _('Allow users to delete this push once retrieved.') %></small>
</span>
</label>
<% end %>
</div>
</div>
</div>
<div class='row mb-3'>
<div class='col'>
<div class="input-group">
<span class="input-group-text"><%= _('Passphrase Lockdown') %></span>
<%= f.text_field(:passphrase, { class: "form-control",
autocomplete: "off",
placeholder: _('Optional: Require recipients to enter a passphrase to view this push') }) %>
</div>
</div>
</div>
<div class='row'>
<div class='col'>
<p class='mb-3'>
<div id='cookie-save'>
<a data-action="click->knobs#saveSettings" href="#"><%= _('Save') %></a> <%= _('the above settings as the page default.') %>
</div>
</p>
</div>
</div>
</div>
<div class='col-12 col-sm-4 p-3 mt-3'>
<div class="row mb-3">
<div class="btn-group mb-3" role="group" aria-label="Password Generator button group with nested dropdown">
<button class="btn btn-secondary w-75" type="button"
id='generate_password'
data-knobs-target="generatePasswordButton"
data-action="pwgen#producePassword passwords#updateCharacterCount"><em class="bi bi-cpu"></em> <%= _('Generate Password') %></button>
<button class="btn btn-secondary" type="button" id='configure_generator'
data-action="pwgen#configureGenerator"
data-bs-toggle="modal" data-bs-target="#configureModal">
<em class="bi bi-gear"></em>
</button>
</div>
<p class='fst-italic fw-light'><%= _('Use the button above to generate a random password.') %></p>
</div>
<% if user_signed_in? %>
<div class='row mb-3'>
<div class="input-group">
<span class="input-group-text"><%= Password.human_attribute_name(:name) %></span>
<%= f.text_field(:name, { class: "form-control",
placeholder: _('Optional'),
autocomplete: "off" }) %>
</div>
<div class="form-text" id="basic-addon4"><%= _('A name shown in the dashboard, notifications and emails.') %></div>
</div>
<div class='row mb-3'>
<div class="input-group">
<span class="input-group-text"><%= _('Reference Note') %></span>
<%= f.text_area(:note, { class: "form-control",
rows: 1,
placeholder: _('Optional'),
autocomplete: "off" }) %>
</div>
<div class="form-text" id="basic-addon4"><%= _('Encrypted and visible only to you') %></div>
</div>
<% end %>
<div class='row my-3 px-5'> <hr> </div>
<div class='row mb-3'>
<p class='fst-italic'><%= _('Tip: Only enter a password into the box. Other identifying information can compromise security.') %></p>
<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>
</div>
</div>
</div>
<div class='row'>
<div class='col'>
<p class='my-3'>
<button class="form-control btn btn-primary" type="submit" data-form-target="pushit" data-disable-with="Pushing..."><%= _('Push It!') %></button>
</p>
</div>
</div>
<% end %>

<%= render partial: 'shared/pw_generator_modal', cached: true %>

</div>
13 changes: 13 additions & 0 deletions app/views/pushes/_show_payload.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class='text-center m-3'>
<p class=""><strong><%= _('Please obtain and securely store this content in a secure manner, such as in a password manager.') %></strong></p>
<% if Settings.pw.enable_blur %>
<p class="text-muted"><%= _('Your password is blurred out. Click below to reveal it.') %></p>
<% end %>
<%= render partial: 'shared/copy_button', cached: true %>
</div>

<% if payload.chomp.match?(/\n/) || payload.length > 100 %>
<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>
<% else %>
<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>
<% end %>
4 changes: 4 additions & 0 deletions app/views/pushes/_show_qr.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class='text-center m-3'>
<p class=""><strong><%= _('Please obtain and securely store this content in a secure manner, such as in a password manager.') %></strong></p>
<%= qr_code(push.payload) %>
<div>
2 changes: 2 additions & 0 deletions app/views/pushes/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
File(s)
<% elsif push.url? %>
URL
<% elsif push.qr? %>
QR Code
<% else %>
Text
<% end %>
Expand Down
Loading
Loading