diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 61a7393..0000000 --- a/.dockerignore +++ /dev/null @@ -1,45 +0,0 @@ -# This file excludes paths from the Docker build context. -# -# By default, Docker's build context includes all files (and folders) in the -# current directory. Even if a file isn't copied into the container it is still sent to -# the Docker daemon. -# -# There are multiple reasons to exclude files from the build context: -# -# 1. Prevent nested folders from being copied into the container (ex: exclude -# /assets/node_modules when copying /assets) -# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) -# 3. Avoid sending files containing sensitive information -# -# More information on using .dockerignore is available here: -# https://docs.docker.com/engine/reference/builder/#dockerignore-file - -.dockerignore - -# Ignore git, but keep git HEAD and refs to access current commit hash if needed: -# -# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat -# d0b8727759e1e0e7aa3d41707d12376e373d5ecc -.git -!.git/HEAD -!.git/refs - -# Common development/test artifacts -/cover/ -/doc/ -/test/ -/tmp/ -.elixir_ls - -# Mix artifacts -/_build/ -/deps/ -*.ez - -# Generated on crash by the VM -erl_crash.dump - -# Static artifacts - These should be fetched and built inside the Docker image -/assets/node_modules/ -/priv/static/assets/ -/priv/static/cache_manifest.json diff --git a/.gitignore b/.gitignore index b74ec75..a90274c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,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"). something_erlang-*.tar diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 1f9c99b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,97 +0,0 @@ -# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian -# instead of Alpine to avoid DNS resolution issues in production. -# -# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu -# https://hub.docker.com/_/ubuntu?tab=tags -# -# This file is based on these images: -# -# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image -# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image -# - https://pkgs.org/ - resource for finding needed packages -# - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim -# -ARG ELIXIR_VERSION=1.16.2 -ARG OTP_VERSION=26.2.5 -ARG DEBIAN_VERSION=bullseye-20240513-slim - -ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" -ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" - -FROM ${BUILDER_IMAGE} as builder - -# install build dependencies -RUN apt-get update -y && apt-get install -y build-essential git \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# prepare build dir -WORKDIR /app - -# install hex + rebar -RUN mix local.hex --force && \ - mix local.rebar --force - -# set build ENV -ENV MIX_ENV="prod" - -# install mix dependencies -COPY mix.exs mix.lock ./ -RUN mix deps.get --only $MIX_ENV -RUN mkdir config - -# copy compile-time config files before we compile dependencies -# to ensure any relevant config change will trigger the dependencies -# to be re-compiled. -COPY config/config.exs config/${MIX_ENV}.exs config/ -RUN mix deps.compile - -COPY priv priv - -COPY lib lib - -COPY assets assets - -# compile assets -RUN mix assets.deploy - -# Compile the release -RUN mix compile - -# Changes to config/runtime.exs don't require recompiling the code -COPY config/runtime.exs config/ - -COPY rel rel -RUN mix release - -# start a new build stage so that the final image will only contain -# the compiled release and other runtime necessities -FROM ${RUNNER_IMAGE} - -RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# Set the locale -RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen - -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 - -WORKDIR "/app" -RUN chown nobody /app - -# set runner ENV -ENV MIX_ENV="prod" - -# Only copy the final release from the build stage -COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/something_erlang ./ - -USER nobody - -# If using an environment that doesn't automatically reap zombie processes, it is -# advised to add an init process such as tini via `apt-get install` -# above and adding an entrypoint. See https://github.com/krallin/tini for details -# ENTRYPOINT ["/tini", "--"] - -CMD ["sh", "-c", "/app/bin/migrate && /app/bin/server"] diff --git a/assets/css/app.css b/assets/css/app.css index 6216043..378c8f9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3,44 +3,3 @@ @import "tailwindcss/utilities"; /* This file is for your main application CSS */ - -body { - @apply bg-base-300 text-[14pt] leading-8 overflow-x-hidden; -} - -.post { - @apply bg-base-200 shadow-md rounded-md mb-4; - @apply grid grid-cols-[1fr] grid-rows-[min-content_1fr_auto]; - @apply sm:grid-cols-[13em_auto] sm:grid-rows-[1fr_auto]; -} -.post :where(article, .userinfo) { - @apply p-4 pb-0 sm:pb-4; -} - -.post .bbc-block { - @apply bg-base-300 p-4 py-2 border-l-2 border-secondary rounded w-full; -} -.post .bbc-block h4 { - @apply text-sm mb-2; -} -.post .bbc-spoiler { @apply bg-black text-black; } -.post .bbc-spoiler img { @apply invisible; } -.post .bbc-spoiler:hover { @apply text-inherit bg-inherit; } -.post .bbc-spoiler:hover img { @apply visible; } -.post .sa-smilie { @apply inline; } -.post iframe { - @apply w-full bg-[brown]; -} -.post .code { @apply mockup-code border-l-0; } -.post .code:before { @apply -ml-[2ch]; } -.post .code pre:before { @apply mr-0; } -.post .code h5 { @apply hidden; } -.post a[href] { @apply link; } -.post .editedby { @apply text-sm italic opacity-70 mt-4; } -.post .title :where(img[src*="gangtags"]) + * { - @apply mb-1; -} - -.pagination a svg { - @apply h-5; -} diff --git a/assets/js/app.js b/assets/js/app.js index 44a8122..d5e278a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,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.delayedShow(200)) -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/package-lock.json b/assets/package-lock.json new file mode 100644 index 0000000..6660148 --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,160 @@ +{ + "name": "assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "daisyui": "^4.11.1" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/culori": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz", + "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/daisyui": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.11.1.tgz", + "integrity": "sha512-obT9CUbQdW6eoHwSeT5VwaRrWlwrM4OT5qlfdJ0oQlSIEYhwnEl2+L2fwu5PioLbitwuMdYC2X8I1cyy8Pf6LQ==", + "dev": true, + "dependencies": { + "css-selector-tokenizer": "^0.8", + "culori": "^3", + "picocolors": "^1", + "postcss-js": "^4" + }, + "engines": { + "node": ">=16.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + } + }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/assets/package.json b/assets/package.json index 12f5515..7fdb326 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,7 +1,5 @@ { - "dependencies": { - "autoprefixer": "^10.4.13", - "daisyui": "^2.47.0", - "postcss": "^8.4.21" - } + "devDependencies": { + "daisyui": "^4.11.1" + } } diff --git a/assets/pnpm-lock.yaml b/assets/pnpm-lock.yaml deleted file mode 100644 index be623f9..0000000 --- a/assets/pnpm-lock.yaml +++ /dev/null @@ -1,547 +0,0 @@ -lockfileVersion: 5.4 - -specifiers: - autoprefixer: ^10.4.13 - daisyui: ^2.47.0 - postcss: ^8.4.21 - -dependencies: - autoprefixer: 10.4.13_postcss@8.4.21 - daisyui: 2.47.0_gbtt6ss3tbiz4yjtvdr6fbrj44 - postcss: 8.4.21 - -packages: - - /@nodelib/fs.scandir/2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: false - - /@nodelib/fs.stat/2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: false - - /@nodelib/fs.walk/1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.13.0 - dev: false - - /acorn-node/1.8.2: - resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} - dependencies: - acorn: 7.4.1 - acorn-walk: 7.2.0 - xtend: 4.0.2 - dev: false - - /acorn-walk/7.2.0: - resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} - engines: {node: '>=0.4.0'} - dev: false - - /acorn/7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: false - - /anymatch/3.1.2: - resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: false - - /arg/5.0.1: - resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==} - dev: false - - /autoprefixer/10.4.13_postcss@8.4.21: - resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - dependencies: - browserslist: 4.21.4 - caniuse-lite: 1.0.30001445 - fraction.js: 4.2.0 - normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.21 - postcss-value-parser: 4.2.0 - dev: false - - /binary-extensions/2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - dev: false - - /braces/3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.0.1 - dev: false - - /browserslist/4.21.4: - resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - caniuse-lite: 1.0.30001445 - electron-to-chromium: 1.4.284 - node-releases: 2.0.8 - update-browserslist-db: 1.0.10_browserslist@4.21.4 - dev: false - - /camelcase-css/2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - dev: false - - /caniuse-lite/1.0.30001445: - resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==} - dev: false - - /chokidar/3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.2 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.2 - dev: false - - /color-convert/2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: false - - /color-name/1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: false - - /color-string/1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 - dev: false - - /color/4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - dev: false - - /css-selector-tokenizer/0.8.0: - resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==} - dependencies: - cssesc: 3.0.0 - fastparse: 1.1.2 - dev: false - - /cssesc/3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - dev: false - - /daisyui/2.47.0_gbtt6ss3tbiz4yjtvdr6fbrj44: - resolution: {integrity: sha512-svZpXKldtHjXTEdj/lu2n7b+EQJSatqvmVB59k4dhCDOYUhUZ3jtGuPrgOJlPysHhDjxjCRWWug/fgV5e8tc/w==} - peerDependencies: - autoprefixer: ^10.0.2 - postcss: ^8.1.6 - dependencies: - autoprefixer: 10.4.13_postcss@8.4.21 - color: 4.2.3 - css-selector-tokenizer: 0.8.0 - postcss: 8.4.21 - postcss-js: 4.0.0_postcss@8.4.21 - tailwindcss: 3.0.24_postcss@8.4.21 - transitivePeerDependencies: - - ts-node - dev: false - - /defined/1.0.0: - resolution: {integrity: sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==} - dev: false - - /detective/5.2.0: - resolution: {integrity: sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==} - engines: {node: '>=0.8.0'} - hasBin: true - dependencies: - acorn-node: 1.8.2 - defined: 1.0.0 - minimist: 1.2.6 - dev: false - - /didyoumean/1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dev: false - - /dlv/1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dev: false - - /electron-to-chromium/1.4.284: - resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==} - dev: false - - /escalade/3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - dev: false - - /fast-glob/3.2.11: - resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: false - - /fastparse/1.1.2: - resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} - dev: false - - /fastq/1.13.0: - resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} - dependencies: - reusify: 1.0.4 - dev: false - - /fill-range/7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: false - - /fraction.js/4.2.0: - resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} - dev: false - - /fsevents/2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /function-bind/1.1.1: - resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - dev: false - - /glob-parent/5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: false - - /glob-parent/6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: false - - /has/1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - dependencies: - function-bind: 1.1.1 - dev: false - - /is-arrayish/0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} - dev: false - - /is-binary-path/2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - dependencies: - binary-extensions: 2.2.0 - dev: false - - /is-core-module/2.9.0: - resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} - dependencies: - has: 1.0.3 - dev: false - - /is-extglob/2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: false - - /is-glob/4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: false - - /is-number/7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: false - - /lilconfig/2.0.5: - resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} - engines: {node: '>=10'} - dev: false - - /merge2/1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: false - - /micromatch/4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.2 - picomatch: 2.3.1 - dev: false - - /minimist/1.2.6: - resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} - dev: false - - /nanoid/3.3.4: - resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: false - - /node-releases/2.0.8: - resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==} - dev: false - - /normalize-path/3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: false - - /normalize-range/0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - dev: false - - /object-hash/3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - dev: false - - /path-parse/1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: false - - /picocolors/1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: false - - /picomatch/2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: false - - /postcss-js/4.0.0_postcss@8.4.21: - resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.3.3 - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.21 - dev: false - - /postcss-load-config/3.1.4_postcss@8.4.21: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 2.0.5 - postcss: 8.4.21 - yaml: 1.10.2 - dev: false - - /postcss-nested/5.0.6_postcss@8.4.21: - resolution: {integrity: sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss: 8.4.21 - postcss-selector-parser: 6.0.10 - dev: false - - /postcss-selector-parser/6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - dev: false - - /postcss-value-parser/4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - dev: false - - /postcss/8.4.21: - resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.4 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: false - - /queue-microtask/1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: false - - /quick-lru/5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - dev: false - - /readdirp/3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: false - - /resolve/1.22.0: - resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} - hasBin: true - dependencies: - is-core-module: 2.9.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: false - - /reusify/1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: false - - /run-parallel/1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: false - - /simple-swizzle/0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} - dependencies: - is-arrayish: 0.3.2 - dev: false - - /source-map-js/1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - dev: false - - /supports-preserve-symlinks-flag/1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - dev: false - - /tailwindcss/3.0.24_postcss@8.4.21: - resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==} - engines: {node: '>=12.13.0'} - hasBin: true - peerDependencies: - postcss: ^8.0.9 - dependencies: - arg: 5.0.1 - chokidar: 3.5.3 - color-name: 1.1.4 - detective: 5.2.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.2.11 - glob-parent: 6.0.2 - is-glob: 4.0.3 - lilconfig: 2.0.5 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.21 - postcss-js: 4.0.0_postcss@8.4.21 - postcss-load-config: 3.1.4_postcss@8.4.21 - postcss-nested: 5.0.6_postcss@8.4.21 - postcss-selector-parser: 6.0.10 - postcss-value-parser: 4.2.0 - quick-lru: 5.1.1 - resolve: 1.22.0 - transitivePeerDependencies: - - ts-node - dev: false - - /to-regex-range/5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: false - - /update-browserslist-db/1.0.10_browserslist@4.21.4: - resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.21.4 - escalade: 3.1.1 - picocolors: 1.0.0 - dev: false - - /util-deprecate/1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: false - - /xtend/4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - dev: false - - /yaml/1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - dev: false diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 1275a92..96ac40d 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -2,12 +2,14 @@ // https://tailwindcss.com/docs/configuration const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") module.exports = { content: [ "./js/**/*.js", - "../lib/*_web.ex", - "../lib/*_web/**/*.*ex" + "../lib/something_erlang_web.ex", + "../lib/something_erlang_web/**/*.*ex" ], theme: { extend: { @@ -16,16 +18,59 @@ module.exports = { } }, }, - daisyui: { - themes: ["winter", "night"], - darkTheme: "night" - }, plugins: [ require("@tailwindcss/forms"), require("daisyui"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 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 &"])) + 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 4176ede..4195727 100644 --- a/assets/vendor/topbar.js +++ b/assets/vendor/topbar.js @@ -1,9 +1,7 @@ /** * @license MIT - * topbar 1.0.0, 2021-01-06 - * Modifications: - * - add delayedShow(time) (2022-09-21) - * http://buunguyen.github.io/topbar + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar * Copyright (c) 2021 Buu Nguyen */ (function (window, document) { @@ -98,26 +96,26 @@ for (var key in opts) if (options.hasOwnProperty(key)) options[key] = opts[key]; }, - delayedShow: function(time) { + show: function (delay) { if (showing) return; - if (delayTimerId) return; - delayTimerId = setTimeout(() => topbar.show(), time); - }, - show: function () { - 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) { diff --git a/config/config.exs b/config/config.exs index 2ec69c5..8f86234 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,7 +8,8 @@ import Config config :something_erlang, - ecto_repos: [SomethingErlang.Repo] + ecto_repos: [SomethingErlang.Repo], + generators: [timestamp_type: :utc_datetime] # Configures the endpoint config :something_erlang, SomethingErlangWeb.Endpoint, @@ -19,7 +20,7 @@ config :something_erlang, SomethingErlangWeb.Endpoint, layout: false ], pubsub_server: SomethingErlang.PubSub, - live_view: [signing_salt: "00UFDP60"] + live_view: [signing_salt: "FIHDRhv0"] # Configures the mailer # @@ -32,8 +33,8 @@ config :something_erlang, SomethingErlang.Mailer, adapter: Swoosh.Adapters.Local # Configure esbuild (the version is required) config :esbuild, - version: "0.14.41", - default: [ + version: "0.17.11", + something_erlang: [ args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), @@ -42,8 +43,8 @@ config :esbuild, # Configure tailwind (the version is required) config :tailwind, - version: "3.2.4", - default: [ + version: "3.4.0", + something_erlang: [ args: ~w( --config=tailwind.config.js --input=css/app.css diff --git a/config/dev.exs b/config/dev.exs index 82bd406..fca6e32 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -14,8 +14,8 @@ config :something_erlang, SomethingErlang.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 :something_erlang, SomethingErlangWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. @@ -23,10 +23,10 @@ config :something_erlang, SomethingErlangWeb.Endpoint, check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "uUSGthtWxUO9OOLlTaRq78iLoKgqFxonmAsZ5wmuLnnsKc1l3MVJkPDUNGu06+4Q", + secret_key_base: "s9ehTIW4uAHpl9HKL8cbxFTaH0o5qWqEiWsz3+xY/05YP5kw1uZppf459GFBTAKW", watchers: [ - esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, - tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + esbuild: {Esbuild, :install_and_run, [:something_erlang, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:something_erlang, ~w(--watch)]} ] # ## SSL Support @@ -56,7 +56,7 @@ config :something_erlang, SomethingErlangWeb.Endpoint, config :something_erlang, SomethingErlangWeb.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/something_erlang_web/(controllers|live|components)/.*(ex|heex)$" ] @@ -75,5 +75,11 @@ 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/prod.exs b/config/prod.exs index f1cf490..fe830be 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,19 +1,19 @@ import Config -# For production, don't forget to configure the url host -# to something meaningful, Phoenix uses this information -# when generating URLs. - # Note we also include the path to a cache manifest # containing the digested version of static files. This -# manifest is generated by the `mix phx.digest` task, +# manifest is generated by the `mix assets.deploy` task, # which you should run after static files are built and # before starting your production server. -config :something_erlang, SomethingErlangWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" +config :something_erlang, SomethingErlangWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" # Configures Swoosh API Client config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SomethingErlang.Finch +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + # Do not print debug messages in production config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs index ee1121b..7ddc3ee 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 :something_erlang, SomethingErlang.Repo, # ssl: true, @@ -51,12 +51,14 @@ if config_env() == :prod do host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000") + config :something_erlang, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + config :something_erlang, SomethingErlangWeb.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 @@ -87,8 +89,8 @@ if config_env() == :prod do # "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 endpoint, ensuring - # no data is ever sent via http, always redirecting to https: + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: # # config :something_erlang, SomethingErlangWeb.Endpoint, # force_ssl: [hsts: true] diff --git a/config/test.exs b/config/test.exs index 12883df..be6a4a7 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,13 +11,13 @@ config :something_erlang, SomethingErlang.Repo, hostname: "localhost", database: "something_erlang_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. config :something_erlang, SomethingErlangWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "hnSErwuszrqB3jBjmZVIAgb8D7m4nZPqti/6WDaL1pJi6l3/kQZY0Z4H4JAPadgF", + secret_key_base: "oeiuquQvGK/axIhwOn3+pqT6qnLh54bCQLlD10REdl/vYW9syU41aHafc9BEXP0f", server: false # In test we don't send emails. @@ -34,3 +31,7 @@ config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true diff --git a/lib/something_erlang/accounts.ex b/lib/something_erlang/accounts.ex deleted file mode 100644 index c6c03df..0000000 --- a/lib/something_erlang/accounts.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule SomethingErlang.Accounts do - @moduledoc """ - The Accounts context. - """ - - import Ecto.Query, warn: false - alias SomethingErlang.Repo - - alias SomethingErlang.AwfulApi.Client - alias SomethingErlang.Accounts.{User, UserToken} - - ## Database getters - - def get_user_by_bbuserid(bbuserid) when is_binary(bbuserid) do - Repo.get_by(User, :bbuserid, bbuserid) - end - - def get_or_create_user_by_bbuserid(bbuserid) - when is_binary(bbuserid) do - if user = Repo.get_by(User, bbuserid: bbuserid) do - user - else - %User{bbuserid: bbuserid} |> Repo.insert!() - end - end - - def login_sa_user_and_get_cookies(username, password) - when is_binary(username) and is_binary(password) do - case Client.login(username, password) do - %{bbuserid: userid, bbpassword: _hash} = bbuser -> - user = get_or_create_user_by_bbuserid(userid) - Map.merge(user, bbuser) - - _ -> - nil - end - 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) - 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_user_session_token(token) do - Repo.delete_all(UserToken.token_and_context_query(token, "session")) - :ok - end -end diff --git a/lib/something_erlang/accounts/user.ex b/lib/something_erlang/accounts/user.ex deleted file mode 100644 index d88fdc0..0000000 --- a/lib/something_erlang/accounts/user.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule SomethingErlang.Accounts.User do - use Ecto.Schema - import Ecto.Changeset - - schema "users" do - field :bbuserid, :string - - timestamps() - end - - def registration_changeset(user, attrs, _opts \\ []) do - user - |> cast(attrs, [:bbuserid]) - |> validate_required([:bbuserid]) - end -end diff --git a/lib/something_erlang/accounts/user_notifier.ex b/lib/something_erlang/accounts/user_notifier.ex deleted file mode 100644 index 4da35d0..0000000 --- a/lib/something_erlang/accounts/user_notifier.ex +++ /dev/null @@ -1,79 +0,0 @@ -defmodule SomethingErlang.Accounts.UserNotifier do - import Swoosh.Email - - alias SomethingErlang.Mailer - - # Delivers the email using the application mailer. - defp deliver(recipient, subject, body) do - email = - new() - |> to(recipient) - |> from({"SomethingErlang", "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/something_erlang/accounts/user_token.ex b/lib/something_erlang/accounts/user_token.ex deleted file mode 100644 index 43de28f..0000000 --- a/lib/something_erlang/accounts/user_token.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule SomethingErlang.Accounts.UserToken do - use Ecto.Schema - import Ecto.Query - alias SomethingErlang.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, SomethingErlang.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/something_erlang/application.ex b/lib/something_erlang/application.ex index 28d9e1e..bc009b7 100644 --- a/lib/something_erlang/application.ex +++ b/lib/something_erlang/application.ex @@ -8,20 +8,16 @@ defmodule SomethingErlang.Application do @impl true def start(_type, _args) do children = [ - {Registry, [name: SomethingErlang.Registry.Grovers, keys: :unique]}, - {DynamicSupervisor, [name: SomethingErlang.Supervisor.Grovers, strategy: :one_for_one]}, - # Start the Telemetry supervisor SomethingErlangWeb.Telemetry, - # Start the Ecto repository SomethingErlang.Repo, - # Start the PubSub system + {DNSCluster, query: Application.get_env(:something_erlang, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: SomethingErlang.PubSub}, - # Start Finch + # Start the Finch HTTP client for sending emails {Finch, name: SomethingErlang.Finch}, - # Start the Endpoint (http/https) - SomethingErlangWeb.Endpoint # Start a worker by calling: SomethingErlang.Worker.start_link(arg) - # {SomethingErlang.Worker, arg} + # {SomethingErlang.Worker, arg}, + # Start to serve requests, typically the last entry + SomethingErlangWeb.Endpoint ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/something_erlang/awful_api/awful_api.ex b/lib/something_erlang/awful_api/awful_api.ex deleted file mode 100644 index e70cb9d..0000000 --- a/lib/something_erlang/awful_api/awful_api.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule SomethingErlang.AwfulApi do - require Logger - - alias SomethingErlang.AwfulApi.Thread - alias SomethingErlang.AwfulApi.Bookmarks - - @doc """ - Returns a list of all posts on page of a thread. - - ## Examples - - iex> t = AwfulApi.parsed_thread(3945300, 1) - iex> length(t.posts) - 42 - iex> t.page_count - 12 - """ - def parsed_thread(id, page, user) do - Thread.compile(id, page, user) - end - - def bookmarks(user) do - Bookmarks.compile(1, user) - end -end diff --git a/lib/something_erlang/awful_api/bookmarks.ex b/lib/something_erlang/awful_api/bookmarks.ex deleted file mode 100644 index d0097bd..0000000 --- a/lib/something_erlang/awful_api/bookmarks.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule SomethingErlang.AwfulApi.Bookmarks do - require Logger - - alias SomethingErlang.AwfulApi.Client - - def compile(page, user) do - doc = Client.bookmarks_doc(page, user) - html = Floki.parse_document!(doc) - - for thread <- Floki.find(html, "tr.thread") do - parse(thread) - end - end - - def parse(thread) do - %{ - title: Floki.find(thread, "td.title") |> inner_html() |> Floki.raw_html(), - icon: Floki.find(thread, "td.icon") |> inner_html() |> Floki.raw_html(), - author: Floki.find(thread, "td.author") |> inner_html() |> Floki.text(), - replies: Floki.find(thread, "td.replies") |> inner_html() |> Floki.text(), - views: Floki.find(thread, "td.views") |> inner_html() |> Floki.text(), - rating: Floki.find(thread, "td.rating") |> inner_html() |> Floki.raw_html(), - lastpost: Floki.find(thread, "td.lastpost") |> inner_html() |> Floki.raw_html() - } - - for {"td", [{"class", class} | _attrs], children} <- Floki.find(thread, "td"), - String.starts_with?(class, "star") == false, - into: %{} do - case class do - <<"title", _rest::binary>> -> - {:title, children |> Floki.raw_html()} - - <<"icon", _rest::binary>> -> - {:icon, children |> Floki.raw_html()} - - <<"author", _rest::binary>> -> - {:author, children |> Floki.text()} - - <<"replies", _rest::binary>> -> - {:replies, children |> Floki.text() |> String.to_integer()} - - <<"views", _rest::binary>> -> - {:views, children |> Floki.text() |> String.to_integer()} - - <<"rating", _rest::binary>> -> - {:rating, children |> Floki.raw_html()} - - <<"lastpost", _rest::binary>> -> - {:lastpost, children |> Floki.raw_html()} - end - end - end - - defp inner_html(node) do - node - |> List.first() - |> Floki.children() - end -end diff --git a/lib/something_erlang/awful_api/client.ex b/lib/something_erlang/awful_api/client.ex deleted file mode 100644 index 9847778..0000000 --- a/lib/something_erlang/awful_api/client.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule SomethingErlang.AwfulApi.Client do - @base_url "https://forums.somethingawful.com/" - @user_agent "SomethingErlangClient/0.1" - - require Logger - - def thread_doc(id, page, user) do - resp = new(user) |> get_thread(id, page) - Logger.debug("Client reply in #{resp.private.time}ms ") - :unicode.characters_to_binary(resp.body, :latin1) - end - - def thread_lastseen_page(id, user) do - resp = new(user) |> get_thread_newpost(id) - %{status: 302, headers: headers} = resp - {"location", redir_url} = List.keyfind(headers, "location", 0) - [_, page] = Regex.run(~r/pagenumber=(\d+)/, redir_url) - page |> String.to_integer() - end - - def bookmarks_doc(page, user) do - resp = new(user) |> get_bookmarks(page) - :unicode.characters_to_binary(resp.body, :latin1) - end - - defp get_thread(req, id, page) do - url = "showthread.php" - params = [threadid: id, pagenumber: page] - Req.get!(req, url: url, params: params) - end - - defp get_thread_newpost(req, id) do - url = "showthread.php" - params = [threadid: id, goto: "newpost"] - Req.get!(req, url: url, params: params, follow_redirects: false) - end - - defp get_bookmarks(req, page) do - url = "bookmarkthreads.php" - params = [pagenumber: page] - Req.get!(req, url: url, params: params) - end - - def login(username, password) do - form = [action: "login", username: username, password: password] - url = "account.php" - - new() - |> Req.post!(url: url, form: form) - |> extract_cookies() - end - - defp extract_cookies(%Req.Response{} = response) do - cookies = response.headers["set-cookie"] - - for cookie <- cookies, String.starts_with?(cookie, "bb"), into: %{} do - cookie - |> String.split(";", parts: 2) - |> List.first() - |> String.split("=") - |> then(fn [k, v] -> {String.to_existing_atom(k), v} end) - end - end - - defp new(user) do - Req.new( - base_url: @base_url, - user_agent: @user_agent, - cache: true, - headers: [cookie: [cookies(%{bbuserid: user.id, bbpassword: user.hash})]] - ) - |> Req.Request.append_request_steps( - time: fn req -> Req.Request.put_private(req, :time, Time.utc_now()) end - ) - |> Req.Request.prepend_response_steps( - time: fn {req, res} -> - start = req.private.time - diff = Time.diff(Time.utc_now(), start, :millisecond) - {req, Req.Response.put_private(res, :time, diff)} - end - ) - end - - defp new() do - Req.new( - base_url: @base_url, - user_agent: @user_agent, - redirect: false - ) - end - - defp cookies(args) when is_map(args) do - Enum.map_join(args, "; ", fn {k, v} -> "#{k}=#{v}" end) - end -end diff --git a/lib/something_erlang/awful_api/thread.ex b/lib/something_erlang/awful_api/thread.ex deleted file mode 100644 index 8d00102..0000000 --- a/lib/something_erlang/awful_api/thread.ex +++ /dev/null @@ -1,170 +0,0 @@ -defmodule SomethingErlang.AwfulApi.Thread do - require Logger - - alias SomethingErlang.AwfulApi.Client - - def compile(id, page, user) do - doc = Client.thread_doc(id, page, user) - html = Floki.parse_document!(doc) - thread = Floki.find(html, "#thread") |> Floki.filter_out("table.post.ignored") - - title = Floki.find(html, "title") |> Floki.text() - title = title |> String.replace(" - The Something Awful Forums", "") - - page_count = - case Floki.find(html, "#content .pages.top option:last-of-type") |> Floki.text() do - "" -> 1 - s -> String.to_integer(s) - end - - posts = - for post <- Floki.find(thread, "table.post") do - %{ - userinfo: post |> userinfo(), - postdate: post |> postdate(), - postbody: post |> postbody() - } - end - - %{id: id, title: title, page: page, page_count: page_count, posts: posts} - end - - defp userinfo(post) do - user = Floki.find(post, "dl.userinfo") - name = user |> Floki.find("dt") |> Floki.text() - regdate = user |> Floki.find("dd.registered") |> Floki.text() - title = user |> Floki.find("dd.title") |> List.first() |> Floki.children() |> Floki.raw_html() - - %{ - name: name, - regdate: regdate, - title: title - } - end - - defp postdate(post) do - date = Floki.find(post, "td.postdate") |> Floki.find("td.postdate") |> Floki.text() - - [month_text, day, year, hours, minutes] = - date - |> String.split(~r{[\s,:]}, trim: true) - |> Enum.drop(1) - - month = - 1 + - Enum.find_index( - ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], - fn m -> m == month_text end - ) - - NaiveDateTime.new!( - year |> String.to_integer(), - month, - day |> String.to_integer(), - hours |> String.to_integer(), - minutes |> String.to_integer(), - 0 - ) - end - - defp postbody(post) do - body = - Floki.find(post, "td.postbody") - |> List.first() - |> Floki.filter_out(:comment) - - Floki.traverse_and_update(body, fn - {"img", attrs, []} -> transform(:img, attrs) - {"a", attrs, children} -> transform(:a, attrs, children) - other -> other - end) - |> Floki.children() - |> Floki.raw_html() - end - - defp transform(elem, attr, children \\ []) - - defp transform(:img, attrs, _children) do - {"class", class} = List.keyfind(attrs, "class", 0, {"class", ""}) - - if class == "sa-smilie" do - {"img", attrs, []} - else - t_attrs = List.keyreplace(attrs, "class", 0, {"class", "img-responsive"}) - {"img", [{"loading", "lazy"} | t_attrs], []} - end - end - - defp transform(:a, attrs, children) do - {"href", href} = List.keyfind(attrs, "href", 0, {"href", ""}) - - cond do - # skip internal links - String.starts_with?(href, "/") -> - {"a", [{"href", href}], children} - - # mp4 - String.ends_with?(href, ".mp4") -> - transform_link(:mp4, href) - - # gifv - String.ends_with?(href, ".gifv") -> - transform_link(:gifv, href) - - # youtube - String.starts_with?(href, "https://www.youtube.com/watch") -> - transform_link(:ytlong, href) - - String.starts_with?(href, "https://youtu.be/") -> - transform_link(:ytshort, href) - - true -> - Logger.debug("no transform for #{href}") - {"a", [{"href", href}], children} - end - end - - defp transform_link(:mp4, href), - do: - {"div", [{"class", "responsive-embed"}], - [ - {"video", [{"class", "img-responsive"}, {"controls", ""}], - [{"source", [{"src", href}, {"type", "video/mp4"}], []}]} - ]} - - defp transform_link(:gifv, href), - do: - {"div", [{"class", "responsive-embed"}], - [ - {"video", [{"class", "img-responsive"}, {"controls", ""}], - [ - {"source", [{"src", String.replace(href, ".gifv", ".webm")}, {"type", "video/webm"}], - []}, - {"source", [{"src", String.replace(href, ".gifv", ".mp4")}, {"type", "video/mp4"}], - []} - ]} - ]} - - defp transform_link(:ytlong, href) do - String.replace(href, "/watch?v=", "/embed/") - |> youtube_iframe() - end - - defp transform_link(:ytshort, href) do - String.replace(href, "youtu.be/", "www.youtube.com/embed/") - |> youtube_iframe() - end - - defp youtube_iframe(src), - do: - {"div", [{"class", "responsive-embed"}], - [ - {"iframe", - [ - {"class", "youtube-player"}, - {"loading", "lazy"}, - {"allow", "fullscreen"}, - {"src", src} - ], []} - ]} -end diff --git a/lib/something_erlang/grover.ex b/lib/something_erlang/grover.ex deleted file mode 100644 index 8a907a6..0000000 --- a/lib/something_erlang/grover.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule SomethingErlang.Grover do - use GenServer - - alias SomethingErlang.AwfulApi - require Logger - - def mount(user) do - grover = - DynamicSupervisor.start_child( - SomethingErlang.Supervisor.Grovers, - {__MODULE__, [self(), user]} - ) - - case grover do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - {:error, error} -> {:error, error} - end - end - - def get_thread!(thread_id, page_number) do - GenServer.call(via(self()), {:show_thread, thread_id, page_number}) - end - - def get_bookmarks!(page_number) do - GenServer.call(via(self()), {:show_bookmarks, page_number}) - end - - def start_link([lv_pid, user]) do - GenServer.start_link( - __MODULE__, - [lv_pid, user], - name: via(lv_pid) - ) - end - - @impl true - def init([pid, user]) do - %{bbuserid: userid, bbpassword: userhash} = user - - initial_state = %{ - lv_pid: pid, - user: %{id: userid, hash: userhash} - } - - Logger.debug("init #{userid} #{inspect(pid)}") - Process.monitor(pid) - {:ok, initial_state} - end - - @impl true - def handle_call({:show_thread, thread_id, page_number}, _from, state) do - thread = AwfulApi.parsed_thread(thread_id, page_number, state.user) - {:reply, thread, state} - end - - @impl true - def handle_call({:show_bookmarks, _page_number}, _from, state) do - bookmarks = AwfulApi.bookmarks(state.user) - {:reply, bookmarks, state} - end - - @impl true - def handle_info({:DOWN, _ref, :process, _object, reason}, state) do - Logger.debug("received :DOWN from: #{inspect(state.lv_pid)} reason: #{inspect(reason)}") - - case reason do - {:shutdown, _} -> {:stop, :normal, state} - :killed -> {:stop, :normal, state} - _ -> {:noreply, state} - end - end - - defp via(lv_pid), - do: {:via, Registry, {SomethingErlang.Registry.Grovers, lv_pid}} -end diff --git a/lib/something_erlang/release.ex b/lib/something_erlang/release.ex deleted file mode 100644 index 950fedd..0000000 --- a/lib/something_erlang/release.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule SomethingErlang.Release do - @moduledoc """ - Used for executing DB release tasks when run in production without Mix - installed. - """ - @app :something_erlang - - def migrate do - load_app() - - for repo <- repos() do - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) - end - end - - def rollback(repo, version) do - load_app() - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) - end - - defp repos do - Application.fetch_env!(@app, :ecto_repos) - end - - defp load_app do - Application.load(@app) - end -end diff --git a/lib/something_erlang_web.ex b/lib/something_erlang_web.ex index e2c8620..52b1eb3 100644 --- a/lib/something_erlang_web.ex +++ b/lib/something_erlang_web.ex @@ -105,7 +105,7 @@ defmodule SomethingErlangWeb do 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/something_erlang_web/components/core_components.ex b/lib/something_erlang_web/components/core_components.ex index 0ee17d1..1b376eb 100644 --- a/lib/something_erlang_web/components/core_components.ex +++ b/lib/something_erlang_web/components/core_components.ex @@ -260,7 +260,8 @@ defmodule SomethingErlangWeb.CoreComponents do * For live file uploads, see `Phoenix.Component.live_file_input/1` See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input - for more information. + for more information. Unsupported types, such as hidden and radio, + are best written directly in your templates. ## Examples @@ -274,8 +275,8 @@ defmodule SomethingErlangWeb.CoreComponents do attr :type, :string, default: "text", - values: ~w(checkbox color date datetime-local email file hidden month number password - range radio search select tel text textarea time url week) + values: ~w(checkbox color date datetime-local email file month number password + range search select tel text textarea time url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -660,9 +661,9 @@ defmodule SomethingErlangWeb.CoreComponents do # with our gettext backend as first argument. Translations are # available in the errors.po file (as we use the "errors" domain). if count = opts[:count] do - Gettext.dngettext(DingeWeb.Gettext, "errors", msg, msg, count, opts) + Gettext.dngettext(SomethingErlangWeb.Gettext, "errors", msg, msg, count, opts) else - Gettext.dgettext(DingeWeb.Gettext, "errors", msg, opts) + Gettext.dgettext(SomethingErlangWeb.Gettext, "errors", msg, opts) end end diff --git a/lib/something_erlang_web/components/layouts.ex b/lib/something_erlang_web/components/layouts.ex index f6cbe48..83052ed 100644 --- a/lib/something_erlang_web/components/layouts.ex +++ b/lib/something_erlang_web/components/layouts.ex @@ -1,4 +1,13 @@ defmodule SomethingErlangWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use SomethingErlangWeb, :controller` and + `use SomethingErlangWeb, :live_view`. + """ use SomethingErlangWeb, :html embed_templates "layouts/*" diff --git a/lib/something_erlang_web/components/layouts/app.html.heex b/lib/something_erlang_web/components/layouts/app.html.heex index 8a38d06..e23bfc8 100644 --- a/lib/something_erlang_web/components/layouts/app.html.heex +++ b/lib/something_erlang_web/components/layouts/app.html.heex @@ -1,28 +1,23 @@
-
+
- Home - Threads -

