Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +223 −0
| @@ -155,6 +155,17 @@ dependencies = [ | |||
| 155 | 155 | ] | |
| 156 | 156 | ||
| 157 | 157 | [[package]] | |
| 158 | + | name = "async-trait" | |
| 159 | + | version = "0.1.91" | |
| 160 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 161 | + | checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" | |
| 162 | + | dependencies = [ | |
| 163 | + | "proc-macro2", | |
| 164 | + | "quote", | |
| 165 | + | "syn 3.0.3", | |
| 166 | + | ] | |
| 167 | + | ||
| 168 | + | [[package]] | |
| 158 | 169 | name = "atoi" | |
| 159 | 170 | version = "2.0.0" | |
| 160 | 171 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -467,6 +478,7 @@ dependencies = [ | |||
| 467 | 478 | "clap", | |
| 468 | 479 | "config", | |
| 469 | 480 | "git", | |
| 481 | + | "mail", | |
| 470 | 482 | "model", | |
| 471 | 483 | "rpassword", | |
| 472 | 484 | "serde", | |
| @@ -546,6 +558,22 @@ dependencies = [ | |||
| 546 | 558 | ] | |
| 547 | 559 | ||
| 548 | 560 | [[package]] | |
| 561 | + | name = "core-foundation" | |
| 562 | + | version = "0.10.1" | |
| 563 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 564 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 565 | + | dependencies = [ | |
| 566 | + | "core-foundation-sys", | |
| 567 | + | "libc", | |
| 568 | + | ] | |
| 569 | + | ||
| 570 | + | [[package]] | |
| 571 | + | name = "core-foundation-sys" | |
| 572 | + | version = "0.8.7" | |
| 573 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 574 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 575 | + | ||
| 576 | + | [[package]] | |
| 549 | 577 | name = "cpufeatures" | |
| 550 | 578 | version = "0.2.17" | |
| 551 | 579 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -972,6 +1000,22 @@ dependencies = [ | |||
| 972 | 1000 | ] | |
| 973 | 1001 | ||
| 974 | 1002 | [[package]] | |
| 1003 | + | name = "email-encoding" | |
| 1004 | + | version = "0.4.1" | |
| 1005 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1006 | + | checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" | |
| 1007 | + | dependencies = [ | |
| 1008 | + | "base64", | |
| 1009 | + | "memchr", | |
| 1010 | + | ] | |
| 1011 | + | ||
| 1012 | + | [[package]] | |
| 1013 | + | name = "email_address" | |
| 1014 | + | version = "0.2.9" | |
| 1015 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1016 | + | checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" | |
| 1017 | + | ||
| 1018 | + | [[package]] | |
| 975 | 1019 | name = "encode_unicode" | |
| 976 | 1020 | version = "1.0.0" | |
| 977 | 1021 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1100,6 +1144,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1100 | 1144 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 1101 | 1145 | ||
| 1102 | 1146 | [[package]] | |
| 1147 | + | name = "foreign-types" | |
| 1148 | + | version = "0.3.2" | |
| 1149 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1150 | + | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | |
| 1151 | + | dependencies = [ | |
| 1152 | + | "foreign-types-shared", | |
| 1153 | + | ] | |
| 1154 | + | ||
| 1155 | + | [[package]] | |
| 1156 | + | name = "foreign-types-shared" | |
| 1157 | + | version = "0.1.1" | |
| 1158 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1159 | + | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | |
| 1160 | + | ||
| 1161 | + | [[package]] | |
| 1103 | 1162 | name = "form_urlencoded" | |
| 1104 | 1163 | version = "1.2.2" | |
| 1105 | 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1377,6 +1436,23 @@ dependencies = [ | |||
| 1377 | 1436 | ] | |
| 1378 | 1437 | ||
| 1379 | 1438 | [[package]] | |
| 1439 | + | name = "hostname" | |
| 1440 | + | version = "0.4.2" | |
| 1441 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1442 | + | checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" | |
| 1443 | + | dependencies = [ | |
| 1444 | + | "cfg-if", | |
| 1445 | + | "libc", | |
| 1446 | + | "windows-link", | |
| 1447 | + | ] | |
| 1448 | + | ||
| 1449 | + | [[package]] | |
| 1450 | + | name = "httpdate" | |
| 1451 | + | version = "1.0.3" | |
| 1452 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1453 | + | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | |
| 1454 | + | ||
| 1455 | + | [[package]] | |
| 1380 | 1456 | name = "hybrid-array" | |
| 1381 | 1457 | version = "0.4.13" | |
| 1382 | 1458 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1622,6 +1698,33 @@ dependencies = [ | |||
| 1622 | 1698 | ] | |
| 1623 | 1699 | ||
| 1624 | 1700 | [[package]] | |
| 1701 | + | name = "lettre" | |
| 1702 | + | version = "0.11.22" | |
| 1703 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1704 | + | checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" | |
| 1705 | + | dependencies = [ | |
| 1706 | + | "async-trait", | |
| 1707 | + | "base64", | |
| 1708 | + | "email-encoding", | |
| 1709 | + | "email_address", | |
| 1710 | + | "fastrand", | |
| 1711 | + | "futures-io", | |
| 1712 | + | "futures-util", | |
| 1713 | + | "hostname", | |
| 1714 | + | "httpdate", | |
| 1715 | + | "idna", | |
| 1716 | + | "mime", | |
| 1717 | + | "native-tls", | |
| 1718 | + | "nom", | |
| 1719 | + | "percent-encoding", | |
| 1720 | + | "quoted_printable", | |
| 1721 | + | "socket2", | |
| 1722 | + | "tokio", | |
| 1723 | + | "tokio-native-tls", | |
| 1724 | + | "url", | |
| 1725 | + | ] | |
| 1726 | + | ||
| 1727 | + | [[package]] | |
| 1625 | 1728 | name = "libbz2-rs-sys" | |
| 1626 | 1729 | version = "0.2.5" | |
| 1627 | 1730 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1704,6 +1807,12 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |||
| 1704 | 1807 | [[package]] | |
| 1705 | 1808 | name = "mail" | |
| 1706 | 1809 | version = "0.1.0" | |
| 1810 | + | dependencies = [ | |
| 1811 | + | "config", | |
| 1812 | + | "lettre", | |
| 1813 | + | "thiserror 2.0.19", | |
| 1814 | + | "tokio", | |
| 1815 | + | ] | |
| 1707 | 1816 | ||
| 1708 | 1817 | [[package]] | |
| 1709 | 1818 | name = "md-5" | |
| @@ -1732,6 +1841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1732 | 1841 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 1733 | 1842 | ||
| 1734 | 1843 | [[package]] | |
| 1844 | + | name = "mime" | |
| 1845 | + | version = "0.3.17" | |
| 1846 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1847 | + | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | |
| 1848 | + | ||
| 1849 | + | [[package]] | |
| 1735 | 1850 | name = "miniz_oxide" | |
| 1736 | 1851 | version = "0.8.9" | |
| 1737 | 1852 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1777,6 +1892,23 @@ dependencies = [ | |||
| 1777 | 1892 | ] | |
| 1778 | 1893 | ||
| 1779 | 1894 | [[package]] | |
| 1895 | + | name = "native-tls" | |
| 1896 | + | version = "0.2.18" | |
| 1897 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1898 | + | checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" | |
| 1899 | + | dependencies = [ | |
| 1900 | + | "libc", | |
| 1901 | + | "log", | |
| 1902 | + | "openssl", | |
| 1903 | + | "openssl-probe", | |
| 1904 | + | "openssl-sys", | |
| 1905 | + | "schannel", | |
| 1906 | + | "security-framework", | |
| 1907 | + | "security-framework-sys", | |
| 1908 | + | "tempfile", | |
| 1909 | + | ] | |
| 1910 | + | ||
| 1911 | + | [[package]] | |
| 1780 | 1912 | name = "nom" | |
| 1781 | 1913 | version = "8.0.0" | |
| 1782 | 1914 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1883,6 +2015,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1883 | 2015 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | |
| 1884 | 2016 | ||
| 1885 | 2017 | [[package]] | |
| 2018 | + | name = "openssl" | |
| 2019 | + | version = "0.10.81" | |
| 2020 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2021 | + | checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" | |
| 2022 | + | dependencies = [ | |
| 2023 | + | "bitflags", | |
| 2024 | + | "cfg-if", | |
| 2025 | + | "foreign-types", | |
| 2026 | + | "libc", | |
| 2027 | + | "openssl-macros", | |
| 2028 | + | "openssl-sys", | |
| 2029 | + | ] | |
| 2030 | + | ||
| 2031 | + | [[package]] | |
| 2032 | + | name = "openssl-macros" | |
| 2033 | + | version = "0.1.1" | |
| 2034 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2035 | + | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | |
| 2036 | + | dependencies = [ | |
| 2037 | + | "proc-macro2", | |
| 2038 | + | "quote", | |
| 2039 | + | "syn 2.0.119", | |
| 2040 | + | ] | |
| 2041 | + | ||
| 2042 | + | [[package]] | |
| 2043 | + | name = "openssl-probe" | |
| 2044 | + | version = "0.2.1" | |
| 2045 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2046 | + | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | |
| 2047 | + | ||
| 2048 | + | [[package]] | |
| 2049 | + | name = "openssl-sys" | |
| 2050 | + | version = "0.9.117" | |
| 2051 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2052 | + | checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" | |
| 2053 | + | dependencies = [ | |
| 2054 | + | "cc", | |
| 2055 | + | "libc", | |
| 2056 | + | "pkg-config", | |
| 2057 | + | "vcpkg", | |
| 2058 | + | ] | |
| 2059 | + | ||
| 2060 | + | [[package]] | |
| 1886 | 2061 | name = "p256" | |
| 1887 | 2062 | version = "0.13.2" | |
| 1888 | 2063 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2178,6 +2353,12 @@ dependencies = [ | |||
| 2178 | 2353 | ] | |
| 2179 | 2354 | ||
| 2180 | 2355 | [[package]] | |
| 2356 | + | name = "quoted_printable" | |
| 2357 | + | version = "0.5.2" | |
| 2358 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2359 | + | checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" | |
| 2360 | + | ||
| 2361 | + | [[package]] | |
| 2181 | 2362 | name = "r-efi" | |
| 2182 | 2363 | version = "5.3.0" | |
| 2183 | 2364 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2405,6 +2586,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2405 | 2586 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 2406 | 2587 | ||
| 2407 | 2588 | [[package]] | |
| 2589 | + | name = "schannel" | |
| 2590 | + | version = "0.1.29" | |
| 2591 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2592 | + | checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" | |
| 2593 | + | dependencies = [ | |
| 2594 | + | "windows-sys 0.61.2", | |
| 2595 | + | ] | |
| 2596 | + | ||
| 2597 | + | [[package]] | |
| 2408 | 2598 | name = "scopeguard" | |
| 2409 | 2599 | version = "1.2.0" | |
| 2410 | 2600 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2426,6 +2616,29 @@ dependencies = [ | |||
| 2426 | 2616 | ] | |
| 2427 | 2617 | ||
| 2428 | 2618 | [[package]] | |
| 2619 | + | name = "security-framework" | |
| 2620 | + | version = "3.7.0" | |
| 2621 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2622 | + | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" | |
| 2623 | + | dependencies = [ | |
| 2624 | + | "bitflags", | |
| 2625 | + | "core-foundation", | |
| 2626 | + | "core-foundation-sys", | |
| 2627 | + | "libc", | |
| 2628 | + | "security-framework-sys", | |
| 2629 | + | ] | |
| 2630 | + | ||
| 2631 | + | [[package]] | |
| 2632 | + | name = "security-framework-sys" | |
| 2633 | + | version = "2.17.0" | |
| 2634 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2635 | + | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" | |
| 2636 | + | dependencies = [ | |
| 2637 | + | "core-foundation-sys", | |
| 2638 | + | "libc", | |
| 2639 | + | ] | |
| 2640 | + | ||
| 2641 | + | [[package]] | |
| 2429 | 2642 | name = "semver" | |
| 2430 | 2643 | version = "1.0.28" | |
| 2431 | 2644 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3069,6 +3282,16 @@ dependencies = [ | |||
| 3069 | 3282 | ] | |
| 3070 | 3283 | ||
| 3071 | 3284 | [[package]] | |
| 3285 | + | name = "tokio-native-tls" | |
| 3286 | + | version = "0.3.1" | |
| 3287 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3288 | + | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" | |
| 3289 | + | dependencies = [ | |
| 3290 | + | "native-tls", | |
| 3291 | + | "tokio", | |
| 3292 | + | ] | |
| 3293 | + | ||
| 3294 | + | [[package]] | |
| 3072 | 3295 | name = "tokio-stream" | |
| 3073 | 3296 | version = "0.1.19" | |
| 3074 | 3297 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +10 −0
| @@ -101,6 +101,16 @@ insta = "1" | |||
| 101 | 101 | # is LGPL, which our license gate forbids (see docs/decisions.md). | |
| 102 | 102 | ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] } | |
| 103 | 103 | pgp = "0.20" | |
| 104 | + | # SMTP mail. native-tls (system OpenSSL, already a build input) rather than the | |
| 105 | + | # spec's rustls transport, which pulls `ring` and would fail the license gate — | |
| 106 | + | # see docs/decisions.md. | |
| 107 | + | lettre = { version = "0.11", default-features = false, features = [ | |
| 108 | + | "builder", | |
| 109 | + | "smtp-transport", | |
| 110 | + | "tokio1", | |
| 111 | + | "tokio1-native-tls", | |
| 112 | + | "hostname", | |
| 113 | + | ] } | |
| 104 | 114 | ||
| 105 | 115 | [workspace.lints.rust] | |
| 106 | 116 | unsafe_code = "forbid" | |
crates/cli/Cargo.toml +1 −0
| @@ -14,6 +14,7 @@ config = { workspace = true } | |||
| 14 | 14 | store = { workspace = true } | |
| 15 | 15 | auth = { workspace = true } | |
| 16 | 16 | git = { workspace = true } | |
| 17 | + | mail = { workspace = true } | |
| 17 | 18 | model = { workspace = true } | |
| 18 | 19 | anyhow = { workspace = true } | |
| 19 | 20 | clap = { workspace = true } | |
crates/cli/src/user_cmd.rs +56 −9
| @@ -10,7 +10,7 @@ use std::process::ExitCode; | |||
| 10 | 10 | use clap::Subcommand; | |
| 11 | 11 | use config::Config; | |
| 12 | 12 | use model::User; | |
| 13 | - | use store::{NewUser, Store}; | |
| 13 | + | use store::{NewInvite, NewUser, Store}; | |
| 14 | 14 | ||
| 15 | 15 | use crate::context::{block_on, open_store}; | |
| 16 | 16 | use crate::prompt::{confirm, resolve_password}; | |
| @@ -160,18 +160,65 @@ async fn add( | |||
| 160 | 160 | if user.is_admin { " (admin)" } else { "" } | |
| 161 | 161 | ); | |
| 162 | 162 | if !has_password { | |
| 163 | - | // Email invitations are not implemented yet (phase 9), so there is no | |
| 164 | - | // out-of-band way to activate the account — point the operator at the | |
| 165 | - | // direct path. This is the SMTP-disabled fallback made explicit. | |
| 166 | - | eprintln!( | |
| 167 | - | "note: no password set; this account cannot log in yet. \ | |
| 168 | - | Set one with: fabrica user passwd {}", | |
| 169 | - | user.username | |
| 170 | - | ); | |
| 163 | + | // No password was set, so mint an activation invite, try to email it, and | |
| 164 | + | // always print the link so an operator can deliver it when SMTP is off. | |
| 165 | + | send_activation(&config, &store, &user).await?; | |
| 171 | 166 | } | |
| 172 | 167 | Ok(ExitCode::SUCCESS) | |
| 173 | 168 | } | |
| 174 | 169 | ||
| 170 | + | /// Mint an activation invite for `user`, attempt to email it (best-effort), and | |
| 171 | + | /// print the activation URL to stdout regardless. | |
| 172 | + | async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> { | |
| 173 | + | // The emailed token is opaque 32-byte randomness; only its SHA-256 is stored. | |
| 174 | + | let token = auth::new_session_token(); | |
| 175 | + | let ttl_ms = i64::from(config.auth.invite_ttl_hours).saturating_mul(3_600_000); | |
| 176 | + | store | |
| 177 | + | .create_invite(NewInvite { | |
| 178 | + | user_id: user.id.clone(), | |
| 179 | + | token_hash: token.id, | |
| 180 | + | purpose: mail::Purpose::Activate.as_str().to_string(), | |
| 181 | + | expires_at: now_ms().saturating_add(ttl_ms), | |
| 182 | + | }) | |
| 183 | + | .await?; | |
| 184 | + | ||
| 185 | + | let link = format!( | |
| 186 | + | "{}/invite/{}", | |
| 187 | + | config.instance.url.trim_end_matches('/'), | |
| 188 | + | token.cookie | |
| 189 | + | ); | |
| 190 | + | ||
| 191 | + | match mail::Mailer::build(&config.mail) { | |
| 192 | + | Ok(mailer) => match mailer | |
| 193 | + | .send_invite( | |
| 194 | + | &config.instance.name, | |
| 195 | + | &user.email, | |
| 196 | + | user.display_name.as_deref(), | |
| 197 | + | &link, | |
| 198 | + | mail::Purpose::Activate, | |
| 199 | + | ) | |
| 200 | + | .await | |
| 201 | + | { | |
| 202 | + | Ok(()) => println!("sent activation email to {}", user.email), | |
| 203 | + | Err(err) => eprintln!("warning: could not send activation email: {err}"), | |
| 204 | + | }, | |
| 205 | + | Err(err) => eprintln!("warning: mail not configured: {err}"), | |
| 206 | + | } | |
| 207 | + | println!( | |
| 208 | + | "activation link (valid {}h): {link}", | |
| 209 | + | config.auth.invite_ttl_hours | |
| 210 | + | ); | |
| 211 | + | Ok(()) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// The current time in Unix milliseconds. | |
| 215 | + | fn now_ms() -> i64 { | |
| 216 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 217 | + | SystemTime::now() | |
| 218 | + | .duration_since(UNIX_EPOCH) | |
| 219 | + | .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) | |
| 220 | + | } | |
| 221 | + | ||
| 175 | 222 | async fn passwd( | |
| 176 | 223 | config_path: Option<&Path>, | |
| 177 | 224 | name: &str, | |
crates/config/src/types.rs +3 −0
| @@ -181,6 +181,8 @@ pub struct Auth { | |||
| 181 | 181 | pub session_ttl_days: u32, | |
| 182 | 182 | /// API token lifetime, in days. | |
| 183 | 183 | pub token_ttl_days: u32, | |
| 184 | + | /// Activation/reset invite lifetime, in hours. | |
| 185 | + | pub invite_ttl_hours: u32, | |
| 184 | 186 | /// Name of the session cookie. | |
| 185 | 187 | pub cookie_name: String, | |
| 186 | 188 | /// Set the `Secure` attribute on the session cookie. | |
| @@ -200,6 +202,7 @@ impl Default for Auth { | |||
| 200 | 202 | secret_file: None, | |
| 201 | 203 | session_ttl_days: 30, | |
| 202 | 204 | token_ttl_days: 90, | |
| 205 | + | invite_ttl_hours: 72, | |
| 203 | 206 | cookie_name: "fabrica_session".to_string(), | |
| 204 | 207 | cookie_secure: true, | |
| 205 | 208 | argon2_m_cost: 19456, | |
crates/mail/Cargo.toml +6 −0
| @@ -10,6 +10,12 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | config = { workspace = true } | |
| 14 | + | lettre = { workspace = true } | |
| 15 | + | thiserror = { workspace = true } | |
| 16 | + | ||
| 17 | + | [dev-dependencies] | |
| 18 | + | tokio = { workspace = true } | |
| 13 | 19 | ||
| 14 | 20 | [lints] | |
| 15 | 21 | workspace = true | |
crates/mail/src/lib.rs +223 −1
| @@ -2,4 +2,226 @@ | |||
| 2 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | - | //! Outbound mail: templates rendered to text and HTML, sent best-effort. | |
| 5 | + | //! Outbound mail: SMTP delivery plus the activation/reset templates. | |
| 6 | + | //! | |
| 7 | + | //! Three backends (`config::MailBackend`): `smtp` delivers over SMTP with | |
| 8 | + | //! native-TLS (`starttls`/`tls`/plaintext), `stdout` prints the rendered message | |
| 9 | + | //! (development), and `none` rejects any flow that needs mail. Sending is | |
| 10 | + | //! best-effort — the caller always has the activation URL to deliver by hand — so | |
| 11 | + | //! `send_invite` returns a `Result` the caller logs rather than fails on. | |
| 12 | + | ||
| 13 | + | mod templates; | |
| 14 | + | ||
| 15 | + | use std::time::Duration; | |
| 16 | + | ||
| 17 | + | use config::{Mail, MailBackend, MailEncryption}; | |
| 18 | + | use lettre::message::{Mailbox, MultiPart}; | |
| 19 | + | use lettre::transport::smtp::authentication::Credentials; | |
| 20 | + | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; | |
| 21 | + | ||
| 22 | + | pub use crate::templates::{Purpose, Rendered, render}; | |
| 23 | + | ||
| 24 | + | /// An error from the mail layer. | |
| 25 | + | #[derive(Debug, thiserror::Error)] | |
| 26 | + | pub enum MailError { | |
| 27 | + | /// The `none` backend was asked to send. | |
| 28 | + | #[error("mail is disabled (mail.backend = \"none\")")] | |
| 29 | + | Disabled, | |
| 30 | + | /// An address failed to parse. | |
| 31 | + | #[error("invalid email address: {0}")] | |
| 32 | + | Address(String), | |
| 33 | + | /// Building the SMTP transport failed. | |
| 34 | + | #[error("smtp transport setup failed: {0}")] | |
| 35 | + | Transport(String), | |
| 36 | + | /// Building the message failed. | |
| 37 | + | #[error("message construction failed: {0}")] | |
| 38 | + | Message(String), | |
| 39 | + | /// Delivery failed. | |
| 40 | + | #[error("mail delivery failed: {0}")] | |
| 41 | + | Send(String), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// A configured mailer. Cheap to build once and reuse. | |
| 45 | + | pub struct Mailer { | |
| 46 | + | from: Mailbox, | |
| 47 | + | backend: Delivery, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// The delivery mechanism behind a [`Mailer`]. | |
| 51 | + | enum Delivery { | |
| 52 | + | /// Deliver over SMTP. | |
| 53 | + | Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>), | |
| 54 | + | /// Print the rendered message to stdout. | |
| 55 | + | Stdout, | |
| 56 | + | /// Reject sends. | |
| 57 | + | None, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl Mailer { | |
| 61 | + | /// Build a mailer from configuration. | |
| 62 | + | /// | |
| 63 | + | /// # Errors | |
| 64 | + | /// | |
| 65 | + | /// Returns [`MailError::Address`] if `mail.from` is not a valid address, or | |
| 66 | + | /// [`MailError::Transport`] if the SMTP transport cannot be constructed. | |
| 67 | + | pub fn build(config: &Mail) -> Result<Self, MailError> { | |
| 68 | + | let from: Mailbox = config | |
| 69 | + | .from | |
| 70 | + | .parse() | |
| 71 | + | .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?; | |
| 72 | + | ||
| 73 | + | let backend = match config.backend { | |
| 74 | + | MailBackend::Stdout => Delivery::Stdout, | |
| 75 | + | MailBackend::None => Delivery::None, | |
| 76 | + | MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)), | |
| 77 | + | }; | |
| 78 | + | Ok(Self { from, backend }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Send an invite email to `to_email` (optionally named `to_name`) with the | |
| 82 | + | /// activation `link`. | |
| 83 | + | /// | |
| 84 | + | /// # Errors | |
| 85 | + | /// | |
| 86 | + | /// Returns [`MailError`] on address/message/transport failure, or | |
| 87 | + | /// [`MailError::Disabled`] under the `none` backend. Callers treat this as | |
| 88 | + | /// best-effort and still surface the link out of band. | |
| 89 | + | pub async fn send_invite( | |
| 90 | + | &self, | |
| 91 | + | instance_name: &str, | |
| 92 | + | to_email: &str, | |
| 93 | + | to_name: Option<&str>, | |
| 94 | + | link: &str, | |
| 95 | + | purpose: Purpose, | |
| 96 | + | ) -> Result<(), MailError> { | |
| 97 | + | let rendered = render(instance_name, link, purpose); | |
| 98 | + | let to = mailbox(to_email, to_name)?; | |
| 99 | + | ||
| 100 | + | match &self.backend { | |
| 101 | + | Delivery::None => Err(MailError::Disabled), | |
| 102 | + | Delivery::Stdout => { | |
| 103 | + | println!("--- mail ({}) ---", purpose.as_str()); | |
| 104 | + | println!("From: {}", self.from); | |
| 105 | + | println!("To: {to}"); | |
| 106 | + | println!("Subject: {}", rendered.subject); | |
| 107 | + | println!(); | |
| 108 | + | println!("{}", rendered.plain); | |
| 109 | + | println!("--- end mail ---"); | |
| 110 | + | Ok(()) | |
| 111 | + | } | |
| 112 | + | Delivery::Smtp(transport) => { | |
| 113 | + | let email = Message::builder() | |
| 114 | + | .from(self.from.clone()) | |
| 115 | + | .to(to) | |
| 116 | + | .subject(rendered.subject) | |
| 117 | + | .multipart(MultiPart::alternative_plain_html( | |
| 118 | + | rendered.plain, | |
| 119 | + | rendered.html, | |
| 120 | + | )) | |
| 121 | + | .map_err(|e| MailError::Message(e.to_string()))?; | |
| 122 | + | transport | |
| 123 | + | .send(email) | |
| 124 | + | .await | |
| 125 | + | .map_err(|e| MailError::Send(e.to_string()))?; | |
| 126 | + | Ok(()) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Build a `to` mailbox from an address and optional display name. | |
| 133 | + | fn mailbox(email: &str, name: Option<&str>) -> Result<Mailbox, MailError> { | |
| 134 | + | let address = email | |
| 135 | + | .parse() | |
| 136 | + | .map_err(|e| MailError::Address(format!("{email}: {e}")))?; | |
| 137 | + | Ok(Mailbox::new(name.map(str::to_string), address)) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Construct the async SMTP transport for the `smtp` backend. | |
| 141 | + | fn build_transport(config: &Mail) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> { | |
| 142 | + | let builder = match config.encryption { | |
| 143 | + | MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host) | |
| 144 | + | .map_err(|e| MailError::Transport(e.to_string()))?, | |
| 145 | + | MailEncryption::Starttls => { | |
| 146 | + | AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host) | |
| 147 | + | .map_err(|e| MailError::Transport(e.to_string()))? | |
| 148 | + | } | |
| 149 | + | MailEncryption::None => { | |
| 150 | + | AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host) | |
| 151 | + | } | |
| 152 | + | }; | |
| 153 | + | ||
| 154 | + | let mut builder = builder | |
| 155 | + | .port(config.port) | |
| 156 | + | .timeout(Some(Duration::from_secs(config.timeout_secs))); | |
| 157 | + | if !config.username.is_empty() { | |
| 158 | + | let password = config | |
| 159 | + | .password | |
| 160 | + | .as_ref() | |
| 161 | + | .map(|s| s.expose().to_string()) | |
| 162 | + | .unwrap_or_default(); | |
| 163 | + | builder = builder.credentials(Credentials::new(config.username.clone(), password)); | |
| 164 | + | } | |
| 165 | + | Ok(builder.build()) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[cfg(test)] | |
| 169 | + | mod tests { | |
| 170 | + | #![allow(clippy::unwrap_used)] | |
| 171 | + | ||
| 172 | + | use config::Mail; | |
| 173 | + | ||
| 174 | + | use super::*; | |
| 175 | + | ||
| 176 | + | fn stdout_config() -> Mail { | |
| 177 | + | Mail { | |
| 178 | + | backend: MailBackend::Stdout, | |
| 179 | + | from: "Fabrica <fabrica@example.com>".to_string(), | |
| 180 | + | ..Mail::default() | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[tokio::test] | |
| 185 | + | async fn stdout_backend_sends_without_error() { | |
| 186 | + | let mailer = Mailer::build(&stdout_config()).unwrap(); | |
| 187 | + | mailer | |
| 188 | + | .send_invite( | |
| 189 | + | "Forge", | |
| 190 | + | "ada@example.com", | |
| 191 | + | Some("Ada"), | |
| 192 | + | "https://git.example.com/invite/tok", | |
| 193 | + | Purpose::Activate, | |
| 194 | + | ) | |
| 195 | + | .await | |
| 196 | + | .unwrap(); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[tokio::test] | |
| 200 | + | async fn none_backend_rejects() { | |
| 201 | + | let config = Mail { | |
| 202 | + | backend: MailBackend::None, | |
| 203 | + | ..stdout_config() | |
| 204 | + | }; | |
| 205 | + | let mailer = Mailer::build(&config).unwrap(); | |
| 206 | + | let err = mailer | |
| 207 | + | .send_invite( | |
| 208 | + | "Forge", | |
| 209 | + | "a@b.com", | |
| 210 | + | None, | |
| 211 | + | "https://x/invite/t", | |
| 212 | + | Purpose::Activate, | |
| 213 | + | ) | |
| 214 | + | .await | |
| 215 | + | .unwrap_err(); | |
| 216 | + | assert!(matches!(err, MailError::Disabled)); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn invalid_from_is_rejected() { | |
| 221 | + | let config = Mail { | |
| 222 | + | from: "not an address".to_string(), | |
| 223 | + | ..stdout_config() | |
| 224 | + | }; | |
| 225 | + | assert!(matches!(Mailer::build(&config), Err(MailError::Address(_)))); | |
| 226 | + | } | |
| 227 | + | } | |
crates/mail/src/templates.rs +117 −0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Message templates. A plain-text primary and a minimal HTML alternative are | |
| 6 | + | //! rendered from the same data, with the instance name and URL interpolated. | |
| 7 | + | ||
| 8 | + | /// Why an invite was sent. | |
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 10 | + | pub enum Purpose { | |
| 11 | + | /// A new-account activation link. | |
| 12 | + | Activate, | |
| 13 | + | /// A password-reset link. | |
| 14 | + | Reset, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl Purpose { | |
| 18 | + | /// The `invites.purpose` token. | |
| 19 | + | #[must_use] | |
| 20 | + | pub fn as_str(self) -> &'static str { | |
| 21 | + | match self { | |
| 22 | + | Self::Activate => "activate", | |
| 23 | + | Self::Reset => "reset", | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// A rendered message: subject plus the two body alternatives. | |
| 29 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 30 | + | pub struct Rendered { | |
| 31 | + | /// The `Subject:` header. | |
| 32 | + | pub subject: String, | |
| 33 | + | /// The plain-text body (primary). | |
| 34 | + | pub plain: String, | |
| 35 | + | /// The HTML body (alternative). | |
| 36 | + | pub html: String, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Render the invite message for `purpose`, interpolating the instance name/URL | |
| 40 | + | /// and the activation `link`. | |
| 41 | + | #[must_use] | |
| 42 | + | pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered { | |
| 43 | + | let (subject, lead, action) = match purpose { | |
| 44 | + | Purpose::Activate => ( | |
| 45 | + | format!("Activate your {instance_name} account"), | |
| 46 | + | format!("An account has been created for you on {instance_name}."), | |
| 47 | + | "set your password and activate your account", | |
| 48 | + | ), | |
| 49 | + | Purpose::Reset => ( | |
| 50 | + | format!("Reset your {instance_name} password"), | |
| 51 | + | format!("A password reset was requested for your {instance_name} account."), | |
| 52 | + | "choose a new password", | |
| 53 | + | ), | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | let plain = format!( | |
| 57 | + | "{lead}\n\nOpen this link to {action}:\n\n {link}\n\n\ | |
| 58 | + | If you did not expect this email, you can safely ignore it.\n" | |
| 59 | + | ); | |
| 60 | + | let html = format!( | |
| 61 | + | "<p>{}</p>\n<p>Open this link to {}:</p>\n<p><a href=\"{}\">{}</a></p>\n\ | |
| 62 | + | <p>If you did not expect this email, you can safely ignore it.</p>\n", | |
| 63 | + | escape(&lead), | |
| 64 | + | escape(action), | |
| 65 | + | escape(link), | |
| 66 | + | escape(link), | |
| 67 | + | ); | |
| 68 | + | ||
| 69 | + | Rendered { | |
| 70 | + | subject, | |
| 71 | + | plain, | |
| 72 | + | html, | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Minimal HTML escaping for the interpolated values. | |
| 77 | + | fn escape(s: &str) -> String { | |
| 78 | + | s.replace('&', "&") | |
| 79 | + | .replace('<', "<") | |
| 80 | + | .replace('>', ">") | |
| 81 | + | .replace('"', """) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[cfg(test)] | |
| 85 | + | mod tests { | |
| 86 | + | use super::*; | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn activate_body_contains_link_and_name() { | |
| 90 | + | let r = render( | |
| 91 | + | "Forge", | |
| 92 | + | "https://git.example.com/invite/abc123", | |
| 93 | + | Purpose::Activate, | |
| 94 | + | ); | |
| 95 | + | assert!(r.subject.contains("Forge")); | |
| 96 | + | assert!(r.plain.contains("https://git.example.com/invite/abc123")); | |
| 97 | + | assert!( | |
| 98 | + | r.html | |
| 99 | + | .contains("href=\"https://git.example.com/invite/abc123\"") | |
| 100 | + | ); | |
| 101 | + | assert!(r.plain.contains("activate your account")); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[test] | |
| 105 | + | fn reset_uses_reset_wording() { | |
| 106 | + | let r = render("Forge", "https://x/invite/t", Purpose::Reset); | |
| 107 | + | assert!(r.subject.to_lowercase().contains("reset")); | |
| 108 | + | assert!(r.plain.contains("new password")); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[test] | |
| 112 | + | fn html_escapes_interpolated_values() { | |
| 113 | + | let r = render("A&B", "https://x/?a=1&b=2", Purpose::Activate); | |
| 114 | + | assert!(r.html.contains("&")); | |
| 115 | + | assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped"); | |
| 116 | + | } | |
| 117 | + | } | |
crates/model/src/entity.rs +20 −0
| @@ -79,6 +79,26 @@ pub struct User { | |||
| 79 | 79 | pub updated_at: Timestamp, | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | /// A pending activation or password-reset invite. The emailed token is never | |
| 83 | + | /// stored; `token_hash` is its SHA-256, matched on redemption. | |
| 84 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 85 | + | pub struct Invite { | |
| 86 | + | /// ULID primary key. | |
| 87 | + | pub id: String, | |
| 88 | + | /// The user this invite activates. | |
| 89 | + | pub user_id: String, | |
| 90 | + | /// SHA-256 (hex) of the emailed token. | |
| 91 | + | pub token_hash: String, | |
| 92 | + | /// `"activate"` or `"reset"`. | |
| 93 | + | pub purpose: String, | |
| 94 | + | /// When the invite expires. | |
| 95 | + | pub expires_at: Timestamp, | |
| 96 | + | /// When it was redeemed, if it has been (single-use). | |
| 97 | + | pub used_at: Option<Timestamp>, | |
| 98 | + | /// Creation time. | |
| 99 | + | pub created_at: Timestamp, | |
| 100 | + | } | |
| 101 | + | ||
| 82 | 102 | /// A nested, user-owned group. Groups provide the repository hierarchy; `path` is | |
| 83 | 103 | /// the materialized `group/sub/leaf` under the owner. | |
| 84 | 104 | #[derive(Debug, Clone, PartialEq, Eq)] | |
crates/model/src/lib.rs +1 −1
| @@ -11,5 +11,5 @@ | |||
| 11 | 11 | pub mod entity; | |
| 12 | 12 | pub mod name; | |
| 13 | 13 | ||
| 14 | - | pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User}; | |
| 14 | + | pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User}; | |
| 15 | 15 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | |
crates/store/src/invites.rs +113 −0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Activation and password-reset invites. | |
| 6 | + | //! | |
| 7 | + | //! The emailed token is never stored; only its SHA-256 (`token_hash`) is, and | |
| 8 | + | //! redemption looks the invite up by that hash. Invites are single-use | |
| 9 | + | //! (`used_at`) and time-limited (`expires_at`). | |
| 10 | + | ||
| 11 | + | use model::Invite; | |
| 12 | + | use sqlx::any::AnyRow; | |
| 13 | + | use sqlx::{AssertSqlSafe, Row}; | |
| 14 | + | ||
| 15 | + | use crate::{Store, StoreError, new_id, now_ms}; | |
| 16 | + | ||
| 17 | + | /// The columns of `invites` in a fixed order, shared by every `SELECT`. | |
| 18 | + | const INVITE_COLUMNS: &str = "id, user_id, token_hash, purpose, expires_at, used_at, created_at"; | |
| 19 | + | ||
| 20 | + | /// Fields needed to mint an invite. The caller generates the token and passes its | |
| 21 | + | /// SHA-256 here; the store assigns the id and creation time. | |
| 22 | + | #[derive(Debug, Clone)] | |
| 23 | + | pub struct NewInvite { | |
| 24 | + | /// The user the invite activates. | |
| 25 | + | pub user_id: String, | |
| 26 | + | /// SHA-256 (hex) of the emailed token. | |
| 27 | + | pub token_hash: String, | |
| 28 | + | /// `"activate"` or `"reset"`. | |
| 29 | + | pub purpose: String, | |
| 30 | + | /// Absolute expiry, epoch ms. | |
| 31 | + | pub expires_at: i64, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Store { | |
| 35 | + | /// Create an invite row. | |
| 36 | + | /// | |
| 37 | + | /// # Errors | |
| 38 | + | /// | |
| 39 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 40 | + | pub async fn create_invite(&self, new: NewInvite) -> Result<Invite, StoreError> { | |
| 41 | + | let invite = Invite { | |
| 42 | + | id: new_id(), | |
| 43 | + | user_id: new.user_id, | |
| 44 | + | token_hash: new.token_hash, | |
| 45 | + | purpose: new.purpose, | |
| 46 | + | expires_at: new.expires_at, | |
| 47 | + | used_at: None, | |
| 48 | + | created_at: now_ms(), | |
| 49 | + | }; | |
| 50 | + | let sql = "INSERT INTO invites (id, user_id, token_hash, purpose, expires_at, used_at, created_at) \ | |
| 51 | + | VALUES ($1, $2, $3, $4, $5, $6, $7)"; | |
| 52 | + | sqlx::query(sql) | |
| 53 | + | .bind(invite.id.clone()) | |
| 54 | + | .bind(invite.user_id.clone()) | |
| 55 | + | .bind(invite.token_hash.clone()) | |
| 56 | + | .bind(invite.purpose.clone()) | |
| 57 | + | .bind(invite.expires_at) | |
| 58 | + | .bind(invite.used_at) | |
| 59 | + | .bind(invite.created_at) | |
| 60 | + | .execute(&self.pool) | |
| 61 | + | .await?; | |
| 62 | + | Ok(invite) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Look up an invite by its token hash, regardless of state. The caller checks | |
| 66 | + | /// `used_at`/`expires_at` so it can distinguish "already used" from "expired" | |
| 67 | + | /// from "unknown" for the redemption page. | |
| 68 | + | /// | |
| 69 | + | /// # Errors | |
| 70 | + | /// | |
| 71 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 72 | + | pub async fn invite_by_token_hash( | |
| 73 | + | &self, | |
| 74 | + | token_hash: &str, | |
| 75 | + | ) -> Result<Option<Invite>, StoreError> { | |
| 76 | + | let sql = format!("SELECT {INVITE_COLUMNS} FROM invites WHERE token_hash = $1"); | |
| 77 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 78 | + | .bind(token_hash) | |
| 79 | + | .fetch_optional(&self.pool) | |
| 80 | + | .await?; | |
| 81 | + | Ok(row.as_ref().map(map_invite).transpose()?) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Mark an invite redeemed. Guards against a double-redeem with a | |
| 85 | + | /// `used_at IS NULL` predicate, so `true` means "this call redeemed it". | |
| 86 | + | /// | |
| 87 | + | /// # Errors | |
| 88 | + | /// | |
| 89 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 90 | + | pub async fn mark_invite_used(&self, invite_id: &str) -> Result<bool, StoreError> { | |
| 91 | + | let updated = | |
| 92 | + | sqlx::query("UPDATE invites SET used_at = $1 WHERE id = $2 AND used_at IS NULL") | |
| 93 | + | .bind(now_ms()) | |
| 94 | + | .bind(invite_id) | |
| 95 | + | .execute(&self.pool) | |
| 96 | + | .await? | |
| 97 | + | .rows_affected(); | |
| 98 | + | Ok(updated > 0) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Map an `invites` row (selected via [`INVITE_COLUMNS`]) to an [`Invite`]. | |
| 103 | + | fn map_invite(row: &AnyRow) -> Result<Invite, sqlx::Error> { | |
| 104 | + | Ok(Invite { | |
| 105 | + | id: row.try_get("id")?, | |
| 106 | + | user_id: row.try_get("user_id")?, | |
| 107 | + | token_hash: row.try_get("token_hash")?, | |
| 108 | + | purpose: row.try_get("purpose")?, | |
| 109 | + | expires_at: row.try_get("expires_at")?, | |
| 110 | + | used_at: row.try_get("used_at")?, | |
| 111 | + | created_at: row.try_get("created_at")?, | |
| 112 | + | }) | |
| 113 | + | } | |
crates/store/src/lib.rs +2 −0
| @@ -26,6 +26,7 @@ | |||
| 26 | 26 | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. | |
| 27 | 27 | ||
| 28 | 28 | mod groups; | |
| 29 | + | mod invites; | |
| 29 | 30 | mod keys; | |
| 30 | 31 | mod repos; | |
| 31 | 32 | mod tokens; | |
| @@ -39,6 +40,7 @@ use sqlx::Row; | |||
| 39 | 40 | use sqlx::any::{AnyPoolOptions, AnyRow}; | |
| 40 | 41 | use sqlx::migrate::Migrator; | |
| 41 | 42 | ||
| 43 | + | pub use crate::invites::NewInvite; | |
| 42 | 44 | pub use crate::keys::NewKey; | |
| 43 | 45 | pub use crate::repos::NewRepo; | |
| 44 | 46 | pub use crate::tokens::NewToken; | |
crates/store/src/tests.rs +53 −0
| @@ -732,6 +732,59 @@ async fn rename_and_delete_repo() { | |||
| 732 | 732 | } | |
| 733 | 733 | } | |
| 734 | 734 | ||
| 735 | + | #[tokio::test] | |
| 736 | + | async fn invites_round_trip_and_are_single_use() { | |
| 737 | + | use super::NewInvite; | |
| 738 | + | ||
| 739 | + | for fx in sqlite_fixtures().await { | |
| 740 | + | let user = fx | |
| 741 | + | .store | |
| 742 | + | .create_user(sample_user("i", "i@example.com")) | |
| 743 | + | .await | |
| 744 | + | .unwrap(); | |
| 745 | + | ||
| 746 | + | let invite = fx | |
| 747 | + | .store | |
| 748 | + | .create_invite(NewInvite { | |
| 749 | + | user_id: user.id.clone(), | |
| 750 | + | token_hash: "hash-abc".to_string(), | |
| 751 | + | purpose: "activate".to_string(), | |
| 752 | + | expires_at: now_plus_a_day(), | |
| 753 | + | }) | |
| 754 | + | .await | |
| 755 | + | .unwrap(); | |
| 756 | + | assert!(invite.used_at.is_none()); | |
| 757 | + | ||
| 758 | + | let found = fx | |
| 759 | + | .store | |
| 760 | + | .invite_by_token_hash("hash-abc") | |
| 761 | + | .await | |
| 762 | + | .unwrap() | |
| 763 | + | .unwrap(); | |
| 764 | + | assert_eq!(found.user_id, user.id); | |
| 765 | + | assert_eq!(found.purpose, "activate"); | |
| 766 | + | ||
| 767 | + | // Redemption is single-use: the first mark succeeds, the second does not. | |
| 768 | + | assert!(fx.store.mark_invite_used(&invite.id).await.unwrap()); | |
| 769 | + | assert!(!fx.store.mark_invite_used(&invite.id).await.unwrap()); | |
| 770 | + | let redeemed = fx | |
| 771 | + | .store | |
| 772 | + | .invite_by_token_hash("hash-abc") | |
| 773 | + | .await | |
| 774 | + | .unwrap() | |
| 775 | + | .unwrap(); | |
| 776 | + | assert!(redeemed.used_at.is_some()); | |
| 777 | + | ||
| 778 | + | assert!( | |
| 779 | + | fx.store | |
| 780 | + | .invite_by_token_hash("nope") | |
| 781 | + | .await | |
| 782 | + | .unwrap() | |
| 783 | + | .is_none() | |
| 784 | + | ); | |
| 785 | + | } | |
| 786 | + | } | |
| 787 | + | ||
| 735 | 788 | /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the | |
| 736 | 789 | /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a | |
| 737 | 790 | /// reachable database; the test cleans up the rows it creates so it can rerun. | |
docs/decisions.md +17 −0
| @@ -393,3 +393,20 @@ directly on the data directory with no running server, so first-run generation m | |||
| 393 | 393 | happen there too. Centralizing it in one helper means `token add` today and `serve` | |
| 394 | 394 | later sign with the same key. `auth::new_secret` provides the 32-byte base64url | |
| 395 | 395 | value, keeping randomness in the auth crate. | |
| 396 | + | ||
| 397 | + | ## 2026-07-24 — Mail over native-TLS, not the spec's rustls transport | |
| 398 | + | ||
| 399 | + | **Decision:** `crates/mail` uses `lettre` with the `tokio1-native-tls` transport, | |
| 400 | + | not the `tokio1-rustls` transport the spec names. | |
| 401 | + | ||
| 402 | + | **Alternatives:** `tokio1-rustls-tls` (the spec's choice). | |
| 403 | + | ||
| 404 | + | **Rationale:** lettre's rustls transport pulls `rustls` → `ring`, whose license set | |
| 405 | + | is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and | |
| 406 | + | sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a | |
| 407 | + | flake `buildInput` (and present in the Debian runtime image), so it adds no new C | |
| 408 | + | dependency and keeps `ring` out of the graph. Backends are `smtp` (native-TLS | |
| 409 | + | `starttls`/`tls`/plaintext), `stdout`, and `none`; tests exercise the `stdout` and | |
| 410 | + | `none` paths so `cargo test` needs no SMTP server. A **SHOULD** deviation logged | |
| 411 | + | here. The invite token reuses `auth::new_session_token`'s shape (32 bytes, | |
| 412 | + | base64url value emailed, SHA-256 stored). | |
fabrica.example.toml +1 −0
| @@ -51,6 +51,7 @@ max_connections = 16 | |||
| 51 | 51 | secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600 | |
| 52 | 52 | session_ttl_days = 30 | |
| 53 | 53 | token_ttl_days = 90 | |
| 54 | + | invite_ttl_hours = 72 # activation/reset link lifetime | |
| 54 | 55 | cookie_name = "fabrica_session" | |
| 55 | 56 | cookie_secure = true # set false only if instance.url is plain http (dev) | |
| 56 | 57 | argon2_m_cost = 19456 # KiB | |