diff --git a/config/config.exs b/config/config.exs index 403ec5b..17cccc6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,7 +20,7 @@ config :zoeyscomputer, ZoeyscomputerWeb.Endpoint, layout: false ], pubsub_server: Zoeyscomputer.PubSub, - live_view: [signing_salt: "byg+88ZA"] + live_view: [signing_salt: "uUcDyRmg"] # Configures the mailer # diff --git a/config/dev.exs b/config/dev.exs index 47a5024..a071a22 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -23,7 +23,7 @@ config :zoeyscomputer, ZoeyscomputerWeb.Endpoint, check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "Qe0dIfVihLtZD6q2TbcUJvJZEo4i5SAAAmKfLXfjj8AxZfXgL4+g2ISn9CaOq3ES", + secret_key_base: "qP4Ia7HM4vZ854nrMdK3FZvV57X4tDxHM1btfYuMzc2U1QpAAoVGTM+bWh5/ob/I", watchers: [ esbuild: {Esbuild, :install_and_run, [:zoeyscomputer, ~w(--sourcemap=inline --watch)]}, tailwind: {Tailwind, :install_and_run, [:zoeyscomputer, ~w(--watch)]} diff --git a/config/test.exs b/config/test.exs index 82a6b5b..36af3f0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ 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 @@ -17,7 +20,7 @@ config :zoeyscomputer, Zoeyscomputer.Repo, # you can enable the server option below. config :zoeyscomputer, ZoeyscomputerWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "bF1Py67SqufVZ+89ftwi6qMCvOpv01k6Gwm2xKCwOCuGUdDsJ+rYsRtRbS1efk1r", + secret_key_base: "EvnzV8Funkmd+/QjtPf1M4nnmdBB+8k1CXRrw3Q+7WDWTudeudvKb4VcUb/lM5K3", server: false # In test we don't send emails diff --git a/deps.nix b/deps.nix new file mode 100644 index 0000000..19d05ed --- /dev/null +++ b/deps.nix @@ -0,0 +1,727 @@ +{ pkgs, lib, beamPackages, overrides ? (x: y: { }) }: + +let + buildMix = lib.makeOverridable beamPackages.buildMix; + buildRebar3 = lib.makeOverridable beamPackages.buildRebar3; + + defaultOverrides = (final: prev: + + let + apps = { + explorer = [ + { + name = "rustlerPrecompiled"; + toolchain = { + name = "nightly-2024-07-26"; + sha256 = "sha256-5icy5hSaQy6/fUim9L2vz2GeZNC3fX1N5T2MjnkTplc="; + }; + } + ]; + tokenizers = [ + { + name = "rustlerPrecompiled"; + } + ]; + }; + + elixirConfig = pkgs.writeTextDir + "config/config.exs" + '' + import Config + + config :explorer, Explorer.PolarsBackend.Native, + skip_compilation?: true + + config :tokenizers, Tokenizers.Native, + skip_compilation?: true + ''; + + buildNativeDir = src: "${src}/native/${with builtins; head (attrNames (readDir "${src}/native"))}"; + + workarounds = { + rustlerPrecompiled = { toolchain ? null, ... }: old: + let + extendedPkgs = pkgs.extend fenixOverlay; + fenixOverlay = import + "${fetchTarball { + url = "https://github.com/nix-community/fenix/archive/43efa7a3a97f290441bd75b18defcd4f7b8df220.tar.gz"; + sha256 = "sha256:1b9v45cafixpbj6iqjw3wr0yfpcrh3p11am7v0cjpjq5n8bhs8v3"; + }}/overlay.nix"; + nativeDir = buildNativeDir old.src; + fenix = + if toolchain == null + then extendedPkgs.fenix.stable + else extendedPkgs.fenix.fromToolchainName toolchain; + native = (extendedPkgs.makeRustPlatform { + inherit (fenix) cargo rustc; + }).buildRustPackage { + pname = "${old.packageName}-native"; + version = old.version; + src = nativeDir; + cargoLock = { + lockFile = "${nativeDir}/Cargo.lock"; + }; + nativeBuildInputs = [ extendedPkgs.cmake ] ++ extendedPkgs.lib.lists.optional extendedPkgs.stdenv.isDarwin extendedPkgs.darwin.IOKit; + doCheck = false; + }; + in + { + nativeBuildInputs = [ extendedPkgs.cargo ]; + + appConfigPath = "${elixirConfig}/config"; + + env.RUSTLER_PRECOMPILED_FORCE_BUILD_ALL = "true"; + env.RUSTLER_PRECOMPILED_GLOBAL_CACHE_PATH = "unused-but-required"; + + preConfigure = '' + mkdir -p priv/native + for lib in ${native}/lib/* + do + ln -s "$lib" "priv/native/$(basename "$lib")" + done + ''; + }; + }; + + applyOverrides = appName: drv: + let + allOverridesForApp = builtins.foldl' + (acc: workaround: acc // (workarounds.${workaround.name} workaround) drv) + { } + apps.${appName}; + + in + if builtins.hasAttr appName apps + then + drv.override allOverridesForApp + else + drv; + + in + builtins.mapAttrs + applyOverrides + prev); + + self = packages // (defaultOverrides self packages) // (overrides self packages); + + packages = with beamPackages; with self; { + bandit = + let + version = "1.5.7"; + in + buildMix { + inherit version; + name = "bandit"; + + src = fetchHex { + inherit version; + pkg = "bandit"; + sha256 = "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"; + }; + + beamDeps = [ hpax plug telemetry thousand_island websock ]; + }; + + bcrypt_elixir = + let + version = "3.2.0"; + in + buildMix { + inherit version; + name = "bcrypt_elixir"; + + src = fetchHex { + inherit version; + pkg = "bcrypt_elixir"; + sha256 = "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"; + }; + + beamDeps = [ comeonin elixir_make ]; + }; + + castore = + let + version = "1.0.9"; + in + buildMix { + inherit version; + name = "castore"; + + src = fetchHex { + inherit version; + pkg = "castore"; + sha256 = "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"; + }; + }; + + comeonin = + let + version = "5.5.0"; + in + buildMix { + inherit version; + name = "comeonin"; + + src = fetchHex { + inherit version; + pkg = "comeonin"; + sha256 = "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"; + }; + }; + + db_connection = + let + version = "2.7.0"; + in + buildMix { + inherit version; + name = "db_connection"; + + src = fetchHex { + inherit version; + pkg = "db_connection"; + sha256 = "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"; + }; + + beamDeps = [ telemetry ]; + }; + + decimal = + let + version = "2.1.1"; + in + buildMix { + inherit version; + name = "decimal"; + + src = fetchHex { + inherit version; + pkg = "decimal"; + sha256 = "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"; + }; + }; + + dns_cluster = + let + version = "0.1.3"; + in + buildMix { + inherit version; + name = "dns_cluster"; + + src = fetchHex { + inherit version; + pkg = "dns_cluster"; + sha256 = "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"; + }; + }; + + ecto = + let + version = "3.12.4"; + in + buildMix { + inherit version; + name = "ecto"; + + src = fetchHex { + inherit version; + pkg = "ecto"; + sha256 = "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"; + }; + + beamDeps = [ decimal jason telemetry ]; + }; + + ecto_sql = + let + version = "3.12.1"; + in + buildMix { + inherit version; + name = "ecto_sql"; + + src = fetchHex { + inherit version; + pkg = "ecto_sql"; + sha256 = "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"; + }; + + beamDeps = [ db_connection ecto postgrex telemetry ]; + }; + + elixir_make = + let + version = "0.8.4"; + in + buildMix { + inherit version; + name = "elixir_make"; + + src = fetchHex { + inherit version; + pkg = "elixir_make"; + sha256 = "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"; + }; + + beamDeps = [ castore ]; + }; + + esbuild = + let + version = "0.8.2"; + in + buildMix { + inherit version; + name = "esbuild"; + + src = fetchHex { + inherit version; + pkg = "esbuild"; + sha256 = "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"; + }; + + beamDeps = [ castore jason ]; + }; + + expo = + let + version = "1.1.0"; + in + buildMix { + inherit version; + name = "expo"; + + src = fetchHex { + inherit version; + pkg = "expo"; + sha256 = "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"; + }; + }; + + finch = + let + version = "0.19.0"; + in + buildMix { + inherit version; + name = "finch"; + + src = fetchHex { + inherit version; + pkg = "finch"; + sha256 = "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"; + }; + + beamDeps = [ mime mint nimble_options nimble_pool telemetry ]; + }; + + gettext = + let + version = "0.26.1"; + in + buildMix { + inherit version; + name = "gettext"; + + src = fetchHex { + inherit version; + pkg = "gettext"; + sha256 = "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"; + }; + + beamDeps = [ expo ]; + }; + + hpax = + let + version = "1.0.0"; + in + buildMix { + inherit version; + name = "hpax"; + + src = fetchHex { + inherit version; + pkg = "hpax"; + sha256 = "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"; + }; + }; + + jason = + let + version = "1.4.4"; + in + buildMix { + inherit version; + name = "jason"; + + src = fetchHex { + inherit version; + pkg = "jason"; + sha256 = "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"; + }; + + beamDeps = [ decimal ]; + }; + + mime = + let + version = "2.0.6"; + in + buildMix { + inherit version; + name = "mime"; + + src = fetchHex { + inherit version; + pkg = "mime"; + sha256 = "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"; + }; + }; + + mint = + let + version = "1.6.2"; + in + buildMix { + inherit version; + name = "mint"; + + src = fetchHex { + inherit version; + pkg = "mint"; + sha256 = "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"; + }; + + beamDeps = [ castore hpax ]; + }; + + nimble_options = + let + version = "1.1.1"; + in + buildMix { + inherit version; + name = "nimble_options"; + + src = fetchHex { + inherit version; + pkg = "nimble_options"; + sha256 = "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"; + }; + }; + + nimble_pool = + let + version = "1.1.0"; + in + buildMix { + inherit version; + name = "nimble_pool"; + + src = fetchHex { + inherit version; + pkg = "nimble_pool"; + sha256 = "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"; + }; + }; + + phoenix = + let + version = "1.7.14"; + in + buildMix { + inherit version; + name = "phoenix"; + + src = fetchHex { + inherit version; + pkg = "phoenix"; + sha256 = "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"; + }; + + beamDeps = [ castore jason phoenix_pubsub phoenix_template plug plug_crypto telemetry websock_adapter ]; + }; + + phoenix_ecto = + let + version = "4.6.2"; + in + buildMix { + inherit version; + name = "phoenix_ecto"; + + src = fetchHex { + inherit version; + pkg = "phoenix_ecto"; + sha256 = "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"; + }; + + beamDeps = [ ecto phoenix_html plug postgrex ]; + }; + + phoenix_html = + let + version = "4.1.1"; + in + buildMix { + inherit version; + name = "phoenix_html"; + + src = fetchHex { + inherit version; + pkg = "phoenix_html"; + sha256 = "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"; + }; + }; + + phoenix_live_dashboard = + let + version = "0.8.4"; + in + buildMix { + inherit version; + name = "phoenix_live_dashboard"; + + src = fetchHex { + inherit version; + pkg = "phoenix_live_dashboard"; + sha256 = "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"; + }; + + beamDeps = [ ecto mime phoenix_live_view telemetry_metrics ]; + }; + + phoenix_live_view = + let + version = "1.0.0-rc.7"; + in + buildMix { + inherit version; + name = "phoenix_live_view"; + + src = fetchHex { + inherit version; + pkg = "phoenix_live_view"; + sha256 = "b82a4575f6f3eb5b97922ec6874b0c52b3ca0cc5dcb4b14ddc478cbfa135dd01"; + }; + + beamDeps = [ jason phoenix phoenix_html phoenix_template plug telemetry ]; + }; + + phoenix_pubsub = + let + version = "2.1.3"; + in + buildMix { + inherit version; + name = "phoenix_pubsub"; + + src = fetchHex { + inherit version; + pkg = "phoenix_pubsub"; + sha256 = "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"; + }; + }; + + phoenix_template = + let + version = "1.0.4"; + in + buildMix { + inherit version; + name = "phoenix_template"; + + src = fetchHex { + inherit version; + pkg = "phoenix_template"; + sha256 = "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"; + }; + + beamDeps = [ phoenix_html ]; + }; + + plug = + let + version = "1.16.1"; + in + buildMix { + inherit version; + name = "plug"; + + src = fetchHex { + inherit version; + pkg = "plug"; + sha256 = "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"; + }; + + beamDeps = [ mime plug_crypto telemetry ]; + }; + + plug_crypto = + let + version = "2.1.0"; + in + buildMix { + inherit version; + name = "plug_crypto"; + + src = fetchHex { + inherit version; + pkg = "plug_crypto"; + sha256 = "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"; + }; + }; + + postgrex = + let + version = "0.19.1"; + in + buildMix { + inherit version; + name = "postgrex"; + + src = fetchHex { + inherit version; + pkg = "postgrex"; + sha256 = "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"; + }; + + beamDeps = [ db_connection decimal jason ]; + }; + + swoosh = + let + version = "1.17.2"; + in + buildMix { + inherit version; + name = "swoosh"; + + src = fetchHex { + inherit version; + pkg = "swoosh"; + sha256 = "de914359f0ddc134dc0d7735e28922d49d0503f31e4bd66b44e26039c2226d39"; + }; + + beamDeps = [ bandit finch jason mime plug telemetry ]; + }; + + tailwind = + let + version = "0.2.4"; + in + buildMix { + inherit version; + name = "tailwind"; + + src = fetchHex { + inherit version; + pkg = "tailwind"; + sha256 = "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"; + }; + + beamDeps = [ castore ]; + }; + + telemetry = + let + version = "1.3.0"; + in + buildRebar3 { + inherit version; + name = "telemetry"; + + src = fetchHex { + inherit version; + pkg = "telemetry"; + sha256 = "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"; + }; + }; + + telemetry_metrics = + let + version = "1.0.0"; + in + buildMix { + inherit version; + name = "telemetry_metrics"; + + src = fetchHex { + inherit version; + pkg = "telemetry_metrics"; + sha256 = "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"; + }; + + beamDeps = [ telemetry ]; + }; + + telemetry_poller = + let + version = "1.1.0"; + in + buildRebar3 { + inherit version; + name = "telemetry_poller"; + + src = fetchHex { + inherit version; + pkg = "telemetry_poller"; + sha256 = "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"; + }; + + beamDeps = [ telemetry ]; + }; + + thousand_island = + let + version = "1.3.5"; + in + buildMix { + inherit version; + name = "thousand_island"; + + src = fetchHex { + inherit version; + pkg = "thousand_island"; + sha256 = "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"; + }; + + beamDeps = [ telemetry ]; + }; + + websock = + let + version = "0.5.3"; + in + buildMix { + inherit version; + name = "websock"; + + src = fetchHex { + inherit version; + pkg = "websock"; + sha256 = "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"; + }; + }; + + websock_adapter = + let + version = "0.5.7"; + in + buildMix { + inherit version; + name = "websock_adapter"; + + src = fetchHex { + inherit version; + pkg = "websock_adapter"; + sha256 = "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"; + }; + + beamDeps = [ bandit plug websock ]; + }; + }; +in +self diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..84a69b0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,166 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1727826117, + "narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, + "locked": { + "lastModified": 1719994518, + "narHash": "sha256-pQMhCCHyQGRzdfAkdJ4cIWiw+JNuWsTX7f0ZYSyz0VY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9227223f6d922fee3c7b190b2cc238a99527bbb7", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "lexical": { + "inputs": { + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs", + "systems": "systems" + }, + "locked": { + "lastModified": 1727126932, + "narHash": "sha256-JvLiDxwkKD1u/DmTVUpmymQoCfYdoTHAOSs9fYCLfcw=", + "owner": "lexical-lsp", + "repo": "lexical", + "rev": "48fb3fa4f25fc490f297758761c5593eb45a2026", + "type": "github" + }, + "original": { + "owner": "lexical-lsp", + "repo": "lexical", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1719931832, + "narHash": "sha256-0LD+KePCKKEb4CcPsTBOwf019wDtZJanjoKm1S8q3Do=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0aeab749216e4c073cece5d34bc01b79e717c3e0", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1727825735, + "narHash": "sha256-0xHYkMkeLVQAMa7gvkddbPqpxph+hDzdu1XdGPJR+Os=", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz" + } + }, + "nixpkgs-lib_2": { + "locked": { + "lastModified": 1719876945, + "narHash": "sha256-Fm2rDDs86sHy0/1jxTOKB1118Q0O3Uc7EC0iXvXKpbI=", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/5daf0514482af3f97abaefc78a6606365c9108e2.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/5daf0514482af3f97abaefc78a6606365c9108e2.tar.gz" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 0, + "narHash": "sha256-/uilDXvCIEs3C9l73JTACm4quuHUsIHcns1c+cHUJwA=", + "path": "/nix/store/riqkpszjqk02bi1wppfg8ip5xvh102qd-source", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "process-compose-flake": { + "locked": { + "lastModified": 1729388329, + "narHash": "sha256-ziJLH/BuBlcfSdFZH+FbITNzwkIZZwdVzuAYUKoSoWg=", + "owner": "Platonic-Systems", + "repo": "process-compose-flake", + "rev": "00f00687efba844f356aca8bfb44105f93756c26", + "type": "github" + }, + "original": { + "owner": "Platonic-Systems", + "repo": "process-compose-flake", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "lexical": "lexical", + "nixpkgs": "nixpkgs_2", + "process-compose-flake": "process-compose-flake", + "systems": "systems_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e0817d7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,124 @@ +{ + inputs = { + # nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + flake-parts.url = "github:hercules-ci/flake-parts"; + # Lexical is an alternative LSP server. There are several options for Elixir + # LSP. See https://gist.github.com/Nezteb/dc63f1d5ad9d88907dd103da2ca000b1 + lexical.url = "github:lexical-lsp/lexical"; + # Use process-compose to manage background processes during development + process-compose-flake.url = "github:Platonic-Systems/process-compose-flake"; + }; + + outputs = { + self, + nixpkgs, + systems, + flake-parts, + ... + } @ inputs: let + # Set the Erlang version + erlangVersion = "erlang_27"; + # Set the Elixir version + elixirVersion = "elixir_1_17"; + in + flake-parts.lib.mkFlake {inherit inputs;} { + systems = import systems; + + imports = [inputs.process-compose-flake.flakeModule]; + + perSystem = { + # self', + config, + system, + pkgs, + lib, + ... + }: let + heroicons = pkgs.fetchFromGitHub { + owner = "tailwindlabs"; + repo = "heroicons"; + rev = "v2.1.1"; + hash = "sha256-y/kY8HPJmzB2e7ErgkUdQijU7oUhfS3fI093Rsvyvqs="; + sparseCheckout = ["optimized"]; + }; + in { + # Define a consistent package set for development, testing, and + # production. + _module.args.pkgs = import nixpkgs { + inherit system; + overlays = [ + ( + final: _: let + erlang = final.beam.interpreters.${erlangVersion}; + beamPackages = final.beam.packages.${erlangVersion}; + elixir = beamPackages.${elixirVersion}; + in { + inherit erlang elixir; + # Hex is not used in the devShell. + # inherit (beamPackages) hex; + } + ) + ]; + }; + + # You can build your Elixir application using mixRelease. + packages.default = pkgs.beamPackages.mixRelease { + pname = "zoeys-computer"; + version = "0.1.0"; + src = ./.; + removeCookie = false; + mixNixDeps = with pkgs; + import ./deps.nix { + inherit pkgs lib beamPackages; + }; + buildInputs = with pkgs; [nodejs]; + + MIX_ENV = "prod"; + }; + + # Add dependencies to develop your application using Mix. + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; ( + [ + erlang + elixir + # You are likely to need Node.js if you develop a Phoenix + # application. + nodejs + mix2nix + # Add the language server of your choice. + inputs.lexical.packages.${system}.default + # I once added Hex via a Nix development shell, but now I install + # and upgrade it using Mix. Hex installed using Nix can cause an + # issue if you manage Elixir dependencies using Mix. + ] + # Add a dependency for a file watcher if you develop a Phoenix + # application. + ++ lib.optional stdenv.isLinux inotify-tools + ++ (lib.optionals stdenv.isDarwin ( + with darwin.apple_sdk.frameworks; [ + CoreFoundation + CoreServices + ] + )) + ); + }; + + # You can define background processes in Nix using + # process-compose-flake. + process-compose.example = { + settings = { + processes = { + ponysay.command = '' + while true; do + ${lib.getExe pkgs.ponysay} "Enjoy our ponysay demo!" + sleep 2 + done + ''; + }; + }; + }; + }; + }; +} diff --git a/lib/zoeyscomputer/links.ex b/lib/zoeyscomputer/links.ex new file mode 100644 index 0000000..7181123 --- /dev/null +++ b/lib/zoeyscomputer/links.ex @@ -0,0 +1,107 @@ +defmodule Zoeyscomputer.Links do + @moduledoc """ + The Links context. + """ + + import Ecto.Query, warn: false + alias Zoeyscomputer.Repo + + alias Zoeyscomputer.Links.Link + + @doc """ + Returns the list of links. + + ## Examples + + iex> list_links() + [%Link{}, ...] + + """ + def list_links(user_id) do + Repo.all( + from l in Link, + where: l.user_id == ^user_id + ) + end + + @doc """ + Gets a single link. + + Raises `Ecto.NoResultsError` if the Link does not exist. + + ## Examples + + iex> get_link!(123) + %Link{} + + iex> get_link!(456) + ** (Ecto.NoResultsError) + + """ + def get_link!(id), do: Repo.get!(Link, id) + + @doc """ + Creates a link. + + ## Examples + + iex> create_link(%{field: value}) + {:ok, %Link{}} + + iex> create_link(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_link(attrs \\ %{}) do + %Link{} + |> Link.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a link. + + ## Examples + + iex> update_link(link, %{field: new_value}) + {:ok, %Link{}} + + iex> update_link(link, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_link(%Link{} = link, attrs) do + link + |> Link.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a link. + + ## Examples + + iex> delete_link(link) + {:ok, %Link{}} + + iex> delete_link(link) + {:error, %Ecto.Changeset{}} + + """ + def delete_link(%Link{} = link) do + Repo.delete(link) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking link changes. + + ## Examples + + iex> change_link(link) + %Ecto.Changeset{data: %Link{}} + + """ + def change_link(%Link{} = link, attrs \\ %{}) do + Link.changeset(link, attrs) + end +end diff --git a/lib/zoeyscomputer/links/link.ex b/lib/zoeyscomputer/links/link.ex new file mode 100644 index 0000000..0d7a19c --- /dev/null +++ b/lib/zoeyscomputer/links/link.ex @@ -0,0 +1,19 @@ +defmodule Zoeyscomputer.Links.Link do + use Ecto.Schema + import Ecto.Changeset + + schema "links" do + field :url, :string + + belongs_to :user, Zoeyscomputer.Users.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(link, attrs \\ %{}) do + link + |> cast(attrs, [:url, :user_id]) + |> validate_required([:url]) + end +end diff --git a/lib/zoeyscomputer/release.ex b/lib/zoeyscomputer/release.ex new file mode 100644 index 0000000..caf04c4 --- /dev/null +++ b/lib/zoeyscomputer/release.ex @@ -0,0 +1,28 @@ +defmodule Zoeyscomputer.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :zoeyscomputer + + 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/zoeyscomputer/users.ex b/lib/zoeyscomputer/users.ex new file mode 100644 index 0000000..7603689 --- /dev/null +++ b/lib/zoeyscomputer/users.ex @@ -0,0 +1,353 @@ +defmodule Zoeyscomputer.Users do + @moduledoc """ + The Users context. + """ + + import Ecto.Query, warn: false + alias Zoeyscomputer.Repo + + alias Zoeyscomputer.Users.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end +end diff --git a/lib/zoeyscomputer/users/user.ex b/lib/zoeyscomputer/users/user.ex new file mode 100644 index 0000000..f366331 --- /dev/null +++ b/lib/zoeyscomputer/users/user.ex @@ -0,0 +1,161 @@ +defmodule Zoeyscomputer.Users.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> validate_email(opts) + |> validate_password(opts) + end + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, Zoeyscomputer.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Zoeyscomputer.Users.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/zoeyscomputer/users/user_notifier.ex b/lib/zoeyscomputer/users/user_notifier.ex new file mode 100644 index 0000000..d355c8c --- /dev/null +++ b/lib/zoeyscomputer/users/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Zoeyscomputer.Users.UserNotifier do + import Swoosh.Email + + alias Zoeyscomputer.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Zoeyscomputer", "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/zoeyscomputer/users/user_token.ex b/lib/zoeyscomputer/users/user_token.ex new file mode 100644 index 0000000..a7c47da --- /dev/null +++ b/lib/zoeyscomputer/users/user_token.ex @@ -0,0 +1,179 @@ +defmodule Zoeyscomputer.Users.UserToken do + use Ecto.Schema + import Ecto.Query + alias Zoeyscomputer.Users.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, Zoeyscomputer.Users.User + + timestamps(type: :utc_datetime, 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 by_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 by_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 by_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 by_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 by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_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/zoeyscomputer_web/components/layouts/root.html.heex b/lib/zoeyscomputer_web/components/layouts/root.html.heex index 928d490..785adac 100644 --- a/lib/zoeyscomputer_web/components/layouts/root.html.heex +++ b/lib/zoeyscomputer_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@
+