mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
add otp views
This commit is contained in:
parent
f1726e3d52
commit
f627677cb6
18 changed files with 299 additions and 105 deletions
|
@ -99,7 +99,7 @@ function loadBooruData() {
|
|||
initializeFilters();
|
||||
|
||||
// CSRF
|
||||
// TODO
|
||||
window.booru.csrfToken = $('meta[name="csrf-token"]').content;
|
||||
}
|
||||
|
||||
function BooruOnRails() {
|
||||
|
|
|
@ -2,8 +2,7 @@ import { $$, makeEl, findFirstTextNode } from './utils/dom';
|
|||
import { fire, delegate } from './utils/events';
|
||||
|
||||
const headers = () => ({
|
||||
Accept: 'text/javascript',
|
||||
'X-CSRF-Token': window.booru.csrfToken
|
||||
'x-csrf-token': window.booru.csrfToken
|
||||
});
|
||||
|
||||
function confirm(event, target) {
|
||||
|
@ -39,7 +38,7 @@ function linkMethod(event, target) {
|
|||
event.preventDefault();
|
||||
|
||||
const form = makeEl('form', { action: target.href, method: 'POST' });
|
||||
const csrf = makeEl('input', { type: 'hidden', name: window.booru.csrfParam, value: window.booru.csrfToken });
|
||||
const csrf = makeEl('input', { type: 'hidden', name: '_csrf_token', value: window.booru.csrfToken });
|
||||
const method = makeEl('input', { type: 'hidden', name: '_method', value: target.dataset.method });
|
||||
|
||||
document.body.appendChild(form);
|
||||
|
|
|
@ -8,7 +8,7 @@ function fetchJson(verb, endpoint, body) {
|
|||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.booru.csrfToken,
|
||||
'x-csrf-token': window.booru.csrfToken
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -24,8 +24,7 @@ function fetchHtml(endpoint) {
|
|||
return fetch(endpoint, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-Token': window.booru.csrfToken,
|
||||
'x-csrf-token': window.booru.csrfToken
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -145,7 +145,14 @@ defmodule Philomena.Users.User do
|
|||
end
|
||||
|
||||
def totp_changeset(user, params, backup_codes) do
|
||||
token = to_string(params["twofactor_token"])
|
||||
token =
|
||||
case params do
|
||||
%{"user" => %{"twofactor_token" => t}} ->
|
||||
to_string(t)
|
||||
|
||||
_ ->
|
||||
""
|
||||
end
|
||||
|
||||
case user.otp_required_for_login do
|
||||
true ->
|
||||
|
@ -171,6 +178,33 @@ defmodule Philomena.Users.User do
|
|||
end)
|
||||
end
|
||||
|
||||
def totp_qrcode(user) do
|
||||
secret = totp_secret(user)
|
||||
provisioning_uri = %URI{
|
||||
scheme: "otpauth",
|
||||
host: "totp",
|
||||
path: "/Derpibooru:" <> user.email,
|
||||
query: URI.encode_query(%{
|
||||
secret: secret,
|
||||
issuer: "Derpibooru"
|
||||
})
|
||||
}
|
||||
|
||||
png =
|
||||
QRCode.to_png(URI.to_string(provisioning_uri))
|
||||
|> Base.encode64()
|
||||
|
||||
"data:image/png;base64," <> png
|
||||
end
|
||||
|
||||
def totp_secret(user) do
|
||||
Philomena.Users.Encryptor.decrypt_model(
|
||||
user.encrypted_otp_secret,
|
||||
user.encrypted_otp_secret_iv,
|
||||
user.encrypted_otp_secret_salt
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
defp enable_totp_changeset(user, backup_codes) do
|
||||
hashed_codes =
|
||||
|
@ -193,20 +227,11 @@ defmodule Philomena.Users.User do
|
|||
end
|
||||
|
||||
defp totp_valid?(user, token),
|
||||
do: :pot.valid_totp(token, otp_secret(user), window: 60)
|
||||
do: :pot.valid_totp(token, totp_secret(user), window: 60)
|
||||
|
||||
defp backup_code_valid?(user, token),
|
||||
do: Enum.any?(user.otp_backup_codes, &Password.verify_pass(token, &1))
|
||||
|
||||
defp remove_backup_code(user, token),
|
||||
do: user.otp_backup_codes |> Enum.reject(&Password.verify_pass(token, &1))
|
||||
|
||||
|
||||
defp otp_secret(user) do
|
||||
Philomena.Users.Encryptor.decrypt_model(
|
||||
user.encrypted_otp_secret,
|
||||
user.encrypted_otp_secret_iv,
|
||||
user.encrypted_otp_secret_salt
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,20 +18,24 @@ defmodule PhilomenaWeb.Registration.TotpController do
|
|||
|
||||
_ ->
|
||||
changeset = Pow.Plug.change_user(conn)
|
||||
render(conn, "edit.html", changeset: changeset)
|
||||
secret = User.totp_secret(user)
|
||||
qrcode = User.totp_qrcode(user)
|
||||
render(conn, "edit.html", changeset: changeset, totp_secret: secret, totp_qrcode: qrcode)
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, params) do
|
||||
backup_codes = User.random_backup_codes()
|
||||
user = Pow.Plug.current_user(conn)
|
||||
|
||||
conn
|
||||
|> Pow.Plug.current_user()
|
||||
user
|
||||
|> User.totp_changeset(params, backup_codes)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", changeset: changeset)
|
||||
secret = User.totp_secret(user)
|
||||
qrcode = User.totp_qrcode(user)
|
||||
render(conn, "edit.html", changeset: changeset, totp_secret: secret, totp_qrcode: qrcode)
|
||||
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|
|
|
@ -21,6 +21,11 @@ defmodule PhilomenaWeb.Router do
|
|||
plug PhilomenaWeb.Plugs.TotpPlug
|
||||
end
|
||||
|
||||
pipeline :protected do
|
||||
plug Pow.Plug.RequireAuthenticated,
|
||||
error_handler: Pow.Phoenix.PlugErrorHandler
|
||||
end
|
||||
|
||||
scope "/" do
|
||||
pipe_through [:browser, :ensure_totp]
|
||||
|
||||
|
@ -33,10 +38,12 @@ defmodule PhilomenaWeb.Router do
|
|||
|
||||
# Additional routes for TOTP
|
||||
scope "/registration", Registration, as: :registration do
|
||||
pipe_through :protected
|
||||
resources "/totp", TotpController, only: [:edit, :update], singleton: true
|
||||
end
|
||||
|
||||
scope "/session", Session, as: :session do
|
||||
pipe_through :protected
|
||||
resources "/totp", TotpController, only: [:new, :create], singleton: true
|
||||
end
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ html lang="en"
|
|||
meta name="theme-color" content="#618fc3"
|
||||
meta name="format-detection" content="telephone=no"
|
||||
meta name="robots" content="noindex, nofollow"
|
||||
= csrf_meta_tag()
|
||||
script type="text/javascript" src=Routes.static_path(@conn, "/js/app.js") async="async"
|
||||
body data-theme="default"
|
||||
= render PhilomenaWeb.LayoutView, "_burger.html", assigns
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
<h1>Edit profile</h1>
|
||||
|
||||
<%= form_for @changeset, @action, [as: :user], fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= label f, :current_password %>
|
||||
<%= password_input f, :current_password %>
|
||||
<%= error_tag f, :current_password %>
|
||||
|
||||
<%= label f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
|
||||
<%= label f, :password %>
|
||||
<%= password_input f, :password %>
|
||||
<%= error_tag f, :password %>
|
||||
|
||||
<%= label f, :confirm_password %>
|
||||
<%= password_input f, :confirm_password %>
|
||||
<%= error_tag f, :confirm_password %>
|
||||
|
||||
<div>
|
||||
<%= submit "Update" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
56
lib/philomena_web/templates/pow/registration/edit.html.slime
Normal file
56
lib/philomena_web/templates/pow/registration/edit.html.slime
Normal file
|
@ -0,0 +1,56 @@
|
|||
h1 Account Settings
|
||||
|
||||
p
|
||||
' Looking for your content settings?
|
||||
a<> href="/settings/edit" Click here!
|
||||
|
||||
p
|
||||
' Looking for two-factor authentication?
|
||||
= link "Click here!", to: Routes.registration_totp_path(@conn, :edit)
|
||||
|
||||
h3 API Key
|
||||
p
|
||||
' Your API key is
|
||||
code
|
||||
=> @current_user.authentication_token
|
||||
' - you can use this to allow API consumers to access your account.
|
||||
|
||||
h3 Update Settings
|
||||
p
|
||||
strong
|
||||
' Don't forget to confirm your changes by entering your current password
|
||||
' at the bottom of the page!
|
||||
|
||||
= form_for @changeset, @action, [as: :user], fn f ->
|
||||
= if @changeset.action do
|
||||
.alert.alert-danger
|
||||
p Oops, something went wrong! Please check the errors below.
|
||||
|
||||
h3 Email address
|
||||
.field
|
||||
= text_input f, :email, class: "input", placeholder: "Email", required: true
|
||||
= error_tag f, :email
|
||||
|
||||
h3 Change Password
|
||||
.field
|
||||
= password_input f, :password, class: "input", placeholder: "New password"
|
||||
= error_tag f, :password
|
||||
|
||||
.field
|
||||
= password_input f, :confirm_password, class: "input", placeholder: "Confirm new password"
|
||||
= error_tag f, :confirm_password
|
||||
|
||||
.fieldlabel
|
||||
' Leave these blank if you don't want to change your password.
|
||||
|
||||
br
|
||||
|
||||
.block.block--fixed.block--warning
|
||||
h3 Confirm
|
||||
.field
|
||||
= password_input f, :current_password, class: "input", placeholder: "Current password"
|
||||
= error_tag f, :current_password
|
||||
.fieldlabel
|
||||
' We need your current password to confirm all of these changes
|
||||
|
||||
= submit "Save Account", class: "button"
|
|
@ -1,28 +0,0 @@
|
|||
<h1>Register</h1>
|
||||
|
||||
<%= form_for @changeset, @action, [as: :user], fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= label f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
|
||||
<%= label f, :password %>
|
||||
<%= password_input f, :password %>
|
||||
<%= error_tag f, :password %>
|
||||
|
||||
<%= label f, :confirm_password %>
|
||||
<%= password_input f, :confirm_password %>
|
||||
<%= error_tag f, :confirm_password %>
|
||||
|
||||
<div>
|
||||
<%= submit "Register" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<span><%= link "Sign in", to: Routes.pow_session_path(@conn, :new) %></span>
|
46
lib/philomena_web/templates/pow/registration/new.html.slime
Normal file
46
lib/philomena_web/templates/pow/registration/new.html.slime
Normal file
|
@ -0,0 +1,46 @@
|
|||
h1 Register
|
||||
|
||||
= form_for @changeset, @action, [as: :user], fn f ->
|
||||
= if @changeset.action do
|
||||
.alert.alert-danger
|
||||
p Oops, something went wrong! Please check the errors below.
|
||||
|
||||
.fieldlabel
|
||||
' Non-anonymous posts permanently show your username as of posting time,
|
||||
' and may appear on search engines; choose wisely.
|
||||
.field
|
||||
= text_input f, :name, class: "input", placeholder: "Username", required: true
|
||||
= error_tag f, :name
|
||||
|
||||
.fieldlabel
|
||||
' You'll use your email address to log in, and we'll use this to get in
|
||||
' touch if we need to. Don't worry, we won't share this or spam you.
|
||||
.field
|
||||
= text_input f, :email, class: "input", placeholder: "Email", required: true
|
||||
= error_tag f, :email
|
||||
|
||||
.fieldlabel
|
||||
' Pick a good strong password - longer is better! Minimum of 6 characters.
|
||||
.field
|
||||
= password_input f, :password, class: "input", placeholder: "Password", required: true
|
||||
= error_tag f, :password
|
||||
.field
|
||||
= password_input f, :confirm_password, class: "input", placeholder: "Confirm password", required: true
|
||||
= error_tag f, :confirm_password
|
||||
|
||||
br
|
||||
|
||||
.block.block--fixed.block--warning
|
||||
p
|
||||
' We won't share your personal information, won't send you spam emails,
|
||||
' and take your security and privacy seriously.
|
||||
p
|
||||
strong
|
||||
' Don't forget to read the
|
||||
a<> href="/pages/rules" site rules
|
||||
' before you dive in - they contain a quick introduction on how to
|
||||
' use the site.
|
||||
|
||||
br
|
||||
|
||||
= submit "Sign Up", class: "button"
|
|
@ -1,24 +0,0 @@
|
|||
<h1>Sign in</h1>
|
||||
|
||||
<%= form_for @changeset, @action, [as: :user], fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= label f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
<%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %>
|
||||
|
||||
<%= label f, :password %>
|
||||
<%= password_input f, :password %>
|
||||
<%= error_tag f, :password %>
|
||||
|
||||
<div>
|
||||
<%= submit "Sign in" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<span><%= link "Register", to: Routes.pow_registration_path(@conn, :new) %></span>
|
28
lib/philomena_web/templates/pow/session/new.html.slime
Normal file
28
lib/philomena_web/templates/pow/session/new.html.slime
Normal file
|
@ -0,0 +1,28 @@
|
|||
h1 Sign in
|
||||
|
||||
= form_for @changeset, @action, [as: :user], fn f ->
|
||||
= if @changeset.action do
|
||||
.alert.alert-danger
|
||||
p Oops, something went wrong! Please check the errors below.
|
||||
|
||||
p = link "Forgot your password?", to: Routes.pow_reset_password_reset_password_path(@conn, :new)
|
||||
|
||||
.field
|
||||
= text_input f, :email, class: "input", required: true, placeholder: "Email"
|
||||
= error_tag f, :email
|
||||
|
||||
.field
|
||||
= password_input f, :password, class: "input", required: true, placeholder: "Password"
|
||||
= error_tag f, :password
|
||||
|
||||
.field
|
||||
= checkbox f, :persistent_session
|
||||
= label f, :persistent_session, "Remember me"
|
||||
|
||||
= submit "Sign in", class: "button"
|
||||
|
||||
p
|
||||
strong
|
||||
' Haven't read the
|
||||
a<> href="/pages/rules" site rules
|
||||
' lately? Make sure you read them before posting or editing metadata!
|
103
lib/philomena_web/templates/registration/totp/edit.html.slime
Normal file
103
lib/philomena_web/templates/registration/totp/edit.html.slime
Normal file
|
@ -0,0 +1,103 @@
|
|||
h1 Two Factor Authentication
|
||||
|
||||
= form_for @changeset, Routes.registration_totp_path(@conn, :update), [as: :user], fn f ->
|
||||
= if @changeset.action do
|
||||
.alert.alert-danger
|
||||
p Oops, something went wrong! Please check the errors below.
|
||||
|
||||
= if @current_user.otp_required_for_login do
|
||||
p
|
||||
' Two factor authentication is currently
|
||||
strong> enabled
|
||||
' for your account.
|
||||
|
||||
h4 Enter the generated 6-digit code or one of your backup codes to disable.
|
||||
p
|
||||
' Note that the 6-digit codes are limited to a single use within their
|
||||
' lifespan of 30 seconds, so if you just logged in with a code, entering
|
||||
' it again here will cause an error. If that's the case, just wait for a
|
||||
' new code to be generated.
|
||||
|
||||
.field
|
||||
= text_input f, :twofactor_token, class: "input", placeholder: "6-digit code"
|
||||
= error_tag f, :twofactor_token
|
||||
|
||||
- else
|
||||
p
|
||||
' Two factor authentication is currently
|
||||
strong> disabled
|
||||
' for your account.
|
||||
|
||||
br
|
||||
|
||||
.block.block--fixed.block--warning
|
||||
p
|
||||
| Enabling 2FA will make it harder for an attacker to get into your account, but it may also make it harder for
|
||||
strong<> you
|
||||
| to get into your account. Make sure you'll have access to your authenticator if you enable it.
|
||||
h4 Download application
|
||||
p
|
||||
| You will need an application on your phone that'll generate TOTP codes for you, such as:
|
||||
ul
|
||||
li
|
||||
| Authy (
|
||||
a href="https://play.google.com/store/apps/details?id=com.authy.authy" target="_blank" rel="noreferrer"
|
||||
| Android
|
||||
| /
|
||||
a href="https://itunes.apple.com/us/app/authy/id494168017" target="_blank" rel="noreferrer"
|
||||
| iOS
|
||||
| )
|
||||
li
|
||||
| LastPass Authenticator (
|
||||
a href="https://play.google.com/store/apps/details?id=com.lastpass.authenticator" target="_blank" rel="noreferrer"
|
||||
| Android
|
||||
| /
|
||||
a href="https://itunes.apple.com/us/app/lastpass-authenticator/id1079110004" target="_blank" rel="noreferrer"
|
||||
| iOS
|
||||
| /
|
||||
a href="https://www.microsoft.com/en-us/store/apps/lastpass-authenticator/9nblggh5l9d7" target="_blank" rel="noreferrer"
|
||||
| Windows Mobile
|
||||
| )
|
||||
li
|
||||
| Microsoft Authenticator (
|
||||
a href="https://play.google.com/store/apps/details?id=com.azure.authenticator" target="_blank" rel="noreferrer"
|
||||
| Android
|
||||
| /
|
||||
a href="https://itunes.apple.com/us/app/microsoft-authenticator/id983156458" target="_blank" rel="noreferrer"
|
||||
| iOS
|
||||
| /
|
||||
a href="https://www.microsoft.com/en-us/store/p/microsoft-authenticator/9nblgggzmcj6" target="_blank" rel="noreferrer"
|
||||
| Windows Mobile
|
||||
| )
|
||||
' Google Authenticator is
|
||||
em> not
|
||||
' recommended.
|
||||
h4 Pair application
|
||||
p
|
||||
' Using the application of your choice, scan the QR code below or enter the following secret key:
|
||||
strong
|
||||
= @totp_secret
|
||||
p
|
||||
img src=@totp_qrcode alt="QR Code"
|
||||
h4 Confirm pairing
|
||||
p Enter the code generated by your authenticator app into the field below for verification.
|
||||
= text_input f, :twofactor_token, class: "input", placeholder: "6-digit code", autocomplete: "off"
|
||||
p Note that the 6-digit codes are limited to a single use within their lifespan of 30 seconds, so if you use a code to enable the feature here, you won't be able to immediately use the same code to log in or to disable the feature. You have to wait for a new code to be generated.
|
||||
.dnp-warning
|
||||
h4 Warning - Authenticator Backup Codes
|
||||
p Once you enable 2FA on your account, you will be provided with a list of backup codes that can be used to access your account in the event of you losing access to your authenticator app. You will only be provided with these codes once, so please ensure that you have a way to safely and securely record them before enabling 2FA on your account. If you lose access to your authenticator app and do not have your backup codes, you will be locked out of your account permanently, and we will be unable to assist you.
|
||||
|
||||
br
|
||||
|
||||
.block.block--fixed.block--warning
|
||||
.field
|
||||
= password_input f, :current_password, class: "input", placeholder: "Current password"
|
||||
= error_tag f, :current_password
|
||||
.fieldlabel
|
||||
' We need your current password to confirm these changes
|
||||
|
||||
br
|
||||
|
||||
= submit "Save Account", class: "button"
|
||||
|
||||
p = link "Back", to: Routes.pow_registration_path(@conn, :edit)
|
3
lib/philomena_web/views/registration/totp_view.ex
Normal file
3
lib/philomena_web/views/registration/totp_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule PhilomenaWeb.Registration.TotpView do
|
||||
use PhilomenaWeb, :view
|
||||
end
|
3
lib/philomena_web/views/session/totp_view.ex
Normal file
3
lib/philomena_web/views/session/totp_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule PhilomenaWeb.Session.TotpView do
|
||||
use PhilomenaWeb, :view
|
||||
end
|
3
mix.exs
3
mix.exs
|
@ -53,7 +53,8 @@ defmodule Philomena.MixProject do
|
|||
{:nimble_parsec, "~> 0.5.1"},
|
||||
{:canary, "~> 1.1.1"},
|
||||
{:scrivener_ecto, "~> 2.0"},
|
||||
{:pbkdf2, "~> 2.0"}
|
||||
{:pbkdf2, "~> 2.0"},
|
||||
{:qrcode, "~> 0.1.5"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
1
mix.lock
1
mix.lock
|
@ -41,6 +41,7 @@
|
|||
"postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"pot": {:hex, :pot, "0.10.1", "af7dc220fd45478719b821fb4c1222975132516478483213507f95026298d8ab", [:rebar3], [], "hexpm"},
|
||||
"pow": {:hex, :pow, "1.0.13", "5ca3e8d9fecca037bfb0ea3b8dde070cc319746498e844d59fc209d461b0d426", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3.0 or ~> 1.4.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 3.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm"},
|
||||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
|
||||
"retry": {:hex, :retry, "0.13.0", "bb9b2713f70f39337837852337ad280c77662574f4fb852a8386c269f3d734c4", [:mix], [], "hexpm"},
|
||||
"scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm"},
|
||||
|
|
Loading…
Reference in a new issue