- v0.1 + + + +

+ v<%= Application.spec(:phoenix, :vsn) %>

-
-
- <.flash kind={:info} title="Success!" flash={@flash} /> - <.flash kind={:error} title="Error!" flash={@flash} /> - <.flash - id="disconnected" - kind={:error} - title="We can't find the internet" - close={false} - autoshow={false} - phx-disconnected={show("#disconnected")} - phx-connected={hide("#disconnected")} - > - Attempting to reconnect - +
+ <.flash_group flash={@flash} /> <%= @inner_content %>
diff --git a/lib/something_erlang_web/components/layouts/root.html.heex b/lib/something_erlang_web/components/layouts/root.html.heex index f858d56..add12ce 100644 --- a/lib/something_erlang_web/components/layouts/root.html.heex +++ b/lib/something_erlang_web/components/layouts/root.html.heex @@ -1,5 +1,5 @@ - + @@ -11,28 +11,7 @@ - - - + <%= @inner_content %> diff --git a/lib/something_erlang_web/controllers/error_html.ex b/lib/something_erlang_web/controllers/error_html.ex index f50105b..947d818 100644 --- a/lib/something_erlang_web/controllers/error_html.ex +++ b/lib/something_erlang_web/controllers/error_html.ex @@ -1,4 +1,9 @@ defmodule SomethingErlangWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ use SomethingErlangWeb, :html # If you want to customize your error pages, diff --git a/lib/something_erlang_web/controllers/error_json.ex b/lib/something_erlang_web/controllers/error_json.ex index 60ddadc..d8756f7 100644 --- a/lib/something_erlang_web/controllers/error_json.ex +++ b/lib/something_erlang_web/controllers/error_json.ex @@ -1,4 +1,10 @@ defmodule SomethingErlangWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + # If you want to customize a particular status code, # you may add your own clauses, such as: # diff --git a/lib/something_erlang_web/controllers/page_controller.ex b/lib/something_erlang_web/controllers/page_controller.ex index f41f5ce..8a93aee 100644 --- a/lib/something_erlang_web/controllers/page_controller.ex +++ b/lib/something_erlang_web/controllers/page_controller.ex @@ -1,33 +1,9 @@ defmodule SomethingErlangWeb.PageController do use SomethingErlangWeb, :controller - def home(conn, params) do + def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. - conn = assign(conn, :params, params) - render(conn, :home) - end - - def to_forum_path(conn, %{"forum_path" => path} = _params) do - {redirect_good, thread, page} = - case { - Regex.run(~r{threadid=(\d+)}, path), - Regex.run(~r{pagenumber=(\d+)}, path) - } do - {[_, thread], nil} -> {:ok, thread, 1} - {[_, thread], [_, page]} -> {:ok, thread, page} - _ -> {:error, nil, nil} - end - - if redirect_good == :ok do - redirect(conn, to: ~p"/thread/#{thread}?page=#{page}") - else - put_flash(conn, :error, "Could not resolve URL") - render(conn, :home) - end - end - - def to_forum_path(conn, _params) do - render(conn, :home) + render(conn, :home, layout: false) end end diff --git a/lib/something_erlang_web/controllers/page_html.ex b/lib/something_erlang_web/controllers/page_html.ex index 2d01b91..faf055d 100644 --- a/lib/something_erlang_web/controllers/page_html.ex +++ b/lib/something_erlang_web/controllers/page_html.ex @@ -1,4 +1,9 @@ defmodule SomethingErlangWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ use SomethingErlangWeb, :html embed_templates "page_html/*" diff --git a/lib/something_erlang_web/controllers/page_html/default.html.heex b/lib/something_erlang_web/controllers/page_html/default.html.heex deleted file mode 100644 index 619f097..0000000 --- a/lib/something_erlang_web/controllers/page_html/default.html.heex +++ /dev/null @@ -1,236 +0,0 @@ - -
-
- -

- Phoenix Framework - - v1.7 - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/lib/something_erlang_web/controllers/page_html/home.html.heex b/lib/something_erlang_web/controllers/page_html/home.html.heex index c65e960..dc1820b 100644 --- a/lib/something_erlang_web/controllers/page_html/home.html.heex +++ b/lib/something_erlang_web/controllers/page_html/home.html.heex @@ -1,9 +1,222 @@ -<.form :let={f} for={@conn} action={~p"/"}> - - - - -
-	<%= inspect(@current_user) %>
-	<%= inspect(@conn.cookies) %>
-
+<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v<%= Application.spec(:phoenix, :vsn) %> + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/lib/something_erlang_web/controllers/user_session_controller.ex b/lib/something_erlang_web/controllers/user_session_controller.ex deleted file mode 100644 index 8086df9..0000000 --- a/lib/something_erlang_web/controllers/user_session_controller.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule SomethingErlangWeb.UserSessionController do - use SomethingErlangWeb, :controller - - alias SomethingErlang.Accounts - alias SomethingErlangWeb.UserAuth - - def create(conn, %{"_action" => "registered"} = params) do - create(conn, params, "Account created successfully!") - end - - def create(conn, %{"_action" => "password_updated"} = params) do - conn - |> put_session(:user_return_to, ~p"/users/settings") - |> create(params, "Password updated successfully!") - end - - def create(conn, params) do - create(conn, params, "Welcome back!") - end - - defp create(conn, %{"user" => user_params}, info) do - %{"username" => username, "password" => password} = user_params - - if user = Accounts.login_sa_user_and_get_cookies(username, password) do - conn - |> put_flash(:info, info) - |> put_session(:bbpassword, user.bbpassword) - |> UserAuth.log_in_user(user, user_params) - else - conn - |> put_flash(:error, "Login failed!") - |> put_flash(:email, String.slice(username, 0, 160)) - |> redirect(to: ~p"/users/log_in") - end - end - - def delete(conn, _params) do - conn - |> put_flash(:info, "Logged out successfully.") - |> UserAuth.log_out_user() - end -end diff --git a/lib/something_erlang_web/endpoint.ex b/lib/something_erlang_web/endpoint.ex index 294eec2..c34e0d6 100644 --- a/lib/something_erlang_web/endpoint.ex +++ b/lib/something_erlang_web/endpoint.ex @@ -7,11 +7,13 @@ defmodule SomethingErlangWeb.Endpoint do @session_options [ store: :cookie, key: "_something_erlang_key", - signing_salt: "0z4XWrxF", + signing_salt: "23G6hSX1", same_site: "Lax" ] - socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/something_erlang_web/live/thread_live.ex b/lib/something_erlang_web/live/thread_live.ex deleted file mode 100644 index fe56d57..0000000 --- a/lib/something_erlang_web/live/thread_live.ex +++ /dev/null @@ -1,163 +0,0 @@ -defmodule SomethingErlangWeb.ThreadLive do - use SomethingErlangWeb, :live_view - - alias SomethingErlang.Grover - - def render(%{thread: _} = assigns) do - ~H""" -

- <%= raw(@thread.title) %> -

- -
- <.pagination thread={@thread} /> - - <%= for %{userinfo: author, postdate: date, postbody: article} <- @thread.posts do %> - <.post author={author} date={date}> - <%= raw(article) %> - - <% end %> - - <.pagination thread={@thread} /> -
- """ - end - - def render(assigns) do - ~H""" -

- Threads! -

-
-      <%= inspect(@current_user) %>
-    
- """ - end - - def post(assigns) do - ~H""" -
- <.user info={@author} /> -
- <%= render_slot(@inner_block) %> -
- <.toolbar date={@date} /> -
- """ - end - - def user(assigns) do - ~H""" - - """ - end - - def toolbar(assigns) do - ~H""" -
- <%= @date |> Calendar.strftime("%A, %b %d %Y @ %H:%M") %> -
- """ - end - - def pagination(assigns) do - ~H""" - - """ - end - - defp label_button(%{label: "«", page: page} = assigns), - do: ~H""" - <%= page %> - """ - - defp label_button(%{label: "‹", page: page} = assigns), - do: ~H""" - <%= page %> - """ - - defp label_button(%{label: "›", page: page} = assigns), - do: ~H""" - <%= page %> - """ - - defp label_button(%{label: "»", page: page} = assigns), - do: ~H""" - <%= page %> - """ - - defp label_button(%{page: page} = assigns), - do: ~H""" - <%= page %> - """ - - defp buttons(thread) do - %{page: page_number, page_count: page_count} = thread - - first_page_disabled_button = if page_number == 1, do: " btn-disabled", else: "" - last_page_disabled_button = if page_number == page_count, do: " btn-disabled", else: "" - active_page_button = " btn-active" - - prev_button_target = if page_number > 1, do: page_number - 1, else: 1 - next_button_target = if page_number < page_count, do: page_number + 1, else: page_count - - [ - %{label: "«", page: 1, special: "" <> first_page_disabled_button}, - %{label: "‹", page: prev_button_target, special: "" <> first_page_disabled_button}, - %{label: "#{page_number}", page: page_number, special: active_page_button}, - %{label: "›", page: next_button_target, special: "" <> last_page_disabled_button}, - %{label: "»", page: page_count, special: "" <> last_page_disabled_button} - ] - end - - def mount(_params, session, socket) do - user = - socket.assigns.current_user - |> Map.put(:bbpassword, session["bbpassword"]) - - Grover.mount(user) - - {:ok, socket} - end - - def handle_params(%{"id" => id, "page" => page}, _, socket) do - thread = Grover.get_thread!(id, page |> String.to_integer()) - - {:noreply, - socket - |> assign(:page_title, thread.title) - |> assign(:thread, thread)} - end - - def handle_params(%{"id" => id}, _, socket) do - params = %{page: 1} - - {:noreply, - push_patch( - socket, - to: ~p"/thread/#{id}?#{params}", - replace: true - )} - end - - def handle_params(%{}, _, socket) do - {:noreply, socket} - end -end diff --git a/lib/something_erlang_web/live/user_login_live.ex b/lib/something_erlang_web/live/user_login_live.ex deleted file mode 100644 index 7ea83ef..0000000 --- a/lib/something_erlang_web/live/user_login_live.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule SomethingErlangWeb.UserLoginLive do - use SomethingErlangWeb, :live_view - - def render(assigns) do - ~H""" -
- <.header class="text-center text-neutral-content"> - Sign in to account - <:subtitle> - Don't have an account? - <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> - Sign up - - for an account now. - - - - <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> - <.input field={@form[:username]} type="text" label="Username" required /> - <.input field={@form[:password]} type="password" label="Password" required /> - - <:actions> - <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> - <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> - Forgot your password? - - - <:actions> - <.button phx-disable-with="Signing in..." class="w-full"> - Sign in - - - -
- """ - end - - def mount(_params, _session, socket) do - email = live_flash(socket.assigns.flash, :email) - form = to_form(%{"email" => email}, as: "user") - {:ok, assign(socket, form: form), temporary_assigns: [form: form]} - end -end diff --git a/lib/something_erlang_web/router.ex b/lib/something_erlang_web/router.ex index c06aa13..d577c02 100644 --- a/lib/something_erlang_web/router.ex +++ b/lib/something_erlang_web/router.ex @@ -1,17 +1,13 @@ defmodule SomethingErlangWeb.Router do use SomethingErlangWeb, :router - import SomethingErlangWeb.UserAuth - pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash - plug :put_root_layout, {SomethingErlangWeb.Layouts, :root} + plug :put_root_layout, html: {SomethingErlangWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers - plug :fetch_current_user - plug :load_bbcookie end pipeline :api do @@ -19,36 +15,15 @@ defmodule SomethingErlangWeb.Router do end scope "/", SomethingErlangWeb do - pipe_through [:browser] + pipe_through :browser get "/", PageController, :home - post "/", PageController, :to_forum_path - - live_session :user_browsing, - on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do - live "/thread", ThreadLive - live "/thread/:id", ThreadLive - end end - ## Authentication routes - - scope "/", SomethingErlangWeb do - pipe_through [:browser, :redirect_if_user_is_authenticated] - - live_session :redirect_if_user_is_authenticated, - on_mount: [{SomethingErlangWeb.UserAuth, :redirect_if_user_is_authenticated}] do - live "/users/log_in", UserLoginLive, :new - end - - post "/users/log_in", UserSessionController, :create - end - - scope "/", SomethingErlangWeb do - pipe_through [:browser] - - delete "/users/log_out", UserSessionController, :delete - end + # Other scopes may use custom stacks. + # scope "/api", SomethingErlangWeb do + # pipe_through :api + # end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:something_erlang, :dev_routes) do @@ -62,10 +37,7 @@ defmodule SomethingErlangWeb.Router do scope "/dev" do pipe_through :browser - live_dashboard "/dashboard", - ecto_repos: [SomethingErlang.Repo], - metrics: SomethingErlangWeb.Telemetry - + live_dashboard "/dashboard", metrics: SomethingErlangWeb.Telemetry forward "/mailbox", Plug.Swoosh.MailboxPreview end end diff --git a/lib/something_erlang_web/telemetry.ex b/lib/something_erlang_web/telemetry.ex index ba506cb..9914ad3 100644 --- a/lib/something_erlang_web/telemetry.ex +++ b/lib/something_erlang_web/telemetry.ex @@ -43,7 +43,7 @@ defmodule SomethingErlangWeb.Telemetry do summary("phoenix.socket_connected.duration", unit: {:native, :millisecond} ), - summary("phoenix.channel_join.duration", + summary("phoenix.channel_joined.duration", unit: {:native, :millisecond} ), summary("phoenix.channel_handled_in.duration", diff --git a/lib/something_erlang_web/user_auth.ex b/lib/something_erlang_web/user_auth.ex deleted file mode 100644 index c1cf867..0000000 --- a/lib/something_erlang_web/user_auth.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule SomethingErlangWeb.UserAuth do - use SomethingErlangWeb, :verified_routes - - import Plug.Conn - import Phoenix.Controller - - alias SomethingErlang.Accounts - - # Make the remember me cookie valid for 60 days. - # If you want bump or reduce this value, also change - # the token expiry itself in UserToken. - @max_age 60 * 60 * 24 * 60 - @remember_me_cookie "_something_erlang_web_user_remember_me" - @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] - - @doc """ - Logs the user in. - - It renews the session ID and clears the whole session - to avoid fixation attacks. See the renew_session - function to customize this behaviour. - - It also sets a `:live_socket_id` key in the session, - so LiveView sessions are identified and automatically - disconnected on log out. The line can be safely removed - if you are not using LiveView. - """ - def log_in_user(conn, user, params \\ %{}) do - token = Accounts.generate_user_session_token(user) - user_return_to = get_session(conn, :user_return_to) - - conn - |> renew_session() - |> put_hashcookie_in_session(user.bbpassword) - |> put_token_in_session(token) - |> maybe_write_remember_me_cookie(token, params) - |> redirect(to: user_return_to || signed_in_path(conn)) - end - - defp put_hashcookie_in_session(conn, bbpassword) do - put_resp_cookie(conn, "bbpassword", bbpassword) - end - - defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do - put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) - end - - defp maybe_write_remember_me_cookie(conn, _token, _params) do - conn - end - - # This function renews the session ID and erases the whole - # session to avoid fixation attacks. If there is any data - # in the session you may want to preserve after log in/log out, - # you must explicitly fetch the session data before clearing - # and then immediately set it after clearing, for example: - # - # defp renew_session(conn) do - # preferred_locale = get_session(conn, :preferred_locale) - # - # conn - # |> configure_session(renew: true) - # |> clear_session() - # |> put_session(:preferred_locale, preferred_locale) - # end - # - defp renew_session(conn) do - conn - |> configure_session(renew: true) - |> clear_session() - end - - @doc """ - Logs the user out. - - It clears all session data for safety. See renew_session. - """ - def log_out_user(conn) do - user_token = get_session(conn, :user_token) - user_token && Accounts.delete_user_session_token(user_token) - - if live_socket_id = get_session(conn, :live_socket_id) do - SomethingErlangWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) - end - - conn - |> renew_session() - |> delete_resp_cookie(@remember_me_cookie) - |> redirect(to: "/") - end - - def load_bbcookie(conn, _opts) do - conn - |> put_session(:bbpassword, conn.cookies["bbpassword"]) - end - - @doc """ - Authenticates the user by looking into the session - and remember me token. - """ - def fetch_current_user(conn, _opts) do - {user_token, conn} = ensure_user_token(conn) - user = user_token && Accounts.get_user_by_session_token(user_token) - - assign(conn, :current_user, user) - end - - defp ensure_user_token(conn) do - if token = get_session(conn, :user_token) do - {token, conn} - else - conn = fetch_cookies(conn, signed: [@remember_me_cookie]) - - if token = conn.cookies[@remember_me_cookie] do - {token, put_token_in_session(conn, token)} - else - {nil, conn} - end - end - end - - @doc """ - Handles mounting and authenticating the current_user in LiveViews. - - ## `on_mount` arguments - - * `:mount_current_user` - Assigns current_user - to socket assigns based on user_token, or nil if - there's no user_token or no matching user. - - * `:ensure_authenticated` - Authenticates the user from the session, - and assigns the current_user to socket assigns based - on user_token. - Redirects to login page if there's no logged user. - - * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. - Redirects to signed_in_path if there's a logged user. - - ## Examples - - Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate - the current_user: - - defmodule SomethingErlangWeb.PageLive do - use SomethingErlangWeb, :live_view - - on_mount {SomethingErlangWeb.UserAuth, :mount_current_user} - ... - end - - Or use the `live_session` of your router to invoke the on_mount callback: - - live_session :authenticated, on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do - live "/profile", ProfileLive, :index - end - """ - def on_mount(:mount_current_user, _params, session, socket) do - {:cont, mount_current_user(session, socket)} - end - - def on_mount(:ensure_authenticated, _params, session, socket) do - socket = mount_current_user(session, socket) - - if socket.assigns.current_user do - {:cont, socket} - else - socket = - socket - |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") - |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") - - {:halt, socket} - end - end - - def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do - socket = mount_current_user(session, socket) - - if socket.assigns.current_user do - {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} - else - {:cont, socket} - end - end - - defp mount_current_user(session, socket) do - case session do - %{"user_token" => user_token} -> - Phoenix.Component.assign_new(socket, :current_user, fn -> - Accounts.get_user_by_session_token(user_token) - end) - - %{} -> - Phoenix.Component.assign_new(socket, :current_user, fn -> nil end) - end - end - - @doc """ - Used for routes that require the user to not be authenticated. - """ - def redirect_if_user_is_authenticated(conn, _opts) do - if conn.assigns[:current_user] do - conn - |> redirect(to: signed_in_path(conn)) - |> halt() - else - conn - end - end - - @doc """ - Used for routes that require the user to be authenticated. - - If you want to enforce the user email is confirmed before - they use the application at all, here would be a good place. - """ - def require_authenticated_user(conn, _opts) do - if conn.assigns[:current_user] do - conn - else - conn - |> put_flash(:error, "You must log in to access this page.") - |> maybe_store_return_to() - |> redirect(to: ~p"/users/log_in") - |> halt() - end - end - - defp put_token_in_session(conn, token) do - conn - |> put_session(:user_token, token) - |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") - end - - defp maybe_store_return_to(%{method: "GET"} = conn) do - put_session(conn, :user_return_to, current_path(conn)) - end - - defp maybe_store_return_to(conn), do: conn - - defp signed_in_path(_conn), do: ~p"/" -end diff --git a/mix.exs b/mix.exs index 605456a..394c89f 100644 --- a/mix.exs +++ b/mix.exs @@ -19,7 +19,7 @@ defmodule SomethingErlang.MixProject do def application do [ mod: {SomethingErlang.Application, []}, - extra_applications: [:logger, :runtime_tools, :os_mon] + extra_applications: [:logger, :runtime_tools] ] end @@ -32,29 +32,32 @@ defmodule SomethingErlang.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:bcrypt_elixir, "~> 3.0"}, - {:phoenix, "~> 1.7.0-rc.2", override: true}, + {:phoenix, "~> 1.7.12"}, {:phoenix_ecto, "~> 4.4"}, - {:ecto_sql, "~> 3.10"}, - {:ecto_psql_extras, "~> 0.6"}, + {:ecto_sql, "~> 3.10"}, {:postgrex, ">= 0.0.0"}, - {:phoenix_html, "~> 3.0"}, + {:phoenix_html, "~> 4.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 0.18.3"}, - {:heroicons, "~> 0.5"}, - {:floki, ">= 0.30.0"}, - {:phoenix_live_dashboard, "~> 0.7.2"}, - {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, - {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, - {:swoosh, "~> 1.3"}, + {:phoenix_live_view, "~> 0.20.2"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, {:finch, "~> 0.13"}, - {:telemetry_metrics, "~> 0.6"}, + {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, - {:bandit, "~> 1.0"}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:req, "~> 0.3"} + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.2"} ] end @@ -66,12 +69,17 @@ defmodule SomethingErlang.MixProject do # See the documentation for `Mix` for more info on aliases. defp aliases do [ - setup: ["deps.get", "ecto.setup", "assets.setup"], + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], - "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + "assets.build": ["tailwind something_erlang", "esbuild something_erlang"], + "assets.deploy": [ + "tailwind something_erlang --minify", + "esbuild something_erlang --minify", + "phx.digest" + ] ] end end diff --git a/mix.lock b/mix.lock index ea50e02..c88b0e9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,54 +1,40 @@ %{ - "bandit": {:hex, :bandit, "1.4.0", "fdf9c4b9e3a2d8579540ff90f74f514e5bec25f8cb1c7ede6fddd409509e5b4b", [:mix], [{:hpax, "~> 0.1.1", [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", "2d068334fe7a4ea17161b875aa112bfa7d62060e8eefb1a1117b2ab6a817e04f"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, - "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, - "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, - "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, - "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.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "bandit": {:hex, :bandit, "1.5.2", "ed0a41c43a9e529c670d0fd48371db4027e7b80d43b1942893e17deb8bed0540", [:mix], [{:hpax, "~> 0.1.1", [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", "35ddbdce7e8a2a3c6b5093f7299d70832a43ed2f4a1852885a61d334cab1b4ad"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, - "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.7.15", "0fc29dbae0e444a29bd6abeee4cf3c4c037e692a272478a234a1cc765077dbb1", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "b6127f3a5c6fc3d84895e4768cc7c199f22b48b67d6c99b13fbf4a374e73f039"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, - "elixir_make": {:hex, :elixir_make, "0.8.3", "d38d7ee1578d722d89b4d452a3e36bcfdc644c618f0d063b874661876e708683", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "5c99a18571a756d4af7a4d89ca75c28ac899e6103af6f223982f09ce44942cc9"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, - "floki": {:hex, :floki, "0.36.1", "712b7f2ba19a4d5a47dfe3e74d81876c95bbcbee44fe551f0af3d2a388abb3da", [:mix], [], "hexpm", "21ba57abb8204bcc70c439b423fc0dd9f0286de67dc82773a14b0200ada0995f"}, + "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, - "heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, - "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, - "nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"}, + "mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.5.1", "6fdbc334ea53620e71655664df6f33f670747b3a7a6c4041cdda3e2c32df6257", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ebe43aa580db129e54408e719fb9659b7f9e0d52b965c5be26cdca416ecead28"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.2", "354460993a480656b71c3887f5565f612b3bdbdd8688c83f9e6f512307067dd4", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "2bb3722f327e14a7aa47b1acf27ed633c8cd27b167e18b8237954b9b4804af39"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.18", "1f38fbd7c363723f19aad1a04b5490ff3a178e37daaf6999594d5f34796c47fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5810d0472f3189ede6d2a95bda7f31c6113156b91784a3426cb0ab6a6d85214"}, + "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, - "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, - "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, - "swoosh": {:hex, :swoosh, "1.16.3", "4ab7dc429e84afaf8ffe1c7c06ce1acbc7ddde758d2cb9152dd2ac32289d5498", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.1.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ff70980087650a72951ebd109a286d83c270e2b6610aba447140562adff8cf0a"}, - "table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"}, - "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"}, + "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, + "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, + "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index ccf5c68..eef2de2 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -7,7 +7,6 @@ ## Run `mix gettext.extract` to bring this file up to ## date. Leave `msgstr`s empty as changing them here has no ## effect: edit them in PO (`.po`) files instead. - ## From Ecto.Changeset.cast/4 msgid "can't be blank" msgstr "" diff --git a/priv/repo/migrations/20230118110156_create_users_auth_tables.exs b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs deleted file mode 100644 index 0cb6c00..0000000 --- a/priv/repo/migrations/20230118110156_create_users_auth_tables.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule SomethingErlang.Repo.Migrations.CreateUsersAuthTables do - use Ecto.Migration - - def change do - execute "CREATE EXTENSION IF NOT EXISTS citext", "" - - create table(:users) do - add :email, :citext, null: false - add :hashed_password, :string, null: false - add :confirmed_at, :naive_datetime - timestamps() - end - - create unique_index(:users, [:email]) - - create table(:users_tokens) do - add :user_id, references(:users, on_delete: :delete_all), null: false - add :token, :binary, null: false - add :context, :string, null: false - add :sent_to, :string - timestamps(updated_at: false) - end - - create index(:users_tokens, [:user_id]) - create unique_index(:users_tokens, [:context, :token]) - end -end diff --git a/priv/repo/migrations/20240329091549_add_bbuserid.exs b/priv/repo/migrations/20240329091549_add_bbuserid.exs deleted file mode 100644 index 80265e1..0000000 --- a/priv/repo/migrations/20240329091549_add_bbuserid.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule SomethingErlang.Repo.Migrations.AddBbuserid do - use Ecto.Migration - - def change do - alter table(:users) do - remove :email - remove :hashed_password - remove :confirmed_at - add :bbuserid, :citext - end - - # drop index(:users, [:email]) - create unique_index(:users, [:bbuserid]) - end -end diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 73de524..7f372bf 100644 Binary files a/priv/static/favicon.ico and b/priv/static/favicon.ico differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate deleted file mode 100755 index 4f9bc3f..0000000 --- a/rel/overlays/bin/migrate +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -cd -P -- "$(dirname -- "$0")" -exec ./something_erlang eval SomethingErlang.Release.migrate diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat deleted file mode 100755 index 1e58f22..0000000 --- a/rel/overlays/bin/migrate.bat +++ /dev/null @@ -1 +0,0 @@ -call "%~dp0\something_erlang" eval SomethingErlang.Release.migrate diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server deleted file mode 100755 index 06a872c..0000000 --- a/rel/overlays/bin/server +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -cd -P -- "$(dirname -- "$0")" -PHX_SERVER=true exec ./something_erlang start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat deleted file mode 100755 index 32948fa..0000000 --- a/rel/overlays/bin/server.bat +++ /dev/null @@ -1,2 +0,0 @@ -set PHX_SERVER=true -call "%~dp0\something_erlang" start diff --git a/test/something_erlang/accounts_test.exs b/test/something_erlang/accounts_test.exs deleted file mode 100644 index 795be85..0000000 --- a/test/something_erlang/accounts_test.exs +++ /dev/null @@ -1,508 +0,0 @@ -defmodule SomethingErlang.AccountsTest do - use SomethingErlang.DataCase - - alias SomethingErlang.Accounts - - import SomethingErlang.AccountsFixtures - alias SomethingErlang.Accounts.{User, UserToken} - - describe "get_user_by_email/1" do - test "does not return the user if the email does not exist" do - refute Accounts.get_user_by_email("unknown@example.com") - end - - test "returns the user if the email exists" do - %{id: id} = user = user_fixture() - assert %User{id: ^id} = Accounts.get_user_by_email(user.email) - end - end - - describe "get_user_by_email_and_password/2" do - test "does not return the user if the email does not exist" do - refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") - end - - test "does not return the user if the password is not valid" do - user = user_fixture() - refute Accounts.get_user_by_email_and_password(user.email, "invalid") - end - - test "returns the user if the email and password are valid" do - %{id: id} = user = user_fixture() - - assert %User{id: ^id} = - Accounts.get_user_by_email_and_password(user.email, valid_user_password()) - end - end - - describe "get_user!/1" do - test "raises if id is invalid" do - assert_raise Ecto.NoResultsError, fn -> - Accounts.get_user!(-1) - end - end - - test "returns the user with the given id" do - %{id: id} = user = user_fixture() - assert %User{id: ^id} = Accounts.get_user!(user.id) - end - end - - describe "register_user/1" do - test "requires email and password to be set" do - {:error, changeset} = Accounts.register_user(%{}) - - assert %{ - password: ["can't be blank"], - email: ["can't be blank"] - } = errors_on(changeset) - end - - test "validates email and password when given" do - {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) - - assert %{ - email: ["must have the @ sign and no spaces"], - password: ["should be at least 12 character(s)"] - } = errors_on(changeset) - end - - test "validates maximum values for email and password for security" do - too_long = String.duplicate("db", 100) - {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) - assert "should be at most 160 character(s)" in errors_on(changeset).email - assert "should be at most 72 character(s)" in errors_on(changeset).password - end - - test "validates email uniqueness" do - %{email: email} = user_fixture() - {:error, changeset} = Accounts.register_user(%{email: email}) - assert "has already been taken" in errors_on(changeset).email - - # Now try with the upper cased email too, to check that email case is ignored. - {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) - assert "has already been taken" in errors_on(changeset).email - end - - test "registers users with a hashed password" do - email = unique_user_email() - {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) - assert user.email == email - assert is_binary(user.hashed_password) - assert is_nil(user.confirmed_at) - assert is_nil(user.password) - end - end - - describe "change_user_registration/2" do - test "returns a changeset" do - assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) - assert changeset.required == [:password, :email] - end - - test "allows fields to be set" do - email = unique_user_email() - password = valid_user_password() - - changeset = - Accounts.change_user_registration( - %User{}, - valid_user_attributes(email: email, password: password) - ) - - assert changeset.valid? - assert get_change(changeset, :email) == email - assert get_change(changeset, :password) == password - assert is_nil(get_change(changeset, :hashed_password)) - end - end - - describe "change_user_email/2" do - test "returns a user changeset" do - assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) - assert changeset.required == [:email] - end - end - - describe "apply_user_email/3" do - setup do - %{user: user_fixture()} - end - - test "requires email to change", %{user: user} do - {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) - assert %{email: ["did not change"]} = errors_on(changeset) - end - - test "validates email", %{user: user} do - {:error, changeset} = - Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) - - assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) - end - - test "validates maximum value for email for security", %{user: user} do - too_long = String.duplicate("db", 100) - - {:error, changeset} = - Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) - - assert "should be at most 160 character(s)" in errors_on(changeset).email - end - - test "validates email uniqueness", %{user: user} do - %{email: email} = user_fixture() - password = valid_user_password() - - {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) - - assert "has already been taken" in errors_on(changeset).email - end - - test "validates current password", %{user: user} do - {:error, changeset} = - Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) - - assert %{current_password: ["is not valid"]} = errors_on(changeset) - end - - test "applies the email without persisting it", %{user: user} do - email = unique_user_email() - {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) - assert user.email == email - assert Accounts.get_user!(user.id).email != email - end - end - - describe "deliver_user_update_email_instructions/3" do - setup do - %{user: user_fixture()} - end - - test "sends token through notification", %{user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) - end) - - {:ok, token} = Base.url_decode64(token, padding: false) - assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) - assert user_token.user_id == user.id - assert user_token.sent_to == user.email - assert user_token.context == "change:current@example.com" - end - end - - describe "update_user_email/2" do - setup do - user = user_fixture() - email = unique_user_email() - - token = - extract_user_token(fn url -> - Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) - end) - - %{user: user, token: token, email: email} - end - - test "updates the email with a valid token", %{user: user, token: token, email: email} do - assert Accounts.update_user_email(user, token) == :ok - changed_user = Repo.get!(User, user.id) - assert changed_user.email != user.email - assert changed_user.email == email - assert changed_user.confirmed_at - assert changed_user.confirmed_at != user.confirmed_at - refute Repo.get_by(UserToken, user_id: user.id) - end - - test "does not update email with invalid token", %{user: user} do - assert Accounts.update_user_email(user, "oops") == :error - assert Repo.get!(User, user.id).email == user.email - assert Repo.get_by(UserToken, user_id: user.id) - end - - test "does not update email if user email changed", %{user: user, token: token} do - assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error - assert Repo.get!(User, user.id).email == user.email - assert Repo.get_by(UserToken, user_id: user.id) - end - - test "does not update email if token expired", %{user: user, token: token} do - {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - assert Accounts.update_user_email(user, token) == :error - assert Repo.get!(User, user.id).email == user.email - assert Repo.get_by(UserToken, user_id: user.id) - end - end - - describe "change_user_password/2" do - test "returns a user changeset" do - assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) - assert changeset.required == [:password] - end - - test "allows fields to be set" do - changeset = - Accounts.change_user_password(%User{}, %{ - "password" => "new valid password" - }) - - assert changeset.valid? - assert get_change(changeset, :password) == "new valid password" - assert is_nil(get_change(changeset, :hashed_password)) - end - end - - describe "update_user_password/3" do - setup do - %{user: user_fixture()} - end - - test "validates password", %{user: user} do - {:error, changeset} = - Accounts.update_user_password(user, valid_user_password(), %{ - password: "not valid", - password_confirmation: "another" - }) - - assert %{ - password: ["should be at least 12 character(s)"], - password_confirmation: ["does not match password"] - } = errors_on(changeset) - end - - test "validates maximum values for password for security", %{user: user} do - too_long = String.duplicate("db", 100) - - {:error, changeset} = - Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) - - assert "should be at most 72 character(s)" in errors_on(changeset).password - end - - test "validates current password", %{user: user} do - {:error, changeset} = - Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) - - assert %{current_password: ["is not valid"]} = errors_on(changeset) - end - - test "updates the password", %{user: user} do - {:ok, user} = - Accounts.update_user_password(user, valid_user_password(), %{ - password: "new valid password" - }) - - assert is_nil(user.password) - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "deletes all tokens for the given user", %{user: user} do - _ = Accounts.generate_user_session_token(user) - - {:ok, _} = - Accounts.update_user_password(user, valid_user_password(), %{ - password: "new valid password" - }) - - refute Repo.get_by(UserToken, user_id: user.id) - end - end - - describe "generate_user_session_token/1" do - setup do - %{user: user_fixture()} - end - - test "generates a token", %{user: user} do - token = Accounts.generate_user_session_token(user) - assert user_token = Repo.get_by(UserToken, token: token) - assert user_token.context == "session" - - # Creating the same token for another user should fail - assert_raise Ecto.ConstraintError, fn -> - Repo.insert!(%UserToken{ - token: user_token.token, - user_id: user_fixture().id, - context: "session" - }) - end - end - end - - describe "get_user_by_session_token/1" do - setup do - user = user_fixture() - token = Accounts.generate_user_session_token(user) - %{user: user, token: token} - end - - test "returns user by token", %{user: user, token: token} do - assert session_user = Accounts.get_user_by_session_token(token) - assert session_user.id == user.id - end - - test "does not return user for invalid token" do - refute Accounts.get_user_by_session_token("oops") - end - - test "does not return user for expired token", %{token: token} do - {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - refute Accounts.get_user_by_session_token(token) - end - end - - describe "delete_user_session_token/1" do - test "deletes the token" do - user = user_fixture() - token = Accounts.generate_user_session_token(user) - assert Accounts.delete_user_session_token(token) == :ok - refute Accounts.get_user_by_session_token(token) - end - end - - describe "deliver_user_confirmation_instructions/2" do - setup do - %{user: user_fixture()} - end - - test "sends token through notification", %{user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_confirmation_instructions(user, url) - end) - - {:ok, token} = Base.url_decode64(token, padding: false) - assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) - assert user_token.user_id == user.id - assert user_token.sent_to == user.email - assert user_token.context == "confirm" - end - end - - describe "confirm_user/1" do - setup do - user = user_fixture() - - token = - extract_user_token(fn url -> - Accounts.deliver_user_confirmation_instructions(user, url) - end) - - %{user: user, token: token} - end - - test "confirms the email with a valid token", %{user: user, token: token} do - assert {:ok, confirmed_user} = Accounts.confirm_user(token) - assert confirmed_user.confirmed_at - assert confirmed_user.confirmed_at != user.confirmed_at - assert Repo.get!(User, user.id).confirmed_at - refute Repo.get_by(UserToken, user_id: user.id) - end - - test "does not confirm with invalid token", %{user: user} do - assert Accounts.confirm_user("oops") == :error - refute Repo.get!(User, user.id).confirmed_at - assert Repo.get_by(UserToken, user_id: user.id) - end - - test "does not confirm email if token expired", %{user: user, token: token} do - {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - assert Accounts.confirm_user(token) == :error - refute Repo.get!(User, user.id).confirmed_at - assert Repo.get_by(UserToken, user_id: user.id) - end - end - - describe "deliver_user_reset_password_instructions/2" do - setup do - %{user: user_fixture()} - end - - test "sends token through notification", %{user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_reset_password_instructions(user, url) - end) - - {:ok, token} = Base.url_decode64(token, padding: false) - assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) - assert user_token.user_id == user.id - assert user_token.sent_to == user.email - assert user_token.context == "reset_password" - end - end - - describe "get_user_by_reset_password_token/1" do - setup do - user = user_fixture() - - token = - extract_user_token(fn url -> - Accounts.deliver_user_reset_password_instructions(user, url) - end) - - %{user: user, token: token} - end - - test "returns the user with valid token", %{user: %{id: id}, token: token} do - assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) - assert Repo.get_by(UserToken, user_id: id) - end - - test "does not return the user with invalid token", %{user: user} do - refute Accounts.get_user_by_reset_password_token("oops") - assert Repo.get_by(UserToken, user_id: user.id) - end - - test "does not return the user if token expired", %{user: user, token: token} do - {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - refute Accounts.get_user_by_reset_password_token(token) - assert Repo.get_by(UserToken, user_id: user.id) - end - end - - describe "reset_user_password/2" do - setup do - %{user: user_fixture()} - end - - test "validates password", %{user: user} do - {:error, changeset} = - Accounts.reset_user_password(user, %{ - password: "not valid", - password_confirmation: "another" - }) - - assert %{ - password: ["should be at least 12 character(s)"], - password_confirmation: ["does not match password"] - } = errors_on(changeset) - end - - test "validates maximum values for password for security", %{user: user} do - too_long = String.duplicate("db", 100) - {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) - assert "should be at most 72 character(s)" in errors_on(changeset).password - end - - test "updates the password", %{user: user} do - {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) - assert is_nil(updated_user.password) - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "deletes all tokens for the given user", %{user: user} do - _ = Accounts.generate_user_session_token(user) - {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) - refute Repo.get_by(UserToken, user_id: user.id) - end - end - - describe "inspect/2 for the User module" do - test "does not include password" do - refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" - end - end -end diff --git a/test/something_erlang_web/controllers/user_session_controller_test.exs b/test/something_erlang_web/controllers/user_session_controller_test.exs deleted file mode 100644 index bafb576..0000000 --- a/test/something_erlang_web/controllers/user_session_controller_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -defmodule SomethingErlangWeb.UserSessionControllerTest do - use SomethingErlangWeb.ConnCase, async: true - - import SomethingErlang.AccountsFixtures - - setup do - %{user: user_fixture()} - end - - describe "POST /users/log_in" do - test "logs the user in", %{conn: conn, user: user} do - conn = - post(conn, ~p"/users/log_in", %{ - "user" => %{"email" => user.email, "password" => valid_user_password()} - }) - - assert get_session(conn, :user_token) - assert redirected_to(conn) == ~p"/" - - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ user.email - assert response =~ "Settings" - assert response =~ "Log out" - end - - test "logs the user in with remember me", %{conn: conn, user: user} do - conn = - post(conn, ~p"/users/log_in", %{ - "user" => %{ - "email" => user.email, - "password" => valid_user_password(), - "remember_me" => "true" - } - }) - - assert conn.resp_cookies["_something_erlang_web_user_remember_me"] - assert redirected_to(conn) == ~p"/" - end - - test "logs the user in with return to", %{conn: conn, user: user} do - conn = - conn - |> init_test_session(user_return_to: "/foo/bar") - |> post(~p"/users/log_in", %{ - "user" => %{ - "email" => user.email, - "password" => valid_user_password() - } - }) - - assert redirected_to(conn) == "/foo/bar" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" - end - - test "login following registration", %{conn: conn, user: user} do - conn = - conn - |> post(~p"/users/log_in", %{ - "_action" => "registered", - "user" => %{ - "email" => user.email, - "password" => valid_user_password() - } - }) - - assert redirected_to(conn) == ~p"/" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" - end - - test "login following password update", %{conn: conn, user: user} do - conn = - conn - |> post(~p"/users/log_in", %{ - "_action" => "password_updated", - "user" => %{ - "email" => user.email, - "password" => valid_user_password() - } - }) - - assert redirected_to(conn) == ~p"/users/settings" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" - end - - test "redirects to login page with invalid credentials", %{conn: conn} do - conn = - post(conn, ~p"/users/log_in", %{ - "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} - }) - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - assert redirected_to(conn) == ~p"/users/log_in" - end - end - - describe "DELETE /users/log_out" do - test "logs the user out", %{conn: conn, user: user} do - conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") - assert redirected_to(conn) == ~p"/" - refute get_session(conn, :user_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" - end - - test "succeeds even if the user is not logged in", %{conn: conn} do - conn = delete(conn, ~p"/users/log_out") - assert redirected_to(conn) == ~p"/" - refute get_session(conn, :user_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" - end - end -end diff --git a/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs b/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs deleted file mode 100644 index 1b94a2d..0000000 --- a/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs +++ /dev/null @@ -1,67 +0,0 @@ -defmodule SomethingErlangWeb.UserConfirmationInstructionsLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - alias SomethingErlang.Accounts - alias SomethingErlang.Repo - - setup do - %{user: user_fixture()} - end - - describe "Resend confirmation" do - test "renders the resend confirmation page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/confirm") - assert html =~ "Resend confirmation instructions" - end - - test "sends a new confirmation token", %{conn: conn, user: user} do - {:ok, lv, _html} = live(conn, ~p"/users/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", user: %{email: user.email}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" - end - - test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do - Repo.update!(Accounts.User.confirm_changeset(user)) - - {:ok, lv, _html} = live(conn, ~p"/users/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", user: %{email: user.email}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - refute Repo.get_by(Accounts.UserToken, user_id: user.id) - end - - test "does not send confirmation token if email is invalid", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/confirm") - - {:ok, conn} = - lv - |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "If your email is in our system" - - assert Repo.all(Accounts.UserToken) == [] - end - end -end diff --git a/test/something_erlang_web/live/user_confirmation_live_test.exs b/test/something_erlang_web/live/user_confirmation_live_test.exs deleted file mode 100644 index 59905a9..0000000 --- a/test/something_erlang_web/live/user_confirmation_live_test.exs +++ /dev/null @@ -1,88 +0,0 @@ -defmodule SomethingErlangWeb.UserConfirmationLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - alias SomethingErlang.Accounts - alias SomethingErlang.Repo - - setup do - %{user: user_fixture()} - end - - describe "Confirm user" do - test "renders confirmation page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") - assert html =~ "Confirm Account" - end - - test "confirms the given token once", %{conn: conn, user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_confirmation_instructions(user, url) - end) - - {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") - - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, "/") - - assert {:ok, conn} = result - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "User confirmed successfully" - - assert Accounts.get_user!(user.id).confirmed_at - refute get_session(conn, :user_token) - assert Repo.all(Accounts.UserToken) == [] - - # when not logged in - {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") - - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, "/") - - assert {:ok, conn} = result - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "User confirmation link is invalid or it has expired" - - # when logged in - {:ok, lv, _html} = - build_conn() - |> log_in_user(user) - |> live(~p"/users/confirm/#{token}") - - result = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, "/") - - assert {:ok, conn} = result - refute Phoenix.Flash.get(conn.assigns.flash, :error) - end - - test "does not confirm email with invalid token", %{conn: conn, user: user} do - {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") - - {:ok, conn} = - lv - |> form("#confirmation_form") - |> render_submit() - |> follow_redirect(conn, ~p"/") - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "User confirmation link is invalid or it has expired" - - refute Accounts.get_user!(user.id).confirmed_at - end - end -end diff --git a/test/something_erlang_web/live/user_forgot_password_live_test.exs b/test/something_erlang_web/live/user_forgot_password_live_test.exs deleted file mode 100644 index f103f20..0000000 --- a/test/something_erlang_web/live/user_forgot_password_live_test.exs +++ /dev/null @@ -1,63 +0,0 @@ -defmodule SomethingErlangWeb.UserForgotPasswordLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - alias SomethingErlang.Accounts - alias SomethingErlang.Repo - - describe "Forgot password page" do - test "renders email page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/reset_password") - - assert html =~ "Forgot your password?" - assert html =~ "Register" - assert html =~ "Log in" - end - - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_user(user_fixture()) - |> live(~p"/users/reset_password") - |> follow_redirect(conn, ~p"/") - - assert {:ok, _conn} = result - end - end - - describe "Reset link" do - setup do - %{user: user_fixture()} - end - - test "sends a new reset password token", %{conn: conn, user: user} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password") - - {:ok, conn} = - lv - |> form("#reset_password_form", user: %{"email" => user.email}) - |> render_submit() - |> follow_redirect(conn, "/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" - - assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == - "reset_password" - end - - test "does not send reset password token if email is invalid", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password") - - {:ok, conn} = - lv - |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) - |> render_submit() - |> follow_redirect(conn, "/") - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" - assert Repo.all(Accounts.UserToken) == [] - end - end -end diff --git a/test/something_erlang_web/live/user_login_live_test.exs b/test/something_erlang_web/live/user_login_live_test.exs deleted file mode 100644 index 9f8add6..0000000 --- a/test/something_erlang_web/live/user_login_live_test.exs +++ /dev/null @@ -1,87 +0,0 @@ -defmodule SomethingErlangWeb.UserLoginLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - describe "Log in page" do - test "renders log in page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/log_in") - - assert html =~ "Log in" - assert html =~ "Register" - assert html =~ "Forgot your password?" - end - - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_user(user_fixture()) - |> live(~p"/users/log_in") - |> follow_redirect(conn, "/") - - assert {:ok, _conn} = result - end - end - - describe "user login" do - test "redirects if user login with valid credentials", %{conn: conn} do - password = "123456789abcd" - user = user_fixture(%{password: password}) - - {:ok, lv, _html} = live(conn, ~p"/users/log_in") - - form = - form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) - - conn = submit_form(form, conn) - - assert redirected_to(conn) == ~p"/" - end - - test "redirects to login page with a flash error if there are no valid credentials", %{ - conn: conn - } do - {:ok, lv, _html} = live(conn, ~p"/users/log_in") - - form = - form(lv, "#login_form", - user: %{email: "test@email.com", password: "123456", remember_me: true} - ) - - conn = submit_form(form, conn) - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - - assert redirected_to(conn) == "/users/log_in" - end - end - - describe "login navigation" do - test "redirects to registration page when the Register button is clicked", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/log_in") - - {:ok, _login_live, login_html} = - lv - |> element(~s|a:fl-contains("Sign up")|) - |> render_click() - |> follow_redirect(conn, ~p"/users/register") - - assert login_html =~ "Register" - end - - test "redirects to forgot password page when the Forgot Password button is clicked", %{ - conn: conn - } do - {:ok, lv, _html} = live(conn, ~p"/users/log_in") - - {:ok, conn} = - lv - |> element(~s{a:fl-contains('Forgot your password?')}) - |> render_click() - |> follow_redirect(conn, ~p"/users/reset_password") - - assert conn.resp_body =~ "Forgot your password?" - end - end -end diff --git a/test/something_erlang_web/live/user_registration_live_test.exs b/test/something_erlang_web/live/user_registration_live_test.exs deleted file mode 100644 index 2cdb902..0000000 --- a/test/something_erlang_web/live/user_registration_live_test.exs +++ /dev/null @@ -1,87 +0,0 @@ -defmodule SomethingErlangWeb.UserRegistrationLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - describe "Registration page" do - test "renders registration page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/users/register") - - assert html =~ "Register" - assert html =~ "Log in" - end - - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_user(user_fixture()) - |> live(~p"/users/register") - |> follow_redirect(conn, "/") - - assert {:ok, _conn} = result - end - - test "renders errors for invalid data", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/register") - - result = - lv - |> element("#registration_form") - |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) - - assert result =~ "Register" - assert result =~ "must have the @ sign and no spaces" - assert result =~ "should be at least 12 character" - end - end - - describe "register user" do - test "creates account and logs the user in", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/register") - - email = unique_user_email() - form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) - render_submit(form) - conn = follow_trigger_action(form, conn) - - assert redirected_to(conn) == ~p"/" - - # Now do a logged in request and assert on the menu - conn = get(conn, "/") - response = html_response(conn, 200) - assert response =~ email - assert response =~ "Settings" - assert response =~ "Log out" - end - - test "renders errors for duplicated email", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/register") - - user = user_fixture(%{email: "test@email.com"}) - - result = - lv - |> form("#registration_form", - user: %{"email" => user.email, "password" => "valid_password"} - ) - |> render_submit() - - assert result =~ "has already been taken" - end - end - - describe "registration navigation" do - test "redirects to login page when the Log in button is clicked", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/register") - - {:ok, _login_live, login_html} = - lv - |> element(~s|main a:fl-contains("Sign in")|) - |> render_click() - |> follow_redirect(conn, ~p"/users/log_in") - - assert login_html =~ "Log in" - end - end -end diff --git a/test/something_erlang_web/live/user_reset_password_live_test.exs b/test/something_erlang_web/live/user_reset_password_live_test.exs deleted file mode 100644 index 17fc8bd..0000000 --- a/test/something_erlang_web/live/user_reset_password_live_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule SomethingErlangWeb.UserResetPasswordLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - alias SomethingErlang.Accounts - - setup do - user = user_fixture() - - token = - extract_user_token(fn url -> - Accounts.deliver_user_reset_password_instructions(user, url) - end) - - %{token: token, user: user} - end - - describe "Reset password page" do - test "renders reset password with valid token", %{conn: conn, token: token} do - {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") - - assert html =~ "Reset Password" - end - - test "does not render reset password with invalid token", %{conn: conn} do - {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") - - assert to == %{ - flash: %{"error" => "Reset password link is invalid or it has expired."}, - to: ~p"/" - } - end - - test "renders errors for invalid data", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") - - result = - lv - |> element("#reset_password_form") - |> render_change( - user: %{"password" => "secret12", "confirmation_password" => "secret123456"} - ) - - assert result =~ "should be at least 12 character" - assert result =~ "does not match password" - end - end - - describe "Reset Password" do - test "resets password once", %{conn: conn, token: token, user: user} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") - - {:ok, conn} = - lv - |> form("#reset_password_form", - user: %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - ) - |> render_submit() - |> follow_redirect(conn, ~p"/users/log_in") - - refute get_session(conn, :user_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "does not reset password on invalid data", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") - - result = - lv - |> form("#reset_password_form", - user: %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - ) - |> render_submit() - - assert result =~ "Reset Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - end - end - - describe "Reset password navigation" do - test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") - - {:ok, conn} = - lv - |> element(~s|main a:fl-contains("Log in")|) - |> render_click() - |> follow_redirect(conn, ~p"/users/log_in") - - assert conn.resp_body =~ "Log in" - end - - test "redirects to password reset page when the Register button is clicked", %{ - conn: conn, - token: token - } do - {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") - - {:ok, conn} = - lv - |> element(~s|main a:fl-contains("Register")|) - |> render_click() - |> follow_redirect(conn, ~p"/users/register") - - assert conn.resp_body =~ "Register" - end - end -end diff --git a/test/something_erlang_web/live/user_settings_live_test.exs b/test/something_erlang_web/live/user_settings_live_test.exs deleted file mode 100644 index e095de8..0000000 --- a/test/something_erlang_web/live/user_settings_live_test.exs +++ /dev/null @@ -1,210 +0,0 @@ -defmodule SomethingErlangWeb.UserSettingsLiveTest do - use SomethingErlangWeb.ConnCase - - alias SomethingErlang.Accounts - import Phoenix.LiveViewTest - import SomethingErlang.AccountsFixtures - - describe "Settings page" do - test "renders settings page", %{conn: conn} do - {:ok, _lv, html} = - conn - |> log_in_user(user_fixture()) - |> live(~p"/users/settings") - - assert html =~ "Change Email" - assert html =~ "Change Password" - end - - test "redirects if user is not logged in", %{conn: conn} do - assert {:error, redirect} = live(conn, ~p"/users/settings") - - assert {:redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"/users/log_in" - assert %{"error" => "You must log in to access this page."} = flash - end - end - - describe "update email form" do - setup %{conn: conn} do - password = valid_user_password() - user = user_fixture(%{password: password}) - %{conn: log_in_user(conn, user), user: user, password: password} - end - - test "updates the user email", %{conn: conn, password: password, user: user} do - new_email = unique_user_email() - - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - result = - lv - |> form("#email_form", %{ - "current_password" => password, - "user" => %{"email" => new_email} - }) - |> render_submit() - - assert result =~ "A link to confirm your email" - assert Accounts.get_user_by_email(user.email) - end - - test "renders errors with invalid data (phx-change)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - result = - lv - |> element("#email_form") - |> render_change(%{ - "action" => "update_email", - "current_password" => "invalid", - "user" => %{"email" => "with spaces"} - }) - - assert result =~ "Change Email" - assert result =~ "must have the @ sign and no spaces" - end - - test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - result = - lv - |> form("#email_form", %{ - "current_password" => "invalid", - "user" => %{"email" => user.email} - }) - |> render_submit() - - assert result =~ "Change Email" - assert result =~ "did not change" - assert result =~ "is not valid" - end - end - - describe "update password form" do - setup %{conn: conn} do - password = valid_user_password() - user = user_fixture(%{password: password}) - %{conn: log_in_user(conn, user), user: user, password: password} - end - - test "updates the user password", %{conn: conn, user: user, password: password} do - new_password = valid_user_password() - - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - form = - form(lv, "#password_form", %{ - "current_password" => password, - "user" => %{ - "email" => user.email, - "password" => new_password, - "password_confirmation" => new_password - } - }) - - render_submit(form) - - new_password_conn = follow_trigger_action(form, conn) - - assert redirected_to(new_password_conn) == ~p"/users/settings" - - assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) - - assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ - "Password updated successfully" - - assert Accounts.get_user_by_email_and_password(user.email, new_password) - end - - test "renders errors with invalid data (phx-change)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - result = - lv - |> element("#password_form") - |> render_change(%{ - "current_password" => "invalid", - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - assert result =~ "Change Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - end - - test "renders errors with invalid data (phx-submit)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"/users/settings") - - result = - lv - |> form("#password_form", %{ - "current_password" => "invalid", - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - |> render_submit() - - assert result =~ "Change Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - assert result =~ "is not valid" - end - end - - describe "confirm email" do - setup %{conn: conn} do - user = user_fixture() - email = unique_user_email() - - token = - extract_user_token(fn url -> - Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) - end) - - %{conn: log_in_user(conn, user), token: token, email: email, user: user} - end - - test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do - {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") - - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"/users/settings" - assert %{"info" => message} = flash - assert message == "Email changed successfully." - refute Accounts.get_user_by_email(user.email) - assert Accounts.get_user_by_email(email) - - # use confirm token again - {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"/users/settings" - assert %{"error" => message} = flash - assert message == "Email change link is invalid or it has expired." - end - - test "does not update email with invalid token", %{conn: conn, user: user} do - {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"/users/settings" - assert %{"error" => message} = flash - assert message == "Email change link is invalid or it has expired." - assert Accounts.get_user_by_email(user.email) - end - - test "redirects if user is not logged in", %{token: token} do - conn = build_conn() - {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") - assert {:redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"/users/log_in" - assert %{"error" => message} = flash - assert message == "You must log in to access this page." - end - end -end diff --git a/test/something_erlang_web/user_auth_test.exs b/test/something_erlang_web/user_auth_test.exs deleted file mode 100644 index 817e0cb..0000000 --- a/test/something_erlang_web/user_auth_test.exs +++ /dev/null @@ -1,272 +0,0 @@ -defmodule SomethingErlangWeb.UserAuthTest do - use SomethingErlangWeb.ConnCase, async: true - - alias Phoenix.LiveView - alias SomethingErlang.Accounts - alias SomethingErlangWeb.UserAuth - import SomethingErlang.AccountsFixtures - - @remember_me_cookie "_something_erlang_web_user_remember_me" - - setup %{conn: conn} do - conn = - conn - |> Map.replace!(:secret_key_base, SomethingErlangWeb.Endpoint.config(:secret_key_base)) - |> init_test_session(%{}) - - %{user: user_fixture(), conn: conn} - end - - describe "log_in_user/3" do - test "stores the user token in the session", %{conn: conn, user: user} do - conn = UserAuth.log_in_user(conn, user) - assert token = get_session(conn, :user_token) - assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" - assert redirected_to(conn) == ~p"/" - assert Accounts.get_user_by_session_token(token) - end - - test "clears everything previously stored in the session", %{conn: conn, user: user} do - conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) - refute get_session(conn, :to_be_removed) - end - - test "redirects to the configured path", %{conn: conn, user: user} do - conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) - assert redirected_to(conn) == "/hello" - end - - test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do - conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) - assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] - - assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] - assert signed_token != get_session(conn, :user_token) - assert max_age == 5_184_000 - end - end - - describe "logout_user/1" do - test "erases session and cookies", %{conn: conn, user: user} do - user_token = Accounts.generate_user_session_token(user) - - conn = - conn - |> put_session(:user_token, user_token) - |> put_req_cookie(@remember_me_cookie, user_token) - |> fetch_cookies() - |> UserAuth.log_out_user() - - refute get_session(conn, :user_token) - refute conn.cookies[@remember_me_cookie] - assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == ~p"/" - refute Accounts.get_user_by_session_token(user_token) - end - - test "broadcasts to the given live_socket_id", %{conn: conn} do - live_socket_id = "users_sessions:abcdef-token" - SomethingErlangWeb.Endpoint.subscribe(live_socket_id) - - conn - |> put_session(:live_socket_id, live_socket_id) - |> UserAuth.log_out_user() - - assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} - end - - test "works even if user is already logged out", %{conn: conn} do - conn = conn |> fetch_cookies() |> UserAuth.log_out_user() - refute get_session(conn, :user_token) - assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == ~p"/" - end - end - - describe "fetch_current_user/2" do - test "authenticates user from session", %{conn: conn, user: user} do - user_token = Accounts.generate_user_session_token(user) - conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) - assert conn.assigns.current_user.id == user.id - end - - test "authenticates user from cookies", %{conn: conn, user: user} do - logged_in_conn = - conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) - - user_token = logged_in_conn.cookies[@remember_me_cookie] - %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] - - conn = - conn - |> put_req_cookie(@remember_me_cookie, signed_token) - |> UserAuth.fetch_current_user([]) - - assert conn.assigns.current_user.id == user.id - assert get_session(conn, :user_token) == user_token - - assert get_session(conn, :live_socket_id) == - "users_sessions:#{Base.url_encode64(user_token)}" - end - - test "does not authenticate if data is missing", %{conn: conn, user: user} do - _ = Accounts.generate_user_session_token(user) - conn = UserAuth.fetch_current_user(conn, []) - refute get_session(conn, :user_token) - refute conn.assigns.current_user - end - end - - describe "on_mount: mount_current_user" do - test "assigns current_user based on a valid user_token ", %{conn: conn, user: user} do - user_token = Accounts.generate_user_session_token(user) - session = conn |> put_session(:user_token, user_token) |> get_session() - - {:cont, updated_socket} = - UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.current_user.id == user.id - end - - test "assigns nil to current_user assign if there isn't a valid user_token ", %{conn: conn} do - user_token = "invalid_token" - session = conn |> put_session(:user_token, user_token) |> get_session() - - {:cont, updated_socket} = - UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.current_user == nil - end - - test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do - session = conn |> get_session() - - {:cont, updated_socket} = - UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.current_user == nil - end - end - - describe "on_mount: ensure_authenticated" do - test "authenticates current_user based on a valid user_token ", %{conn: conn, user: user} do - user_token = Accounts.generate_user_session_token(user) - session = conn |> put_session(:user_token, user_token) |> get_session() - - {:cont, updated_socket} = - UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.current_user.id == user.id - end - - test "redirects to login page if there isn't a valid user_token ", %{conn: conn} do - user_token = "invalid_token" - session = conn |> put_session(:user_token, user_token) |> get_session() - - socket = %LiveView.Socket{ - endpoint: SomethingErlangWeb.Endpoint, - assigns: %{__changed__: %{}, flash: %{}} - } - - {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) - assert updated_socket.assigns.current_user == nil - end - - test "redirects to login page if there isn't a user_token ", %{conn: conn} do - session = conn |> get_session() - - socket = %LiveView.Socket{ - endpoint: SomethingErlangWeb.Endpoint, - assigns: %{__changed__: %{}, flash: %{}} - } - - {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) - assert updated_socket.assigns.current_user == nil - end - end - - describe "on_mount: :redirect_if_user_is_authenticated" do - test "redirects if there is an authenticated user ", %{conn: conn, user: user} do - user_token = Accounts.generate_user_session_token(user) - session = conn |> put_session(:user_token, user_token) |> get_session() - - assert {:halt, _updated_socket} = - UserAuth.on_mount( - :redirect_if_user_is_authenticated, - %{}, - session, - %LiveView.Socket{} - ) - end - - test "Don't redirect is there is no authenticated user", %{conn: conn} do - session = conn |> get_session() - - assert {:cont, _updated_socket} = - UserAuth.on_mount( - :redirect_if_user_is_authenticated, - %{}, - session, - %LiveView.Socket{} - ) - end - end - - describe "redirect_if_user_is_authenticated/2" do - test "redirects if user is authenticated", %{conn: conn, user: user} do - conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) - assert conn.halted - assert redirected_to(conn) == ~p"/" - end - - test "does not redirect if user is not authenticated", %{conn: conn} do - conn = UserAuth.redirect_if_user_is_authenticated(conn, []) - refute conn.halted - refute conn.status - end - end - - describe "require_authenticated_user/2" do - test "redirects if user is not authenticated", %{conn: conn} do - conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) - assert conn.halted - - assert redirected_to(conn) == ~p"/users/log_in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must log in to access this page." - end - - test "stores the path to redirect to on GET", %{conn: conn} do - halted_conn = - %{conn | path_info: ["foo"], query_string: ""} - |> fetch_flash() - |> UserAuth.require_authenticated_user([]) - - assert halted_conn.halted - assert get_session(halted_conn, :user_return_to) == "/foo" - - halted_conn = - %{conn | path_info: ["foo"], query_string: "bar=baz"} - |> fetch_flash() - |> UserAuth.require_authenticated_user([]) - - assert halted_conn.halted - assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" - - halted_conn = - %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} - |> fetch_flash() - |> UserAuth.require_authenticated_user([]) - - assert halted_conn.halted - refute get_session(halted_conn, :user_return_to) - end - - test "does not redirect if user is authenticated", %{conn: conn, user: user} do - conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) - refute conn.halted - refute conn.status - end - end -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 5c19c5c..579afcc 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,30 +35,4 @@ defmodule SomethingErlangWeb.ConnCase do SomethingErlang.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end - - @doc """ - Setup helper that registers and logs in users. - - setup :register_and_log_in_user - - It stores an updated connection and a registered user in the - test context. - """ - def register_and_log_in_user(%{conn: conn}) do - user = SomethingErlang.AccountsFixtures.user_fixture() - %{conn: log_in_user(conn, user), user: user} - end - - @doc """ - Logs the given `user` into the `conn`. - - It returns an updated `conn`. - """ - def log_in_user(conn, user) do - token = SomethingErlang.Accounts.generate_user_session_token(user) - - conn - |> Phoenix.ConnTest.init_test_session(%{}) - |> Plug.Conn.put_session(:user_token, token) - end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex deleted file mode 100644 index 6bd9f67..0000000 --- a/test/support/fixtures/accounts_fixtures.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule SomethingErlang.AccountsFixtures do - @moduledoc """ - This module defines test helpers for creating - entities via the `SomethingErlang.Accounts` context. - """ - - def unique_user_email, do: "user#{System.unique_integer()}@example.com" - def valid_user_password, do: "hello world!" - - def valid_user_attributes(attrs \\ %{}) do - Enum.into(attrs, %{ - email: unique_user_email(), - password: valid_user_password() - }) - end - - def user_fixture(attrs \\ %{}) do - {:ok, user} = - attrs - |> valid_user_attributes() - |> SomethingErlang.Accounts.register_user() - - user - end - - def extract_user_token(fun) do - {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") - [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") - token - end -end