mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-03 14:32:54 +00:00
Implement account provisioning and authentication mechanism
This commit is contained in:
parent
b4f0175166
commit
e55d35d122
16 changed files with 357 additions and 119 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
config :jenot, host: System.fetch_env!("HOST")
|
||||||
|
|
||||||
|
config :jenot, secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
|
||||||
|
|
||||||
|
config :jenot, secure_cookie: System.fetch_env!("SECURE_COOKIE") == "true"
|
||||||
|
|
||||||
config :web_push_elixir,
|
config :web_push_elixir,
|
||||||
vapid_public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
|
vapid_public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
|
||||||
vapid_private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
|
vapid_private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
import Config
|
import Config
|
||||||
|
|
||||||
config :jenot, Jenot.Repo, database: :memory
|
config :jenot, Jenot.Repo, database: :memory
|
||||||
|
|
||||||
|
config :bcrypt_elixir, :log_rounds, 4
|
||||||
|
|
|
||||||
15
lib/jenot.ex
15
lib/jenot.ex
|
|
@ -1,18 +1,9 @@
|
||||||
defmodule Jenot do
|
defmodule Jenot do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Documentation for `Jenot`.
|
Note taking app. Spelled ye-not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@doc """
|
def host() do
|
||||||
Hello world.
|
Application.fetch_env!(:jenot, :host)
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Jenot.hello()
|
|
||||||
:world
|
|
||||||
|
|
||||||
"""
|
|
||||||
def hello do
|
|
||||||
:world
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,45 @@ defmodule Jenot.Account do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@code_length 10
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
schema "accounts" do
|
schema "accounts" do
|
||||||
|
field :name, :string
|
||||||
|
field :code_digest, :string
|
||||||
|
|
||||||
timestamps(type: :utc_datetime_usec)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new() do
|
def new(code) do
|
||||||
|
id = Ecto.UUID.generate()
|
||||||
|
|
||||||
%__MODULE__{}
|
%__MODULE__{}
|
||||||
|> change()
|
|> change()
|
||||||
|> put_change(:id, Ecto.UUID.generate())
|
|> put_change(:id, id)
|
||||||
|
|> put_change(:name, Hahash.name(id))
|
||||||
|
|> put_change(:code_digest, hash(code))
|
||||||
|
end
|
||||||
|
|
||||||
|
def match?(account, input_code) do
|
||||||
|
Bcrypt.verify_pass(input_code, account.code_digest)
|
||||||
|
end
|
||||||
|
|
||||||
|
@safe_disambiguations %{
|
||||||
|
"O" => "8",
|
||||||
|
"I" => "7"
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_code() do
|
||||||
|
:crypto.strong_rand_bytes(6)
|
||||||
|
|> Base.encode32(padding: false)
|
||||||
|
|> String.replace(
|
||||||
|
Map.keys(@safe_disambiguations),
|
||||||
|
&Map.fetch!(@safe_disambiguations, &1)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp hash(code) when byte_size(code) == @code_length do
|
||||||
|
Bcrypt.hash_pwd_salt(code)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,100 @@ defmodule Jenot.Accounts do
|
||||||
alias Jenot.Account
|
alias Jenot.Account
|
||||||
alias Jenot.Repo
|
alias Jenot.Repo
|
||||||
|
|
||||||
def get() do
|
@cookie_name "jenot"
|
||||||
Repo.one(Account)
|
@cookie_seconds 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
def get_by_cookie(conn) do
|
||||||
|
with {:ok, %{account_id: id}} <- get_cookie(conn) do
|
||||||
|
get_by_id(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate(name, code) do
|
||||||
|
with {:ok, account} <- get_by_name(name),
|
||||||
|
:ok <- validate_code(account, code) do
|
||||||
|
{:ok, account}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def new() do
|
||||||
|
code = Account.generate_code()
|
||||||
|
account = Account.new(code)
|
||||||
|
|
||||||
|
case Repo.insert(account) do
|
||||||
|
{:ok, account} -> {:ok, %{code: code, account: account}}
|
||||||
|
{:error, _} -> {:error, :account_creation_failed}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cookie(conn, account_id) do
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.put_resp_cookie(@cookie_name, %{account_id: account_id},
|
||||||
|
domain: Jenot.host(),
|
||||||
|
secure: secure_cookie(),
|
||||||
|
encrypt: true,
|
||||||
|
max_age: @cookie_seconds,
|
||||||
|
same_site: "Strict"
|
||||||
|
)
|
||||||
|
|> Plug.Conn.put_resp_cookie("#{@cookie_name}_pub", "true",
|
||||||
|
domain: Jenot.host(),
|
||||||
|
http_only: false,
|
||||||
|
max_age: @cookie_seconds,
|
||||||
|
same_site: "Strict"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_cookie(conn) do
|
||||||
|
Plug.Conn.delete_resp_cookie(conn, @cookie_name,
|
||||||
|
domain: Jenot.host(),
|
||||||
|
secure: secure_cookie(),
|
||||||
|
encrypt: true,
|
||||||
|
max_age: @cookie_seconds,
|
||||||
|
same_site: "Strict"
|
||||||
|
)
|
||||||
|
|> Plug.Conn.delete_resp_cookie("#{@cookie_name}_pub",
|
||||||
|
domain: Jenot.host(),
|
||||||
|
http_only: false,
|
||||||
|
max_age: @cookie_seconds,
|
||||||
|
same_site: "Strict"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_by_name(name) do
|
||||||
|
case Repo.get_by(Account, name: name) do
|
||||||
|
nil ->
|
||||||
|
# dummy calculation to prevent timing attacks
|
||||||
|
Bcrypt.no_user_verify()
|
||||||
|
{:error, :account_not_found}
|
||||||
|
|
||||||
|
account ->
|
||||||
|
{:ok, account}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_code(account, code) do
|
||||||
|
if Account.match?(account, code) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, :account_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_cookie(conn) do
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.fetch_cookies(encrypted: [@cookie_name])
|
||||||
|
|> Map.fetch!(:cookies)
|
||||||
|
|> Map.fetch(@cookie_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_by_id(id) do
|
||||||
|
case Repo.get(Account, id) do
|
||||||
|
nil -> {:error, :account_not_found}
|
||||||
|
account -> {:ok, account}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def secure_cookie() do
|
||||||
|
Application.fetch_env!(:jenot, :secure_cookie)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
48
lib/jenot/templates/account_authenticate.html.heex
Normal file
48
lib/jenot/templates/account_authenticate.html.heex
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Jenot</title>
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/img/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/img/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/img/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<h1>Jenot</h1>
|
||||||
|
|
||||||
|
<%= if error do %>
|
||||||
|
<h2 style="color: red"><%= error %></h2>
|
||||||
|
<% end %>
|
||||||
|
<form action="/account/authenticate" method="post">
|
||||||
|
<fieldset>
|
||||||
|
<label for="account-name">Your account name:</label>
|
||||||
|
<input id="account-name" name="name" type="text" value="">
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<label for="account-code">Your secret code:</label>
|
||||||
|
<input id="account-code" name="code" type="password" value="">
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
<a href="/index.html">Back to Jenot</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
lib/jenot/templates/new_account.html.heex
Normal file
43
lib/jenot/templates/new_account.html.heex
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Jenot</title>
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/img/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/img/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/img/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
<h1>Jenot</h1>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<fieldset disabled>
|
||||||
|
<label for="account-name">Your account name:</label>
|
||||||
|
<input id="account-name" type="text" value="<%= name %>">
|
||||||
|
</fieldset>
|
||||||
|
<fieldset disabled>
|
||||||
|
<label for="account-code">Your secret code:</label>
|
||||||
|
<input id="account-code" type="text" value="<%= code %>">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<a href="/index.html?reset-meta">Open Jenot</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -8,6 +8,18 @@ defmodule Jenot.Web do
|
||||||
alias Jenot.Accounts
|
alias Jenot.Accounts
|
||||||
alias Jenot.Notes
|
alias Jenot.Notes
|
||||||
|
|
||||||
|
@template_dir "lib/jenot/templates"
|
||||||
|
|
||||||
|
@templates @template_dir
|
||||||
|
|> File.ls!()
|
||||||
|
|> Enum.map(fn file ->
|
||||||
|
{
|
||||||
|
String.replace_suffix(file, ".html.heex", ".html"),
|
||||||
|
File.read!(Path.join(@template_dir, file))
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
if Mix.env() == :dev do
|
if Mix.env() == :dev do
|
||||||
plug PlugLiveReload
|
plug PlugLiveReload
|
||||||
end
|
end
|
||||||
|
|
@ -26,6 +38,8 @@ defmodule Jenot.Web do
|
||||||
pass: ["*/*"],
|
pass: ["*/*"],
|
||||||
json_decoder: Jason
|
json_decoder: Jason
|
||||||
|
|
||||||
|
plug :put_secret_key_base
|
||||||
|
|
||||||
plug :match
|
plug :match
|
||||||
plug :dispatch
|
plug :dispatch
|
||||||
|
|
||||||
|
|
@ -35,6 +49,47 @@ defmodule Jenot.Web do
|
||||||
|> put_resp_header("location", "/index.html")
|
|> put_resp_header("location", "/index.html")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
post "/account/new" do
|
||||||
|
case Accounts.new() do
|
||||||
|
{:ok, %{code: code, account: account}} ->
|
||||||
|
conn
|
||||||
|
|> Accounts.set_cookie(account.id)
|
||||||
|
|> render("new_account.html", name: account.name, code: code)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
conn
|
||||||
|
|> Accounts.clear_cookie()
|
||||||
|
|> resp(:found, "")
|
||||||
|
|> put_resp_header("location", "/index.html")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/account/authenticate" do
|
||||||
|
render(conn, "account_authenticate.html", error: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/account/authenticate" do
|
||||||
|
case Accounts.authenticate(conn.params["name"], conn.params["code"]) do
|
||||||
|
{:ok, account} ->
|
||||||
|
conn
|
||||||
|
|> Accounts.set_cookie(account.id)
|
||||||
|
|> resp(:found, "")
|
||||||
|
|> put_resp_header("location", "/index.html?reset-meta")
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
conn
|
||||||
|
|> put_status(422)
|
||||||
|
|> render("account_authenticate.html", error: "Account not found")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/account/logout" do
|
||||||
|
conn
|
||||||
|
|> Accounts.clear_cookie()
|
||||||
|
|> resp(:found, "")
|
||||||
|
|> put_resp_header("location", "/index.html?reset-meta")
|
||||||
|
end
|
||||||
|
|
||||||
get "/api" do
|
get "/api" do
|
||||||
send_resp(conn, 200, """
|
send_resp(conn, 200, """
|
||||||
Jenot API says hi!
|
Jenot API says hi!
|
||||||
|
|
@ -42,13 +97,13 @@ defmodule Jenot.Web do
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/latest" do
|
get "/api/latest" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
send_resp(conn, 200, Jason.encode!(%{notes: Notes.latest_change(account)}))
|
send_resp(conn, 200, Jason.encode!(%{notes: Notes.latest_change(account)}))
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/notes" do
|
get "/api/notes" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
notes =
|
notes =
|
||||||
account
|
account
|
||||||
|
|
@ -59,7 +114,7 @@ defmodule Jenot.Web do
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/notes/:internal_id" do
|
get "/api/notes/:internal_id" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
case Notes.note_by_internal_id(account, internal_id) do
|
case Notes.note_by_internal_id(account, internal_id) do
|
||||||
{:ok, note} ->
|
{:ok, note} ->
|
||||||
|
|
@ -72,7 +127,7 @@ defmodule Jenot.Web do
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/notes" do
|
post "/api/notes" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
case Notes.add(account, conn.params) do
|
case Notes.add(account, conn.params) do
|
||||||
{:ok, note} ->
|
{:ok, note} ->
|
||||||
|
|
@ -84,7 +139,7 @@ defmodule Jenot.Web do
|
||||||
end
|
end
|
||||||
|
|
||||||
put "/api/notes/:internal_id" do
|
put "/api/notes/:internal_id" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
case Notes.update(account, internal_id, conn.params) do
|
case Notes.update(account, internal_id, conn.params) do
|
||||||
{:ok, note} ->
|
{:ok, note} ->
|
||||||
|
|
@ -99,7 +154,7 @@ defmodule Jenot.Web do
|
||||||
end
|
end
|
||||||
|
|
||||||
delete "/api/notes/:internal_id" do
|
delete "/api/notes/:internal_id" do
|
||||||
account = Accounts.get()
|
{:ok, account} = Accounts.get_by_cookie(conn)
|
||||||
|
|
||||||
:ok = Notes.delete(account, internal_id)
|
:ok = Notes.delete(account, internal_id)
|
||||||
|
|
||||||
|
|
@ -126,4 +181,17 @@ defmodule Jenot.Web do
|
||||||
defp service_worker_header(conn, _opts) do
|
defp service_worker_header(conn, _opts) do
|
||||||
put_resp_header(conn, "service-worker-allowed", "/")
|
put_resp_header(conn, "service-worker-allowed", "/")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render(%{status: status} = conn, template, assigns) do
|
||||||
|
body =
|
||||||
|
@templates
|
||||||
|
|> Map.fetch!(template)
|
||||||
|
|> EEx.eval_string(assigns)
|
||||||
|
|
||||||
|
send_resp(conn, status || 200, body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def put_secret_key_base(conn, _) do
|
||||||
|
put_in(conn.secret_key_base, Application.fetch_env!(:jenot, :secret_key_base))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -37,6 +37,8 @@ defmodule Jenot.MixProject do
|
||||||
[
|
[
|
||||||
{:burrito, "~> 1.0"},
|
{:burrito, "~> 1.0"},
|
||||||
{:bandit, "~> 1.0"},
|
{:bandit, "~> 1.0"},
|
||||||
|
{:bcrypt_elixir, "~> 3.0"},
|
||||||
|
{:hahash, "~> 0.2.0"},
|
||||||
{:web_push_elixir, "~> 0.4.0"},
|
{:web_push_elixir, "~> 0.4.0"},
|
||||||
{:ecto_sqlite3, "~> 0.17"},
|
{:ecto_sqlite3, "~> 0.17"},
|
||||||
{:plug_live_reload, "~> 0.1.0", only: :dev}
|
{:plug_live_reload, "~> 0.1.0", only: :dev}
|
||||||
|
|
|
||||||
3
mix.lock
3
mix.lock
|
|
@ -1,8 +1,10 @@
|
||||||
%{
|
%{
|
||||||
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
|
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
|
||||||
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"},
|
||||||
"burrito": {:hex, :burrito, "1.2.0", "88f973469edcb96bd984498fb639d3fc4dbf01b52baab072b40229f03a396789", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "7e22158023c6558de615795ab135d27f0cbd9a0602834e3e474fe41b448afba9"},
|
"burrito": {:hex, :burrito, "1.2.0", "88f973469edcb96bd984498fb639d3fc4dbf01b52baab072b40229f03a396789", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "7e22158023c6558de615795ab135d27f0cbd9a0602834e3e474fe41b448afba9"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
|
||||||
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
|
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
|
||||||
|
"comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"},
|
||||||
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
|
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
|
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
|
||||||
|
|
@ -16,6 +18,7 @@
|
||||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||||
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
|
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
|
||||||
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
||||||
|
"hahash": {:hex, :hahash, "0.2.0", "5d21c44b1b65d1f591adfd20ea4720e8a81db3caa377c28b20b434b8afc2115f", [:mix], [], "hexpm", "c39ce3f6163bbcf668e7a0bef88b608a9f37f99fcf181e7a17e4d10d02a5fbbd"},
|
||||||
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
|
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
|
||||||
"httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
|
"httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
|
||||||
def change do
|
def change do
|
||||||
create table(:accounts, primary_key: false) do
|
create table(:accounts, primary_key: false) do
|
||||||
add :id, :uuid, primary_key: true
|
add :id, :uuid, primary_key: true
|
||||||
|
add :name, :text, null: false
|
||||||
|
add :code_digest, :text, null: false
|
||||||
|
|
||||||
timestamps(type: :datetime_usec)
|
timestamps(type: :datetime_usec)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
defmodule Jenot.Repo.Migrations.AddDefaultAccount do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def change do
|
|
||||||
id = Ecto.UUID.generate()
|
|
||||||
now = DateTime.utc_now() |> DateTime.to_iso8601()
|
|
||||||
|
|
||||||
execute """
|
|
||||||
INSERT INTO accounts (id, inserted_at, updated_at)
|
|
||||||
VALUES ('#{id}', '#{now}', '#{now}')
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
DELETE FROM accounts
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -30,6 +30,13 @@
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<h1>Jenot</h1>
|
<h1>Jenot</h1>
|
||||||
|
|
||||||
|
<a id="login-link" class="hidden" href="/account/authenticate">Log in</a>
|
||||||
|
<form id="new-account-form" class="hidden" action="/account/new" method="post">
|
||||||
|
<button type="submit">New account</button>
|
||||||
|
</form>
|
||||||
|
<form id="logout-form" class="hidden" action="/account/logout" method="post">
|
||||||
|
<button type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
<button id="enable-notifications" class="hidden"">Enable notifications</button>
|
<button id="enable-notifications" class="hidden"">Enable notifications</button>
|
||||||
<button id="test-notifications">Send test notification</button>
|
<button id="test-notifications">Send test notification</button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import "./service-worker-init.js";
|
import "./service-worker-init.js";
|
||||||
import { renderText } from "./dom.js";
|
import { renderText } from "./dom.js";
|
||||||
import { LocalNoteStore } from "./local-store.js";
|
|
||||||
import { SyncedNoteStore } from "./synced-store.js";
|
import { SyncedNoteStore } from "./synced-store.js";
|
||||||
import {
|
import {
|
||||||
authorizeNotifications,
|
authorizeNotifications,
|
||||||
|
|
@ -11,12 +10,25 @@ import "./components.js";
|
||||||
|
|
||||||
const URL_PARAMS = new URLSearchParams(window.location.search);
|
const URL_PARAMS = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Cookie presence determines login state
|
||||||
|
const isLoggedIn = !!document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("jenot_pub="));
|
||||||
|
|
||||||
// Notes storage configuration.
|
// Notes storage configuration.
|
||||||
// Currently supports either simple, local storage based implementation
|
// The storage is a combination of IndexedDB + network sync.
|
||||||
// and a more elaborate one, using a combination of IndexedDB + network sync.
|
// Network sync is only enabled is user is logged in.
|
||||||
const Notes = URL_PARAMS.has("localStorage")
|
const Notes = new SyncedNoteStore("jenot-app", "notes", isLoggedIn && "/");
|
||||||
? new LocalNoteStore("jenot-app")
|
|
||||||
: new SyncedNoteStore("jenot-app", "notes", "/");
|
// Reset metadata to force full sync
|
||||||
|
if (URL_PARAMS.has("reset-meta")) {
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
location.href.replace("&reset-meta", "").replace("?reset-meta", ""),
|
||||||
|
);
|
||||||
|
await Notes.setMeta({ lastSync: null });
|
||||||
|
}
|
||||||
|
|
||||||
// Very rudimentary periodic sync. It will be refactored into a more real-time
|
// Very rudimentary periodic sync. It will be refactored into a more real-time
|
||||||
// solution using either websocket of long-polling, so that server can notify about
|
// solution using either websocket of long-polling, so that server can notify about
|
||||||
|
|
@ -26,7 +38,22 @@ const sync = async () => {
|
||||||
Notes.saveStorage();
|
Notes.saveStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
setInterval(sync, 5000);
|
if (isLoggedIn) {
|
||||||
|
setInterval(sync, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New account provisioning and login/logout actions
|
||||||
|
|
||||||
|
const newAccountForm = document.querySelector("#new-account-form");
|
||||||
|
const loginLink = document.querySelector("#login-link");
|
||||||
|
const logoutForm = document.querySelector("#logout-form");
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
loginLink.classList.remove("hidden");
|
||||||
|
newAccountForm.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
logoutForm.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
// Notifications API test - to be reused for push notifications later on
|
// Notifications API test - to be reused for push notifications later on
|
||||||
|
|
||||||
|
|
@ -58,7 +85,9 @@ Notes.addEventListener("save", render.bind(this));
|
||||||
|
|
||||||
// Initial notes render and initial sync.
|
// Initial notes render and initial sync.
|
||||||
render();
|
render();
|
||||||
sync();
|
if (isLoggedIn) {
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
// note-form component specific event handlers
|
// note-form component specific event handlers
|
||||||
newNote.addEventListener("addNote", async (e) => {
|
newNote.addEventListener("addNote", async (e) => {
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
export class LocalNoteStore extends EventTarget {
|
|
||||||
localStorageKey;
|
|
||||||
notes = [];
|
|
||||||
|
|
||||||
/*
|
|
||||||
Note structure:
|
|
||||||
|
|
||||||
- id - unique note ID
|
|
||||||
- type - either `note` or `tasklist`
|
|
||||||
- content - note's content
|
|
||||||
- created - timestamp
|
|
||||||
- updated - timestamp
|
|
||||||
*/
|
|
||||||
|
|
||||||
constructor(localStorageKey) {
|
|
||||||
super();
|
|
||||||
this.localStorageKey = localStorageKey;
|
|
||||||
|
|
||||||
this.#readStorage();
|
|
||||||
|
|
||||||
// handle notes edited in another window
|
|
||||||
window.addEventListener(
|
|
||||||
"storage",
|
|
||||||
() => {
|
|
||||||
this.#readStorage();
|
|
||||||
this.saveStorage();
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
all = () => this.notes;
|
|
||||||
get = (id) => this.notes.find((note) => note.id === id);
|
|
||||||
|
|
||||||
add(note) {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
this.notes.unshift({
|
|
||||||
id: "id_" + now,
|
|
||||||
type: note.type,
|
|
||||||
content: note.content,
|
|
||||||
created: now,
|
|
||||||
updated: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
remove({ id }) {
|
|
||||||
this.notes = this.notes.filter((note) => note.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(note) {
|
|
||||||
note.updated = Date.now();
|
|
||||||
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
|
|
||||||
}
|
|
||||||
|
|
||||||
sync() {}
|
|
||||||
|
|
||||||
saveStorage() {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
this.localStorageKey + "_notes",
|
|
||||||
JSON.stringify(this.notes),
|
|
||||||
);
|
|
||||||
this.dispatchEvent(new CustomEvent("save"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#readStorage() {
|
|
||||||
this.notes = JSON.parse(
|
|
||||||
window.localStorage.getItem(this.localStorageKey + "_notes") || "[]",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -123,7 +123,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
|
|
||||||
async setMeta(meta) {
|
async setMeta(meta) {
|
||||||
meta.id = "meta";
|
meta.id = "meta";
|
||||||
this.update(meta, true);
|
return this.update(meta, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(note) {
|
async add(note) {
|
||||||
|
|
@ -193,7 +193,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
const lastSync = meta?.lastSync;
|
const lastSync = meta?.lastSync;
|
||||||
const currentSync = Date.now();
|
const currentSync = Date.now();
|
||||||
|
|
||||||
this.all(lastSync, true)
|
return this.all(lastSync, true)
|
||||||
.then((notes) => {
|
.then((notes) => {
|
||||||
return Promise.all(notes.map((n) => that.webStore.add(n)));
|
return Promise.all(notes.map((n) => that.webStore.add(n)));
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue