diff --git a/.formatter.exs b/.formatter.exs
index 8a6391c..ef8840c 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,5 +1,6 @@
[
- import_deps: [:ecto, :phoenix],
- inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
- subdirectories: ["priv/*/migrations"]
+ import_deps: [:ecto, :ecto_sql, :phoenix],
+ subdirectories: ["priv/*/migrations"],
+ plugins: [Phoenix.LiveView.HTMLFormatter],
+ inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]
diff --git a/.gitignore b/.gitignore
index 9575f30..fab46fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
+# Temporary files, for example, from tests.
+/tmp/
+
# Ignore package tarball (built via "mix hex.build").
link_shortener-*.tar
diff --git a/assets/css/app.css b/assets/css/app.css
index 19c2e51..378c8f9 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,120 +1,5 @@
-/* This file is for your main application CSS */
-@import "./phoenix.css";
-
-/* Alerts and form errors used by phx.new */
-.alert {
- padding: 15px;
- margin-bottom: 20px;
- border: 1px solid transparent;
- border-radius: 4px;
-}
-.alert-info {
- color: #31708f;
- background-color: #d9edf7;
- border-color: #bce8f1;
-}
-.alert-warning {
- color: #8a6d3b;
- background-color: #fcf8e3;
- border-color: #faebcc;
-}
-.alert-danger {
- color: #a94442;
- background-color: #f2dede;
- border-color: #ebccd1;
-}
-.alert p {
- margin-bottom: 0;
-}
-.alert:empty {
- display: none;
-}
-.invalid-feedback {
- color: #a94442;
- display: block;
- margin: -1rem 0 2rem;
-}
-
-/* LiveView specific classes for your customization */
-.phx-no-feedback.invalid-feedback,
-.phx-no-feedback .invalid-feedback {
- display: none;
-}
-
-.phx-click-loading {
- opacity: 0.5;
- transition: opacity 1s ease-out;
-}
-
-.phx-loading{
- cursor: wait;
-}
-
-.phx-modal {
- opacity: 1!important;
- position: fixed;
- z-index: 1;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- overflow: auto;
- background-color: rgba(0,0,0,0.4);
-}
-
-.phx-modal-content {
- background-color: #fefefe;
- margin: 15vh auto;
- padding: 20px;
- border: 1px solid #888;
- width: 80%;
-}
-
-.phx-modal-close {
- color: #aaa;
- float: right;
- font-size: 28px;
- font-weight: bold;
-}
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
-.phx-modal-close:hover,
-.phx-modal-close:focus {
- color: black;
- text-decoration: none;
- cursor: pointer;
-}
-
-.fade-in-scale {
- animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
-}
-
-.fade-out-scale {
- animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
-}
-
-.fade-in {
- animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
-}
-.fade-out {
- animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
-}
-
-@keyframes fade-in-scale-keys{
- 0% { scale: 0.95; opacity: 0; }
- 100% { scale: 1.0; opacity: 1; }
-}
-
-@keyframes fade-out-scale-keys{
- 0% { scale: 1.0; opacity: 1; }
- 100% { scale: 0.95; opacity: 0; }
-}
-
-@keyframes fade-in-keys{
- 0% { opacity: 0; }
- 100% { opacity: 1; }
-}
-
-@keyframes fade-out-keys{
- 0% { opacity: 1; }
- 100% { opacity: 0; }
-}
+/* This file is for your main application CSS */
diff --git a/assets/css/phoenix.css b/assets/css/phoenix.css
deleted file mode 100644
index 0d59050..0000000
--- a/assets/css/phoenix.css
+++ /dev/null
@@ -1,101 +0,0 @@
-/* Includes some default style for the starter application.
- * This can be safely deleted to start fresh.
- */
-
-/* Milligram v1.4.1 https://milligram.github.io
- * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license
- */
-
-*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}
-
-/* General style */
-h1{font-size: 3.6rem; line-height: 1.25}
-h2{font-size: 2.8rem; line-height: 1.3}
-h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35}
-h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5}
-h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4}
-h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2}
-pre{padding: 1em;}
-
-.container{
- margin: 0 auto;
- max-width: 80.0rem;
- padding: 0 2.0rem;
- position: relative;
- width: 100%
-}
-select {
- width: auto;
-}
-
-/* Phoenix promo and logo */
-.phx-hero {
- text-align: center;
- border-bottom: 1px solid #e3e3e3;
- background: #eee;
- border-radius: 6px;
- padding: 3em 3em 1em;
- margin-bottom: 3rem;
- font-weight: 200;
- font-size: 120%;
-}
-.phx-hero input {
- background: #ffffff;
-}
-.phx-logo {
- min-width: 300px;
- margin: 1rem;
- display: block;
-}
-.phx-logo img {
- width: auto;
- display: block;
-}
-
-/* Headers */
-header {
- width: 100%;
- background: #fdfdfd;
- border-bottom: 1px solid #eaeaea;
- margin-bottom: 2rem;
-}
-header section {
- align-items: center;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
-}
-header section :first-child {
- order: 2;
-}
-header section :last-child {
- order: 1;
-}
-header nav ul,
-header nav li {
- margin: 0;
- padding: 0;
- display: block;
- text-align: right;
- white-space: nowrap;
-}
-header nav ul {
- margin: 1rem;
- margin-top: 0;
-}
-header nav a {
- display: block;
-}
-
-@media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */
- header section {
- flex-direction: row;
- }
- header nav ul {
- margin: 1rem;
- }
- .phx-logo {
- flex-basis: 527px;
- margin: 2rem 1rem;
- }
-}
diff --git a/assets/js/app.js b/assets/js/app.js
index 2ca06a5..d5e278a 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,7 +1,3 @@
-// We import the CSS which is extracted to its own file by esbuild.
-// Remove this line if you add a your own CSS build pipeline (e.g postcss).
-import "../css/app.css"
-
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
@@ -27,12 +23,15 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
-let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
+let liveSocket = new LiveSocket("/live", Socket, {
+ longPollFallbackMs: 2500,
+ params: {_csrf_token: csrfToken}
+})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
-window.addEventListener("phx:page-loading-start", info => topbar.show())
-window.addEventListener("phx:page-loading-stop", info => topbar.hide())
+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
new file mode 100644
index 0000000..78011a6
--- /dev/null
+++ b/assets/tailwind.config.js
@@ -0,0 +1,74 @@
+// See the Tailwind configuration guide for advanced usage
+// https://tailwindcss.com/docs/configuration
+
+const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
+
+module.exports = {
+ content: [
+ "./js/**/*.js",
+ "../lib/link_shortener_web.ex",
+ "../lib/link_shortener_web/**/*.*ex"
+ ],
+ theme: {
+ extend: {
+ colors: {
+ brand: "#FD4F00",
+ }
+ },
+ },
+ plugins: [
+ require("@tailwindcss/forms"),
+ // Allows prefixing tailwind classes with LiveView classes to add rules
+ // only when LiveView classes are applied, for example:
+ //
+ //
+ //
+ plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
+ ]
+}
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
index 1f62209..4195727 100644
--- a/assets/vendor/topbar.js
+++ b/assets/vendor/topbar.js
@@ -1,6 +1,6 @@
/**
* @license MIT
- * topbar 1.0.0, 2021-01-06
+ * topbar 2.0.0, 2023-02-04
* https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
@@ -35,10 +35,11 @@
})();
var canvas,
- progressTimerId,
- fadeTimerId,
currentProgress,
showing,
+ progressTimerId = null,
+ fadeTimerId = null,
+ delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
@@ -95,21 +96,26 @@
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
- show: function () {
+ show: function (delay) {
if (showing) return;
- showing = true;
- if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
- if (!canvas) createCanvas();
- canvas.style.opacity = 1;
- canvas.style.display = "block";
- topbar.progress(0);
- if (options.autoRun) {
- (function loop() {
- progressTimerId = window.requestAnimationFrame(loop);
- topbar.progress(
- "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
- );
- })();
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
}
},
progress: function (to) {
@@ -125,6 +131,8 @@
return currentProgress;
},
hide: function () {
+ clearTimeout(delayTimerId);
+ delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
diff --git a/config/config.exs b/config/config.exs
index cb4bbb8..8c29002 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -8,14 +8,19 @@
import Config
config :link_shortener,
- ecto_repos: [LinkShortener.Repo]
+ ecto_repos: [LinkShortener.Repo],
+ generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :link_shortener, LinkShortenerWeb.Endpoint,
url: [host: "localhost"],
- render_errors: [view: LinkShortenerWeb.ErrorView, accepts: ~w(html json), layout: false],
+ adapter: Bandit.PhoenixAdapter,
+ render_errors: [
+ formats: [html: LinkShortenerWeb.ErrorHTML, json: LinkShortenerWeb.ErrorJSON],
+ layout: false
+ ],
pubsub_server: LinkShortener.PubSub,
- live_view: [signing_salt: "8wxfzzEQ"]
+ live_view: [signing_salt: "+S5BXaoX"]
# Configures the mailer
#
@@ -26,19 +31,28 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
# at the `config/runtime.exs`.
config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Local
-# Swoosh API client is needed for adapters other than SMTP.
-config :swoosh, :api_client, false
-
# Configure esbuild (the version is required)
config :esbuild,
- version: "0.14.29",
- default: [
+ version: "0.17.11",
+ link_shortener: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
+# Configure tailwind (the version is required)
+config :tailwind,
+ version: "3.4.3",
+ link_shortener: [
+ args: ~w(
+ --config=tailwind.config.js
+ --input=css/app.css
+ --output=../priv/static/assets/app.css
+ ),
+ cd: Path.expand("../assets", __DIR__)
+ ]
+
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
@@ -47,10 +61,6 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
-config :link_shortener, LinkShortenerWeb.Auth.Guardian,
- issuer: "link_shortener",
- secret_key: System.get_env("SECRET_KEY_BASE")
-
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
index 1ef51ea..eca34ca 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -2,10 +2,10 @@ import Config
# Configure your database
config :link_shortener, LinkShortener.Repo,
- database: System.get_env("DATABASE_NAME"),
username: System.get_env("DATABASE_USERNAME"),
password: System.get_env("DATABASE_PASSWORD"),
hostname: System.get_env("DATABASE_HOST"),
+ database: System.get_env("DATABASE_NAME"),
port: System.get_env("DATABASE_PORT"),
stacktrace: true,
show_sensitive_data_on_connection_error: true,
@@ -15,8 +15,8 @@ config :link_shortener, LinkShortener.Repo,
# debugging and code reloading.
#
# The watchers configuration can be used to run external
-# watchers to your application. For example, we use it
-# with esbuild to bundle .js and .css sources.
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
config :link_shortener, LinkShortenerWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
@@ -26,8 +26,8 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
debug_errors: true,
secret_key_base: System.get_env("SECRET_KEY_BASE"),
watchers: [
- # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
- esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
+ esbuild: {Esbuild, :install_and_run, [:link_shortener, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:link_shortener, ~w(--watch)]}
]
# ## SSL Support
@@ -38,7 +38,6 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
#
# mix phx.gen.cert
#
-# Note that this task requires Erlang/OTP 20 or later.
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
@@ -58,13 +57,15 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
config :link_shortener, LinkShortenerWeb.Endpoint,
live_reload: [
patterns: [
- ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
- ~r"lib/link_shortener_web/(live|views)/.*(ex)$",
- ~r"lib/link_shortener_web/templates/.*(eex)$"
+ ~r"lib/link_shortener_web/(controllers|live|components)/.*(ex|heex)$"
]
]
+# Enable dev routes for dashboard and mailbox
+config :link_shortener, dev_routes: true
+
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
@@ -74,3 +75,12 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
+
+config :phoenix_live_view,
+ # Include HEEx debug annotations as HTML comments in rendered markup
+ debug_heex_annotations: true,
+ # Enable helpful, but potentially expensive runtime checks
+ enable_expensive_runtime_checks: true
+
+# Disable swoosh api client as it is only required for production adapters.
+config :swoosh, :api_client, false
diff --git a/config/runtime.exs b/config/runtime.exs
index 51b6f0b..e16096e 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -28,7 +28,7 @@ if config_env() == :prod do
For example: ecto://USER:PASS@HOST/DATABASE
"""
- maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :link_shortener, LinkShortener.Repo,
# ssl: true,
@@ -51,18 +51,52 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
+ config :link_shortener, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
config :link_shortener, LinkShortenerWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
- # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
+ # ## SSL Support
+ #
+ # To get SSL working, you will need to add the `https` key
+ # to your endpoint configuration:
+ #
+ # config :link_shortener, LinkShortenerWeb.Endpoint,
+ # https: [
+ # ...,
+ # port: 443,
+ # cipher_suite: :strong,
+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
+ # ]
+ #
+ # The `cipher_suite` is set to `:strong` to support only the
+ # latest and more secure SSL ciphers. This means old browsers
+ # and clients may not be supported. You can set it to
+ # `:compatible` for wider support.
+ #
+ # `:keyfile` and `:certfile` expect an absolute path to the key
+ # and cert in disk or a relative path inside priv, for example
+ # "priv/ssl/server.key". For all supported SSL configuration
+ # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
+ #
+ # We also recommend setting `force_ssl` in your config/prod.exs,
+ # ensuring no data is ever sent via http, always redirecting to https:
+ #
+ # config :link_shortener, LinkShortenerWeb.Endpoint,
+ # force_ssl: [hsts: true]
+ #
+ # Check `Plug.SSL` for all available options in `force_ssl`.
+
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
diff --git a/config/test.exs b/config/test.exs
index fb26508..383270d 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,8 +1,5 @@
import Config
-# Only in tests, remove the complexity from the password hashing algorithm
-config :bcrypt_elixir, :log_rounds, 1
-
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
@@ -14,7 +11,7 @@ config :link_shortener, LinkShortener.Repo,
hostname: System.get_env("DATABASE_HOST"),
database: "link_shortener_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
- pool_size: 10
+ pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
@@ -23,11 +20,18 @@ config :link_shortener, LinkShortenerWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE"),
server: false
-# In test we don't send emails.
+# In test we don't send emails
config :link_shortener, LinkShortener.Mailer, adapter: Swoosh.Adapters.Test
+# Disable swoosh api client as it is only required for production adapters
+config :swoosh, :api_client, false
+
# Print only warnings and errors during test
-config :logger, level: :warn
+config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+ enable_expensive_runtime_checks: true
diff --git a/lib/link_shortener/accounts/accounts.ex b/lib/link_shortener/accounts/accounts.ex
deleted file mode 100644
index bdd1b86..0000000
--- a/lib/link_shortener/accounts/accounts.ex
+++ /dev/null
@@ -1,353 +0,0 @@
-defmodule LinkShortener.Accounts do
- @moduledoc """
- The Accounts context.
- """
-
- import Ecto.Query, warn: false
- alias LinkShortener.Repo
-
- alias LinkShortener.Accounts.{User, UserToken, UserNotifier}
-
- ## Database getters
-
- @doc """
- Gets a user by email.
-
- ## Examples
-
- iex> get_user_by_email("foo@example.com")
- %User{}
-
- iex> get_user_by_email("unknown@example.com")
- nil
-
- """
- def get_user_by_email(email) when is_binary(email) do
- Repo.get_by(User, email: email)
- end
-
- @doc """
- Gets a user by email and password.
-
- ## Examples
-
- iex> get_user_by_email_and_password("foo@example.com", "correct_password")
- %User{}
-
- iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
- nil
-
- """
- def get_user_by_email_and_password(email, password)
- when is_binary(email) and is_binary(password) do
- user = Repo.get_by(User, email: email)
- if User.valid_password?(user, password), do: user
- end
-
- @doc """
- Gets a single user.
-
- Raises `Ecto.NoResultsError` if the User does not exist.
-
- ## Examples
-
- iex> get_user!(123)
- %User{}
-
- iex> get_user!(456)
- ** (Ecto.NoResultsError)
-
- """
- def get_user!(id), do: Repo.get!(User, id)
-
- ## User registration
-
- @doc """
- Registers a user.
-
- ## Examples
-
- iex> register_user(%{field: value})
- {:ok, %User{}}
-
- iex> register_user(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def register_user(attrs) do
- %User{}
- |> User.registration_changeset(attrs)
- |> Repo.insert()
- end
-
- @doc """
- Returns an `%Ecto.Changeset{}` for tracking user changes.
-
- ## Examples
-
- iex> change_user_registration(user)
- %Ecto.Changeset{data: %User{}}
-
- """
- def change_user_registration(%User{} = user, attrs \\ %{}) do
- User.registration_changeset(user, attrs, hash_password: false)
- end
-
- ## Settings
-
- @doc """
- Returns an `%Ecto.Changeset{}` for changing the user email.
-
- ## Examples
-
- iex> change_user_email(user)
- %Ecto.Changeset{data: %User{}}
-
- """
- def change_user_email(user, attrs \\ %{}) do
- User.email_changeset(user, attrs)
- end
-
- @doc """
- Emulates that the email will change without actually changing
- it in the database.
-
- ## Examples
-
- iex> apply_user_email(user, "valid password", %{email: ...})
- {:ok, %User{}}
-
- iex> apply_user_email(user, "invalid password", %{email: ...})
- {:error, %Ecto.Changeset{}}
-
- """
- def apply_user_email(user, password, attrs) do
- user
- |> User.email_changeset(attrs)
- |> User.validate_current_password(password)
- |> Ecto.Changeset.apply_action(:update)
- end
-
- @doc """
- Updates the user email using the given token.
-
- If the token matches, the user email is updated and the token is deleted.
- The confirmed_at date is also updated to the current time.
- """
- def update_user_email(user, token) do
- context = "change:#{user.email}"
-
- with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
- %UserToken{sent_to: email} <- Repo.one(query),
- {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
- :ok
- else
- _ -> :error
- end
- end
-
- defp user_email_multi(user, email, context) do
- changeset =
- user
- |> User.email_changeset(%{email: email})
- |> User.confirm_changeset()
-
- Ecto.Multi.new()
- |> Ecto.Multi.update(:user, changeset)
- |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
- end
-
- @doc """
- Delivers the update email instructions to the given user.
-
- ## Examples
-
- iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
- {:ok, %{to: ..., body: ...}}
-
- """
- def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
- when is_function(update_email_url_fun, 1) do
- {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
-
- Repo.insert!(user_token)
- UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
- end
-
- @doc """
- Returns an `%Ecto.Changeset{}` for changing the user password.
-
- ## Examples
-
- iex> change_user_password(user)
- %Ecto.Changeset{data: %User{}}
-
- """
- def change_user_password(user, attrs \\ %{}) do
- User.password_changeset(user, attrs, hash_password: false)
- end
-
- @doc """
- Updates the user password.
-
- ## Examples
-
- iex> update_user_password(user, "valid password", %{password: ...})
- {:ok, %User{}}
-
- iex> update_user_password(user, "invalid password", %{password: ...})
- {:error, %Ecto.Changeset{}}
-
- """
- def update_user_password(user, password, attrs) do
- changeset =
- user
- |> User.password_changeset(attrs)
- |> User.validate_current_password(password)
-
- Ecto.Multi.new()
- |> Ecto.Multi.update(:user, changeset)
- |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
- |> Repo.transaction()
- |> case do
- {:ok, %{user: user}} -> {:ok, user}
- {:error, :user, changeset, _} -> {:error, changeset}
- end
- end
-
- ## Session
-
- @doc """
- Generates a session token.
- """
- def generate_user_session_token(user) do
- {token, user_token} = UserToken.build_session_token(user)
- Repo.insert!(user_token)
- token
- end
-
- @doc """
- Gets the user with the given signed token.
- """
- def get_user_by_session_token(token) do
- {:ok, query} = UserToken.verify_session_token_query(token)
- Repo.one(query)
- end
-
- @doc """
- Deletes the signed token with the given context.
- """
- def delete_session_token(token) do
- Repo.delete_all(UserToken.token_and_context_query(token, "session"))
- :ok
- end
-
- ## Confirmation
-
- @doc """
- Delivers the confirmation email instructions to the given user.
-
- ## Examples
-
- iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1))
- {:ok, %{to: ..., body: ...}}
-
- iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1))
- {:error, :already_confirmed}
-
- """
- def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
- when is_function(confirmation_url_fun, 1) do
- if user.confirmed_at do
- {:error, :already_confirmed}
- else
- {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
- Repo.insert!(user_token)
- UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
- end
- end
-
- @doc """
- Confirms a user by the given token.
-
- If the token matches, the user account is marked as confirmed
- and the token is deleted.
- """
- def confirm_user(token) do
- with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
- %User{} = user <- Repo.one(query),
- {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
- {:ok, user}
- else
- _ -> :error
- end
- end
-
- defp confirm_user_multi(user) do
- Ecto.Multi.new()
- |> Ecto.Multi.update(:user, User.confirm_changeset(user))
- |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
- end
-
- ## Reset password
-
- @doc """
- Delivers the reset password email to the given user.
-
- ## Examples
-
- iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
- {:ok, %{to: ..., body: ...}}
-
- """
- def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
- when is_function(reset_password_url_fun, 1) do
- {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
- Repo.insert!(user_token)
- UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
- end
-
- @doc """
- Gets the user by reset password token.
-
- ## Examples
-
- iex> get_user_by_reset_password_token("validtoken")
- %User{}
-
- iex> get_user_by_reset_password_token("invalidtoken")
- nil
-
- """
- def get_user_by_reset_password_token(token) do
- with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
- %User{} = user <- Repo.one(query) do
- user
- else
- _ -> nil
- end
- end
-
- @doc """
- Resets the user password.
-
- ## Examples
-
- iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
- {:ok, %User{}}
-
- iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
- {:error, %Ecto.Changeset{}}
-
- """
- def reset_user_password(user, attrs) do
- Ecto.Multi.new()
- |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
- |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
- |> Repo.transaction()
- |> case do
- {:ok, %{user: user}} -> {:ok, user}
- {:error, :user, changeset, _} -> {:error, changeset}
- end
- end
-end
diff --git a/lib/link_shortener/accounts/user.ex b/lib/link_shortener/accounts/user.ex
deleted file mode 100644
index 7487093..0000000
--- a/lib/link_shortener/accounts/user.ex
+++ /dev/null
@@ -1,140 +0,0 @@
-defmodule LinkShortener.Accounts.User do
- use Ecto.Schema
- import Ecto.Changeset
-
- schema "users" do
- field :email, :string
- field :password, :string, virtual: true, redact: true
- field :hashed_password, :string, redact: true
- field :confirmed_at, :naive_datetime
-
- timestamps()
- end
-
- @doc """
- A user changeset for registration.
-
- It is important to validate the length of both email and password.
- Otherwise databases may truncate the email without warnings, which
- could lead to unpredictable or insecure behaviour. Long passwords may
- also be very expensive to hash for certain algorithms.
-
- ## Options
-
- * `:hash_password` - Hashes the password so it can be stored securely
- in the database and ensures the password field is cleared to prevent
- leaks in the logs. If password hashing is not needed and clearing the
- password field is not desired (like when using this changeset for
- validations on a LiveView form), this option can be set to `false`.
- Defaults to `true`.
- """
- def registration_changeset(user, attrs, opts \\ []) do
- user
- |> cast(attrs, [:email, :password])
- |> validate_email()
- |> validate_password(opts)
- end
-
- defp validate_email(changeset) do
- changeset
- |> validate_required([:email])
- |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
- |> validate_length(:email, max: 160)
- |> unsafe_validate_unique(:email, LinkShortener.Repo)
- |> unique_constraint(:email)
- end
-
- defp validate_password(changeset, opts) do
- changeset
- |> validate_required([:password])
- |> validate_length(:password, min: 12, max: 72)
- # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
- # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
- # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
- |> maybe_hash_password(opts)
- end
-
- defp maybe_hash_password(changeset, opts) do
- hash_password? = Keyword.get(opts, :hash_password, true)
- password = get_change(changeset, :password)
-
- if hash_password? && password && changeset.valid? do
- changeset
- # If using Bcrypt, then further validate it is at most 72 bytes long
- |> validate_length(:password, max: 72, count: :bytes)
- |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
- |> delete_change(:password)
- else
- changeset
- end
- end
-
- @doc """
- A user changeset for changing the email.
-
- It requires the email to change otherwise an error is added.
- """
- def email_changeset(user, attrs) do
- user
- |> cast(attrs, [:email])
- |> validate_email()
- |> case do
- %{changes: %{email: _}} = changeset -> changeset
- %{} = changeset -> add_error(changeset, :email, "did not change")
- end
- end
-
- @doc """
- A user changeset for changing the password.
-
- ## Options
-
- * `:hash_password` - Hashes the password so it can be stored securely
- in the database and ensures the password field is cleared to prevent
- leaks in the logs. If password hashing is not needed and clearing the
- password field is not desired (like when using this changeset for
- validations on a LiveView form), this option can be set to `false`.
- Defaults to `true`.
- """
- def password_changeset(user, attrs, opts \\ []) do
- user
- |> cast(attrs, [:password])
- |> validate_confirmation(:password, message: "does not match password")
- |> validate_password(opts)
- end
-
- @doc """
- Confirms the account by setting `confirmed_at`.
- """
- def confirm_changeset(user) do
- now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
- change(user, confirmed_at: now)
- end
-
- @doc """
- Verifies the password.
-
- If there is no user or the user doesn't have a password, we call
- `Bcrypt.no_user_verify/0` to avoid timing attacks.
- """
- def valid_password?(%LinkShortener.Accounts.User{hashed_password: hashed_password}, password)
- when is_binary(hashed_password) and byte_size(password) > 0 do
- Bcrypt.verify_pass(password, hashed_password)
- end
-
- def valid_password?(_, _) do
- Bcrypt.no_user_verify()
- false
- end
-
- @doc """
- Validates the current password otherwise adds an error to the changeset.
- """
- def validate_current_password(changeset, password) do
- if valid_password?(changeset.data, password) do
- changeset
- else
- add_error(changeset, :current_password, "is not valid")
- end
- end
-end
diff --git a/lib/link_shortener/accounts/user_notifier.ex b/lib/link_shortener/accounts/user_notifier.ex
deleted file mode 100644
index fca5c98..0000000
--- a/lib/link_shortener/accounts/user_notifier.ex
+++ /dev/null
@@ -1,79 +0,0 @@
-defmodule LinkShortener.Accounts.UserNotifier do
- import Swoosh.Email
-
- alias LinkShortener.Mailer
-
- # Delivers the email using the application mailer.
- defp deliver(recipient, subject, body) do
- email =
- new()
- |> to(recipient)
- |> from({"LinkShortener", "contact@example.com"})
- |> subject(subject)
- |> text_body(body)
-
- with {:ok, _metadata} <- Mailer.deliver(email) do
- {:ok, email}
- end
- end
-
- @doc """
- Deliver instructions to confirm account.
- """
- def deliver_confirmation_instructions(user, url) do
- deliver(user.email, "Confirmation instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can confirm your account by visiting the URL below:
-
- #{url}
-
- If you didn't create an account with us, please ignore this.
-
- ==============================
- """)
- end
-
- @doc """
- Deliver instructions to reset a user password.
- """
- def deliver_reset_password_instructions(user, url) do
- deliver(user.email, "Reset password instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can reset your password by visiting the URL below:
-
- #{url}
-
- If you didn't request this change, please ignore this.
-
- ==============================
- """)
- end
-
- @doc """
- Deliver instructions to update a user email.
- """
- def deliver_update_email_instructions(user, url) do
- deliver(user.email, "Update email instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can change your email by visiting the URL below:
-
- #{url}
-
- If you didn't request this change, please ignore this.
-
- ==============================
- """)
- end
-end
diff --git a/lib/link_shortener/accounts/user_token.ex b/lib/link_shortener/accounts/user_token.ex
deleted file mode 100644
index d5b739f..0000000
--- a/lib/link_shortener/accounts/user_token.ex
+++ /dev/null
@@ -1,179 +0,0 @@
-defmodule LinkShortener.Accounts.UserToken do
- use Ecto.Schema
- import Ecto.Query
- alias LinkShortener.Accounts.UserToken
-
- @hash_algorithm :sha256
- @rand_size 32
-
- # It is very important to keep the reset password token expiry short,
- # since someone with access to the email may take over the account.
- @reset_password_validity_in_days 1
- @confirm_validity_in_days 7
- @change_email_validity_in_days 7
- @session_validity_in_days 60
-
- schema "users_tokens" do
- field :token, :binary
- field :context, :string
- field :sent_to, :string
- belongs_to :user, LinkShortener.Accounts.User
-
- timestamps(updated_at: false)
- end
-
- @doc """
- Generates a token that will be stored in a signed place,
- such as session or cookie. As they are signed, those
- tokens do not need to be hashed.
-
- The reason why we store session tokens in the database, even
- though Phoenix already provides a session cookie, is because
- Phoenix' default session cookies are not persisted, they are
- simply signed and potentially encrypted. This means they are
- valid indefinitely, unless you change the signing/encryption
- salt.
-
- Therefore, storing them allows individual user
- sessions to be expired. The token system can also be extended
- to store additional data, such as the device used for logging in.
- You could then use this information to display all valid sessions
- and devices in the UI and allow users to explicitly expire any
- session they deem invalid.
- """
- def build_session_token(user) do
- token = :crypto.strong_rand_bytes(@rand_size)
- {token, %UserToken{token: token, context: "session", user_id: user.id}}
- end
-
- @doc """
- Checks if the token is valid and returns its underlying lookup query.
-
- The query returns the user found by the token, if any.
-
- The token is valid if it matches the value in the database and it has
- not expired (after @session_validity_in_days).
- """
- def verify_session_token_query(token) do
- query =
- from token in token_and_context_query(token, "session"),
- join: user in assoc(token, :user),
- where: token.inserted_at > ago(@session_validity_in_days, "day"),
- select: user
-
- {:ok, query}
- end
-
- @doc """
- Builds a token and its hash to be delivered to the user's email.
-
- The non-hashed token is sent to the user email while the
- hashed part is stored in the database. The original token cannot be reconstructed,
- which means anyone with read-only access to the database cannot directly use
- the token in the application to gain access. Furthermore, if the user changes
- their email in the system, the tokens sent to the previous email are no longer
- valid.
-
- Users can easily adapt the existing code to provide other types of delivery methods,
- for example, by phone numbers.
- """
- def build_email_token(user, context) do
- build_hashed_token(user, context, user.email)
- end
-
- defp build_hashed_token(user, context, sent_to) do
- token = :crypto.strong_rand_bytes(@rand_size)
- hashed_token = :crypto.hash(@hash_algorithm, token)
-
- {Base.url_encode64(token, padding: false),
- %UserToken{
- token: hashed_token,
- context: context,
- sent_to: sent_to,
- user_id: user.id
- }}
- end
-
- @doc """
- Checks if the token is valid and returns its underlying lookup query.
-
- The query returns the user found by the token, if any.
-
- The given token is valid if it matches its hashed counterpart in the
- database and the user email has not changed. This function also checks
- if the token is being used within a certain period, depending on the
- context. The default contexts supported by this function are either
- "confirm", for account confirmation emails, and "reset_password",
- for resetting the password. For verifying requests to change the email,
- see `verify_change_email_token_query/2`.
- """
- def verify_email_token_query(token, context) do
- case Base.url_decode64(token, padding: false) do
- {:ok, decoded_token} ->
- hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
- days = days_for_context(context)
-
- query =
- from token in token_and_context_query(hashed_token, context),
- join: user in assoc(token, :user),
- where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
- select: user
-
- {:ok, query}
-
- :error ->
- :error
- end
- end
-
- defp days_for_context("confirm"), do: @confirm_validity_in_days
- defp days_for_context("reset_password"), do: @reset_password_validity_in_days
-
- @doc """
- Checks if the token is valid and returns its underlying lookup query.
-
- The query returns the user found by the token, if any.
-
- This is used to validate requests to change the user
- email. It is different from `verify_email_token_query/2` precisely because
- `verify_email_token_query/2` validates the email has not changed, which is
- the starting point by this function.
-
- The given token is valid if it matches its hashed counterpart in the
- database and if it has not expired (after @change_email_validity_in_days).
- The context must always start with "change:".
- """
- def verify_change_email_token_query(token, "change:" <> _ = context) do
- case Base.url_decode64(token, padding: false) do
- {:ok, decoded_token} ->
- hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
-
- query =
- from token in token_and_context_query(hashed_token, context),
- where: token.inserted_at > ago(@change_email_validity_in_days, "day")
-
- {:ok, query}
-
- :error ->
- :error
- end
- end
-
- @doc """
- Returns the token struct for the given token value and context.
- """
- def token_and_context_query(token, context) do
- from UserToken, where: [token: ^token, context: ^context]
- end
-
- @doc """
- Gets all tokens for the given user for the given contexts.
- """
- def user_and_contexts_query(user, :all) do
- from t in UserToken, where: t.user_id == ^user.id
- end
-
- def user_and_contexts_query(user, [_ | _] = contexts) do
- from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
- end
-end
diff --git a/lib/link_shortener/application.ex b/lib/link_shortener/application.ex
index b1d84ed..7f3a112 100644
--- a/lib/link_shortener/application.ex
+++ b/lib/link_shortener/application.ex
@@ -8,16 +8,16 @@ defmodule LinkShortener.Application do
@impl true
def start(_type, _args) do
children = [
- # Start the Ecto repository
- LinkShortener.Repo,
- # Start the Telemetry supervisor
LinkShortenerWeb.Telemetry,
- # Start the PubSub system
+ LinkShortener.Repo,
+ {DNSCluster, query: Application.get_env(:link_shortener, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: LinkShortener.PubSub},
- # Start the Endpoint (http/https)
- LinkShortenerWeb.Endpoint
+ # Start the Finch HTTP client for sending emails
+ {Finch, name: LinkShortener.Finch},
# Start a worker by calling: LinkShortener.Worker.start_link(arg)
- # {LinkShortener.Worker, arg}
+ # {LinkShortener.Worker, arg},
+ # Start to serve requests, typically the last entry
+ LinkShortenerWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
diff --git a/lib/link_shortener_web.ex b/lib/link_shortener_web.ex
index 19cb2ac..1c941ab 100644
--- a/lib/link_shortener_web.ex
+++ b/lib/link_shortener_web.ex
@@ -1,53 +1,60 @@
defmodule LinkShortenerWeb do
@moduledoc """
The entrypoint for defining your web interface, such
- as controllers, views, channels and so on.
+ as controllers, components, channels, and so on.
This can be used in your application as:
use LinkShortenerWeb, :controller
- use LinkShortenerWeb, :view
+ use LinkShortenerWeb, :html
- The definitions below will be executed for every view,
- controller, etc, so keep them short and clean, focused
+ The definitions below will be executed for every controller,
+ component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
- below. Instead, define any helper function in modules
- and import those modules here.
+ below. Instead, define additional modules and import
+ those modules here.
"""
- def controller do
+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+ def router do
quote do
- use Phoenix.Controller, namespace: LinkShortenerWeb
+ use Phoenix.Router, helpers: false
+ # Import common connection and controller functions to use in pipelines
import Plug.Conn
- import LinkShortenerWeb.Gettext
- alias LinkShortenerWeb.Router.Helpers, as: Routes
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ end
+ end
+
+ def channel do
+ quote do
+ use Phoenix.Channel
end
end
- def view do
+ def controller do
quote do
- use Phoenix.View,
- root: "lib/link_shortener_web/templates",
- namespace: LinkShortenerWeb
+ use Phoenix.Controller,
+ formats: [:html, :json],
+ layouts: [html: LinkShortenerWeb.Layouts]
- # Import convenience functions from controllers
- import Phoenix.Controller,
- only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
+ import Plug.Conn
+ import LinkShortenerWeb.Gettext
- # Include shared imports and aliases for views
- unquote(view_helpers())
+ unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView,
- layout: {LinkShortenerWeb.LayoutView, "live.html"}
+ layout: {LinkShortenerWeb.Layouts, :app}
- unquote(view_helpers())
+ unquote(html_helpers())
end
end
@@ -55,54 +62,50 @@ defmodule LinkShortenerWeb do
quote do
use Phoenix.LiveComponent
- unquote(view_helpers())
+ unquote(html_helpers())
end
end
- def component do
+ def html do
quote do
use Phoenix.Component
- unquote(view_helpers())
- end
- end
-
- def router do
- quote do
- use Phoenix.Router
+ # Import convenience functions from controllers
+ import Phoenix.Controller,
+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
- import Plug.Conn
- import Phoenix.Controller
- import Phoenix.LiveView.Router
+ # Include general helpers for rendering HTML
+ unquote(html_helpers())
end
end
- def channel do
+ defp html_helpers do
quote do
- use Phoenix.Channel
+ # HTML escaping functionality
+ import Phoenix.HTML
+ # Core UI components and translation
+ import LinkShortenerWeb.CoreComponents
import LinkShortenerWeb.Gettext
+
+ # Shortcut for generating JS commands
+ alias Phoenix.LiveView.JS
+
+ # Routes generation with the ~p sigil
+ unquote(verified_routes())
end
end
- defp view_helpers do
+ def verified_routes do
quote do
- # Use all HTML functionality (forms, tags, etc)
- use Phoenix.HTML
-
- # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
- import Phoenix.LiveView.Helpers
-
- # Import basic rendering functionality (render, render_layout, etc)
- import Phoenix.View
-
- import LinkShortenerWeb.ErrorHelpers
- import LinkShortenerWeb.Gettext
- alias LinkShortenerWeb.Router.Helpers, as: Routes
+ use Phoenix.VerifiedRoutes,
+ endpoint: LinkShortenerWeb.Endpoint,
+ router: LinkShortenerWeb.Router,
+ statics: LinkShortenerWeb.static_paths()
end
end
@doc """
- When used, dispatch to the appropriate controller/view/etc.
+ When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
diff --git a/lib/link_shortener_web/auth/error_handler.ex b/lib/link_shortener_web/auth/error_handler.ex
deleted file mode 100644
index da9fd5e..0000000
--- a/lib/link_shortener_web/auth/error_handler.ex
+++ /dev/null
@@ -1,10 +0,0 @@
-defmodule LinkShortenerWeb.Auth.ErrorHandler do
- import Plug.Conn
-
- def auth_error(conn, {type, _reason}, _opts) do
- body = Poison.encode!(%{error: to_string(type)})
- conn
- |> put_resp_content_type("application/json")
- |> send_resp(401, body)
- end
-end
diff --git a/lib/link_shortener_web/auth/guardian.ex b/lib/link_shortener_web/auth/guardian.ex
deleted file mode 100644
index c53ec3c..0000000
--- a/lib/link_shortener_web/auth/guardian.ex
+++ /dev/null
@@ -1,31 +0,0 @@
-defmodule LinkShortenerWeb.Auth.Guardian do
- use Guardian, otp_app: :link_shortener
-
- alias LinkShortener.Accounts
- alias LinkShortener.Accounts.User
-
- def subject_for_token(user, _claims) do
- sub = to_string(user.id)
- {:ok, sub}
- end
-
- def resource_from_claims(claims) do
- id = claims["sub"]
- resource = Accounts.get_user!(id)
- {:ok, resource}
- end
-
- def authenticate(email, password) do
- with user <- Accounts.get_user_by_email_and_password(email, password) do
- case user do
- %User{} -> create_token(user)
- nil -> {:error, :unauthorized}
- end
- end
- end
-
- defp create_token(user) do
- {:ok, token, _claims} = encode_and_sign(user)
- {:ok, user, token}
- end
-end
diff --git a/lib/link_shortener_web/auth/pipeline.ex b/lib/link_shortener_web/auth/pipeline.ex
deleted file mode 100644
index 51e8332..0000000
--- a/lib/link_shortener_web/auth/pipeline.ex
+++ /dev/null
@@ -1,9 +0,0 @@
-defmodule LinkShortenerWeb.Auth.Pipeline do
- use Guardian.Plug.Pipeline, otp_app: :link_shortener,
- module: LinkShortenerWeb.Auth.Guardian,
- error_handler: LinkShortenerWeb.Auth.ErrorHandler
-
- plug Guardian.Plug.VerifyHeader
- plug Guardian.Plug.EnsureAuthenticated
- plug Guardian.Plug.LoadResource
-end
diff --git a/lib/link_shortener_web/components/core_components.ex b/lib/link_shortener_web/components/core_components.ex
new file mode 100644
index 0000000..3fa847c
--- /dev/null
+++ b/lib/link_shortener_web/components/core_components.ex
@@ -0,0 +1,676 @@
+defmodule LinkShortenerWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components.
+
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as modals, tables, and
+ forms. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+
+ The default components use Tailwind CSS, a utility-first CSS framework.
+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+ how to customize them or feel free to swap in another framework altogether.
+
+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+ """
+ use Phoenix.Component
+
+ alias Phoenix.LiveView.JS
+ import LinkShortenerWeb.Gettext
+
+ @doc """
+ Renders a modal.
+
+ ## Examples
+
+ <.modal id="confirm-modal">
+ This is a modal.
+
+
+ JS commands may be passed to the `:on_cancel` to configure
+ the closing/cancel event, for example:
+
+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+ This is another modal.
+
+
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :on_cancel, JS, default: %JS{}
+ slot :inner_block, required: true
+
+ def modal(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @doc """
+ Shows the flash group with standard titles and content.
+
+ ## Examples
+
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+ def flash_group(assigns) do
+ ~H"""
+
+ <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
+ <.flash kind={:error} title={gettext("Error!")} flash={@flash} />
+ <.flash
+ id="client-error"
+ kind={:error}
+ title={gettext("We can't find the internet")}
+ phx-disconnected={show(".phx-client-error #client-error")}
+ phx-connected={hide("#client-error")}
+ hidden
+ >
+ <%= gettext("Attempting to reconnect") %>
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ <.flash
+ id="server-error"
+ kind={:error}
+ title={gettext("Something went wrong!")}
+ phx-disconnected={show(".phx-server-error #server-error")}
+ phx-connected={hide("#server-error")}
+ hidden
+ >
+ <%= gettext("Hang in there while we get back on track") %>
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ """
+ end
+
+ @doc """
+ Renders a simple form.
+
+ ## Examples
+
+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
+ <.input field={@form[:email]} label="Email"/>
+ <.input field={@form[:username]} label="Username" />
+ <:actions>
+ <.button>Save
+
+
+ """
+ attr :for, :any, required: true, doc: "the data structure for the form"
+ attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
+
+ attr :rest, :global,
+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
+ doc: "the arbitrary HTML attributes to apply to the form tag"
+
+ slot :inner_block, required: true
+ slot :actions, doc: "the slot for form actions, such as a submit button"
+
+ def simple_form(assigns) do
+ ~H"""
+ <.form :let={f} for={@for} as={@as} {@rest}>
+
+ <%= render_slot(@inner_block, f) %>
+
+ <%= render_slot(action, f) %>
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a button.
+
+ ## Examples
+
+ <.button>Send!
+ <.button phx-click="go" class="ml-2">Send!
+ """
+ attr :type, :string, default: nil
+ attr :class, :string, default: nil
+ attr :rest, :global, include: ~w(disabled form name value)
+
+ slot :inner_block, required: true
+
+ def button(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `