commit ca8ca659c1781c72163f5651586556fcd9c86168
parent 8140a43dac4c21f11f0849a6ad593bc717078889
Author: Silas Brack <silasbrack@gmail.com>
Date: Fri, 12 Jun 2026 20:51:09 +0200
feat: add migration system, email verification, and data migration script
Migration system (BurntSushi-style):
- Numbered migrations in an array, version tracked in migration_version table
- All pending migrations run in a single transaction
- PRAGMAs (WAL, foreign_keys, etc.) run on every pool connection
- Schema extracted from hardcoded init into migration 0
Email verification:
- Registration sends verification email via SMTP (lettre)
- Users must verify email before login
- /verify-email?token=... endpoint
- Resend verification functionality
- Configurable via SMTP_HOST, SMTP_PORT, SMTP_FROM, SITE_URL env vars
Data migration:
- scripts/migrate-from-trailbase.sql migrates old TrailBase data
- Maps UUID user IDs to integer IDs
- Copies stories, comments, votes, categories
- Users need to re-register (passwords can't be migrated)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 971 insertions(+), 201 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -82,7 +82,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
- "nom",
+ "nom 7.1.3",
]
[[package]]
@@ -282,6 +282,16 @@ dependencies = [
]
[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -336,12 +346,49 @@ dependencies = [
]
[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "email-encoding"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
+dependencies = [
+ "base64",
+ "memchr",
+]
+
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -354,6 +401,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -366,6 +419,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -390,6 +458,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -408,7 +482,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
+ "futures-io",
"futures-task",
+ "memchr",
"pin-project-lite",
"slab",
]
@@ -490,6 +566,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
+name = "hostname"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link",
+]
+
+[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -603,12 +690,115 @@ dependencies = [
]
[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -658,6 +848,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
+name = "lettre"
+version = "0.11.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
+dependencies = [
+ "base64",
+ "email-encoding",
+ "email_address",
+ "fastrand",
+ "futures-util",
+ "hostname",
+ "httpdate",
+ "idna",
+ "mime",
+ "native-tls",
+ "nom 8.0.0",
+ "percent-encoding",
+ "quoted_printable",
+ "socket2",
+ "tokio",
+ "url",
+]
+
+[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -681,6 +895,18 @@ dependencies = [
]
[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -741,6 +967,23 @@ dependencies = [
]
[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -751,6 +994,15 @@ dependencies = [
]
[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -791,6 +1043,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -853,6 +1148,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -896,6 +1200,12 @@ dependencies = [
]
[[package]]
+name = "quoted_printable"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
+
+[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1008,6 +1318,19 @@ dependencies = [
]
[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1020,6 +1343,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1035,6 +1367,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1122,6 +1477,7 @@ dependencies = [
"axum-extra",
"chrono",
"jsonwebtoken",
+ "lettre",
"r2d2",
"r2d2_sqlite",
"rand 0.8.6",
@@ -1132,6 +1488,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tracing",
+ "uuid",
]
[[package]]
@@ -1169,6 +1526,12 @@ dependencies = [
]
[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1192,6 +1555,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1243,6 +1630,16 @@ dependencies = [
]
[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
name = "tokio"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1383,6 +1780,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
name = "uuid"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1745,6 +2160,35 @@ dependencies = [
]
[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1765,6 +2209,60 @@ dependencies = [
]
[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -20,3 +20,5 @@ r2d2_sqlite = "0.24"
argon2 = "0.5"
jsonwebtoken = "9"
rand = "0.8"
+lettre = { version = "0.11", features = ["smtp-transport", "builder"] }
+uuid = { version = "1", features = ["v4"] }
diff --git a/flake.nix b/flake.nix
@@ -44,10 +44,16 @@
version = "0.1.0";
strictDeps = true;
- buildInputs = lib.optionals pkgs.stdenv.isDarwin [
+ buildInputs = with pkgs; [
+ openssl
+ ] ++ lib.optionals pkgs.stdenv.isDarwin [
pkgs.darwin.apple_sdk.frameworks.Security
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
];
+
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
};
# Build just the cargo dependencies
@@ -95,10 +101,16 @@
version = "0.1.0";
strictDeps = true;
- buildInputs = lib.optionals pkgs.stdenv.isDarwin [
+ buildInputs = with pkgs; [
+ openssl
+ ] ++ lib.optionals pkgs.stdenv.isDarwin [
pkgs.darwin.apple_sdk.frameworks.Security
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
];
+
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
diff --git a/scripts/migrate-from-trailbase.sql b/scripts/migrate-from-trailbase.sql
@@ -0,0 +1,71 @@
+-- Migration script: TrailBase -> direct SQLite
+-- Usage: sqlite3 /var/lib/simple-web-app/data/app.db < migrate-from-trailbase.sql
+--
+-- Prerequisites:
+-- 1. Stop the old TrailBase service
+-- 2. Copy old DB: cp /var/lib/simple-web-app-trailbase/traildepot/data/main.db /tmp/old.db
+-- 3. Run this script
+-- 4. Users will need to re-register (passwords can't be migrated)
+
+ATTACH '/tmp/old.db' AS old;
+
+-- Migrate categories
+INSERT OR IGNORE INTO category (id, name)
+SELECT id, name FROM old.category;
+
+-- Create users from old user_profile (without passwords - they'll need to re-register)
+-- Use a placeholder password hash
+INSERT OR IGNORE INTO user (id, email, password_hash, username, about, karma, created_at, email_verified)
+SELECT
+ up.id,
+ 'migrated_' || up.id || '@placeholder.com',
+ '$placeholder$needs_reregister',
+ up.username,
+ up.about,
+ up.karma,
+ up.created_at,
+ 0
+FROM old.user_profile up;
+
+-- Create ID mapping: old UUID blob -> new integer user ID
+-- This uses user_profile.user_id (blob) -> user_profile.id (integer)
+CREATE TEMP TABLE user_id_map AS
+SELECT user_id AS old_id, id AS new_id FROM old.user_profile;
+
+-- Migrate stories
+INSERT OR IGNORE INTO story (id, url, title, text, published, author, language, created_by, score, comment_count, domain)
+SELECT
+ s.id, s.url, s.title, s.text, s.published, s.author, s.language,
+ COALESCE(m.new_id, NULL),
+ s.score, s.comment_count, s.domain
+FROM old.story s
+LEFT JOIN user_id_map m ON m.old_id = s.created_by;
+
+-- Migrate comments
+INSERT OR IGNORE INTO comment (id, story_id, parent_id, path, depth, text, score, created_at, created_by)
+SELECT
+ c.id, c.story_id, c.parent_id, c.path, c.depth, c.text, c.score, c.created_at,
+ COALESCE(m.new_id, 0)
+FROM old.comment c
+LEFT JOIN user_id_map m ON m.old_id = c.created_by;
+
+-- Migrate votes
+INSERT OR IGNORE INTO vote (id, user_id, target_type, target_id, created_at)
+SELECT
+ v.id,
+ COALESCE(m.new_id, 0),
+ v.target_type, v.target_id, v.created_at
+FROM old.vote v
+LEFT JOIN user_id_map m ON m.old_id = v.user_id;
+
+-- Migrate story_category
+INSERT OR IGNORE INTO story_category (id, news_item_id, category_id)
+SELECT id, news_item_id, category_id FROM old.story_category;
+
+-- Rebuild FTS index
+INSERT INTO story_fts(story_fts) VALUES('rebuild');
+
+DROP TABLE user_id_map;
+DETACH old;
+
+.quit
diff --git a/src/config.rs b/src/config.rs
@@ -5,6 +5,10 @@ pub struct Config {
pub jwt_secret: String,
pub debug: bool,
pub port: u16,
+ pub smtp_host: String,
+ pub smtp_port: u16,
+ pub smtp_from: String,
+ pub site_url: String,
}
impl Config {
@@ -28,11 +32,23 @@ impl Config {
.and_then(|v| v.parse().ok())
.unwrap_or(8000);
+ let smtp_host = env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string());
+ let smtp_port = env::var("SMTP_PORT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(25);
+ let smtp_from = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@silasbrack.com".to_string());
+ let site_url = env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
+
Self {
db_path,
jwt_secret,
debug,
port,
+ smtp_host,
+ smtp_port,
+ smtp_from,
+ site_url,
}
}
}
diff --git a/src/database.rs b/src/database.rs
@@ -57,6 +57,8 @@ pub struct User {
pub about: Option<String>,
pub karma: i64,
pub created_at: String,
+ pub email_verified: bool,
+ pub verification_token: Option<String>,
}
/// Kept for template compatibility - maps to User fields
@@ -115,15 +117,29 @@ impl Database {
.map_err(|e| AppError::Database(format!("Failed to create db directory: {}", e)))?;
}
- let manager = SqliteConnectionManager::file(path);
+ // Run migrations on a direct connection first
+ let migration_conn = rusqlite::Connection::open(path)
+ .map_err(|e| AppError::Database(format!("Failed to open db for migrations: {}", e)))?;
+ migration_conn.execute_batch(
+ "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA cache_size=-64000;"
+ ).map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {}", e)))?;
+ crate::migrations::run(&migration_conn)
+ .map_err(|e| AppError::Database(e))?;
+ drop(migration_conn);
+
+ // Set up pool with per-connection PRAGMA init
+ let manager = SqliteConnectionManager::file(path)
+ .with_init(|c| {
+ c.execute_batch(
+ "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA cache_size=-64000;"
+ )
+ });
let pool = Pool::builder()
.max_size(10)
.build(manager)
.map_err(|e| AppError::Database(format!("Failed to create pool: {}", e)))?;
- let db = Self { pool };
- db.init_schema()?;
- Ok(db)
+ Ok(Self { pool })
}
fn get_conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>, AppError> {
@@ -132,183 +148,6 @@ impl Database {
.map_err(|e| AppError::Database(format!("Failed to get connection: {}", e)))
}
- fn init_schema(&self) -> Result<(), AppError> {
- let conn = self.get_conn()?;
-
- conn.execute_batch("
- PRAGMA journal_mode=WAL;
- PRAGMA foreign_keys=ON;
- PRAGMA busy_timeout=5000;
- PRAGMA cache_size=-64000;
- ").map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {}", e)))?;
-
- conn.execute_batch("
- CREATE TABLE IF NOT EXISTS user (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- email TEXT UNIQUE NOT NULL,
- password_hash TEXT NOT NULL,
- username TEXT UNIQUE NOT NULL,
- about TEXT,
- karma INTEGER DEFAULT 0,
- created_at TEXT DEFAULT (datetime('now'))
- );
-
- CREATE TABLE IF NOT EXISTS story (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- url TEXT,
- title TEXT NOT NULL,
- text TEXT NOT NULL DEFAULT '',
- published TEXT NOT NULL DEFAULT (datetime('now')),
- author TEXT,
- language TEXT NOT NULL DEFAULT 'en',
- created_by INTEGER REFERENCES user(id),
- score INTEGER NOT NULL DEFAULT 0,
- comment_count INTEGER NOT NULL DEFAULT 0,
- domain TEXT
- );
-
- CREATE TABLE IF NOT EXISTS comment (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- story_id INTEGER NOT NULL REFERENCES story(id),
- parent_id INTEGER REFERENCES comment(id),
- path TEXT NOT NULL DEFAULT '',
- depth INTEGER NOT NULL DEFAULT 0,
- text TEXT NOT NULL DEFAULT '',
- score INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- created_by INTEGER NOT NULL REFERENCES user(id)
- );
-
- CREATE TABLE IF NOT EXISTS vote (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL REFERENCES user(id),
- target_type INTEGER NOT NULL,
- target_id INTEGER NOT NULL,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- UNIQUE(user_id, target_type, target_id)
- );
-
- CREATE TABLE IF NOT EXISTS category (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT
- );
-
- CREATE TABLE IF NOT EXISTS story_category (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- news_item_id INTEGER NOT NULL REFERENCES story(id),
- category_id INTEGER NOT NULL REFERENCES category(id)
- );
-
- -- FTS index
- CREATE VIRTUAL TABLE IF NOT EXISTS story_fts USING fts5(
- title, text,
- content='story',
- content_rowid='id',
- tokenize='porter unicode61'
- );
-
- -- Indices
- CREATE INDEX IF NOT EXISTS idx_story_published ON story(published DESC);
- CREATE INDEX IF NOT EXISTS idx_story_score ON story(score DESC);
- CREATE INDEX IF NOT EXISTS idx_story_created_by ON story(created_by);
- CREATE INDEX IF NOT EXISTS idx_comment_story_id ON comment(story_id);
- CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id);
- CREATE INDEX IF NOT EXISTS idx_comment_created_by ON comment(created_by);
- CREATE INDEX IF NOT EXISTS idx_vote_user_target ON vote(user_id, target_type, target_id);
- CREATE INDEX IF NOT EXISTS idx_story_category_news_item ON story_category(news_item_id);
- CREATE INDEX IF NOT EXISTS idx_story_category_category ON story_category(category_id);
- ").map_err(|e| AppError::Database(format!("Failed to create tables: {}", e)))?;
-
- // Create triggers (use IF NOT EXISTS via checking sqlite_master)
- let trigger_exists = |name: &str| -> Result<bool, AppError> {
- let exists: bool = conn.query_row(
- "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='trigger' AND name=?1",
- params![name],
- |row| row.get(0),
- ).map_err(|e| AppError::Database(format!("Trigger check failed: {}", e)))?;
- Ok(exists)
- };
-
- if !trigger_exists("story_fts_insert")? {
- conn.execute_batch("
- CREATE TRIGGER story_fts_insert AFTER INSERT ON story BEGIN
- INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text);
- END;
- ").map_err(|e| AppError::Database(format!("FTS insert trigger: {}", e)))?;
- }
-
- if !trigger_exists("story_fts_update")? {
- conn.execute_batch("
- CREATE TRIGGER story_fts_update AFTER UPDATE ON story BEGIN
- INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text);
- INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text);
- END;
- ").map_err(|e| AppError::Database(format!("FTS update trigger: {}", e)))?;
- }
-
- if !trigger_exists("story_fts_delete")? {
- conn.execute_batch("
- CREATE TRIGGER story_fts_delete AFTER DELETE ON story BEGIN
- INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text);
- END;
- ").map_err(|e| AppError::Database(format!("FTS delete trigger: {}", e)))?;
- }
-
- if !trigger_exists("comment_count_insert")? {
- conn.execute_batch("
- CREATE TRIGGER comment_count_insert AFTER INSERT ON comment BEGIN
- UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id;
- END;
- ").map_err(|e| AppError::Database(format!("comment_count insert trigger: {}", e)))?;
- }
-
- if !trigger_exists("comment_count_delete")? {
- conn.execute_batch("
- CREATE TRIGGER comment_count_delete AFTER DELETE ON comment BEGIN
- UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id;
- END;
- ").map_err(|e| AppError::Database(format!("comment_count delete trigger: {}", e)))?;
- }
-
- if !trigger_exists("vote_insert_story")? {
- conn.execute_batch("
- CREATE TRIGGER vote_insert_story AFTER INSERT ON vote WHEN new.target_type = 1 BEGIN
- UPDATE story SET score = score + 1 WHERE id = new.target_id;
- UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM story WHERE id = new.target_id);
- END;
- ").map_err(|e| AppError::Database(format!("vote insert story trigger: {}", e)))?;
- }
-
- if !trigger_exists("vote_delete_story")? {
- conn.execute_batch("
- CREATE TRIGGER vote_delete_story AFTER DELETE ON vote WHEN old.target_type = 1 BEGIN
- UPDATE story SET score = score - 1 WHERE id = old.target_id;
- UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM story WHERE id = old.target_id);
- END;
- ").map_err(|e| AppError::Database(format!("vote delete story trigger: {}", e)))?;
- }
-
- if !trigger_exists("vote_insert_comment")? {
- conn.execute_batch("
- CREATE TRIGGER vote_insert_comment AFTER INSERT ON vote WHEN new.target_type = 2 BEGIN
- UPDATE comment SET score = score + 1 WHERE id = new.target_id;
- UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM comment WHERE id = new.target_id);
- END;
- ").map_err(|e| AppError::Database(format!("vote insert comment trigger: {}", e)))?;
- }
-
- if !trigger_exists("vote_delete_comment")? {
- conn.execute_batch("
- CREATE TRIGGER vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 BEGIN
- UPDATE comment SET score = score - 1 WHERE id = old.target_id;
- UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id);
- END;
- ").map_err(|e| AppError::Database(format!("vote delete comment trigger: {}", e)))?;
- }
-
- Ok(())
- }
-
// ======================================================================
// Story queries
// ======================================================================
@@ -743,6 +582,7 @@ impl Database {
// ======================================================================
fn row_to_user(row: &rusqlite::Row) -> rusqlite::Result<User> {
+ let email_verified_int: i64 = row.get("email_verified")?;
Ok(User {
id: row.get("id")?,
email: row.get("email")?,
@@ -751,6 +591,8 @@ impl Database {
about: row.get("about")?,
karma: row.get("karma")?,
created_at: row.get("created_at")?,
+ email_verified: email_verified_int != 0,
+ verification_token: row.get("verification_token")?,
})
}
@@ -799,17 +641,35 @@ impl Database {
}
}
- pub fn create_user(&self, email: &str, password_hash: &str, username: &str) -> Result<User, AppError> {
+ pub fn create_user(&self, email: &str, password_hash: &str, username: &str, verification_token: Option<&str>) -> Result<User, AppError> {
let conn = self.get_conn()?;
conn.execute(
- "INSERT INTO user (email, password_hash, username) VALUES (?1, ?2, ?3)",
- params![email, password_hash, username],
+ "INSERT INTO user (email, password_hash, username, verification_token) VALUES (?1, ?2, ?3, ?4)",
+ params![email, password_hash, username, verification_token],
).map_err(|e| AppError::Database(e.to_string()))?;
let id = conn.last_insert_rowid();
self.get_user_by_id(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created user".to_string()))
}
+ pub fn verify_email_token(&self, token: &str) -> Result<bool, AppError> {
+ let conn = self.get_conn()?;
+ let rows_affected = conn.execute(
+ "UPDATE user SET email_verified = 1, verification_token = NULL WHERE verification_token = ?1",
+ params![token],
+ ).map_err(|e| AppError::Database(e.to_string()))?;
+ Ok(rows_affected > 0)
+ }
+
+ pub fn update_verification_token(&self, user_id: i64, token: &str) -> Result<(), AppError> {
+ let conn = self.get_conn()?;
+ conn.execute(
+ "UPDATE user SET verification_token = ?1 WHERE id = ?2",
+ params![token, user_id],
+ ).map_err(|e| AppError::Database(e.to_string()))?;
+ Ok(())
+ }
+
// ======================================================================
// Enrichment queries
// ======================================================================
diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs
@@ -1,6 +1,6 @@
use askama::Template;
use axum::{
- extract::State,
+ extract::{Query, State},
response::{Html, Redirect},
Form,
};
@@ -33,6 +33,29 @@ fn clear_auth_cookie(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::from(AUTH_COOKIE))
}
+fn send_verification_email(state: &AppState, email: &str, token: &str) -> Result<(), String> {
+ use lettre::{SmtpTransport, Transport, Message};
+ use lettre::message::header::ContentType;
+
+ let verify_url = format!("{}/verify-email?token={}", state.site_url, token);
+ let body = format!("Click to verify your email: {}", verify_url);
+
+ let message = Message::builder()
+ .from(state.smtp_from.parse().map_err(|e| format!("Invalid from address: {}", e))?)
+ .to(email.parse().map_err(|e| format!("Invalid to address: {}", e))?)
+ .subject("Verify your email")
+ .header(ContentType::TEXT_PLAIN)
+ .body(body)
+ .map_err(|e| format!("Failed to build email: {}", e))?;
+
+ let mailer = SmtpTransport::builder_dangerous(&state.smtp_host)
+ .port(state.smtp_port)
+ .build();
+
+ mailer.send(&message).map_err(|e| format!("Failed to send email: {}", e))?;
+ Ok(())
+}
+
pub async fn show_login() -> Result<Html<String>, AppError> {
let login = LoginTemplate { error: None };
let app = ApplicationTemplate {
@@ -77,6 +100,12 @@ pub async fn login(
return Err(AppError::Unauthorized("Invalid email or password".to_string()));
}
+ if !user.email_verified {
+ return Err(AppError::Unauthorized(
+ "Please verify your email before logging in. Check your inbox or visit /resend-verification to get a new link.".to_string()
+ ));
+ }
+
let token = auth::create_token(user.id, &jwt_secret)?;
Ok(token)
}).await;
@@ -100,9 +129,8 @@ pub struct RegisterForm {
pub async fn register(
State(state): State<AppState>,
- jar: CookieJar,
Form(form): Form<RegisterForm>,
-) -> Result<(CookieJar, Redirect), Html<String>> {
+) -> Result<Html<String>, Html<String>> {
// Validate username
if form.username.trim().is_empty() {
return Err(render_register_error(&form, "Username is required"));
@@ -133,7 +161,8 @@ pub async fn register(
let username = form.username.clone();
let email = form.email.clone();
let password = form.password.clone();
- let jwt_secret = state.jwt_secret.clone();
+ let token = uuid::Uuid::new_v4().to_string();
+ let token_clone = token.clone();
let result = state.db_query(move |db| {
// Check if username is taken
@@ -147,24 +176,76 @@ pub async fn register(
}
let password_hash = auth::hash_password(&password)?;
- let user = db.create_user(&email, &password_hash, &username)?;
- let token = auth::create_token(user.id, &jwt_secret)?;
- Ok(token)
+ db.create_user(&email, &password_hash, &username, Some(&token_clone))?;
+ Ok(())
}).await;
match result {
- Ok(token) => {
- let jar = set_auth_cookie(jar, &token);
- Ok((jar, Redirect::to("/")))
+ Ok(()) => {
+ // Send verification email
+ let email_addr = form.email.clone();
+ if let Err(e) = send_verification_email(&state, &email_addr, &token) {
+ eprintln!("Failed to send verification email: {}", e);
+ }
+
+ // Show "check your email" message instead of auto-login
+ let template = ResendVerificationTemplate {
+ error: None,
+ message: Some("Registration successful! Please check your email to verify your account.".to_string()),
+ };
+ let app = ApplicationTemplate {
+ content: template.render().unwrap_or_default(),
+ };
+ Ok(Html(app.render().unwrap_or_default()))
}
Err(e) => Err(render_register_error(&form, &format!("{}", e))),
}
}
+#[derive(Deserialize)]
+pub struct VerifyEmailQuery {
+ pub token: String,
+}
+
+pub async fn verify_email(
+ State(state): State<AppState>,
+ Query(query): Query<VerifyEmailQuery>,
+) -> Result<Redirect, Html<String>> {
+ let token = query.token;
+
+ let result = state.db_query(move |db| {
+ db.verify_email_token(&token)
+ }).await;
+
+ match result {
+ Ok(true) => Ok(Redirect::to("/login")),
+ Ok(false) => {
+ let template = ResendVerificationTemplate {
+ error: Some("Invalid or expired verification token.".to_string()),
+ message: None,
+ };
+ let app = ApplicationTemplate {
+ content: template.render().unwrap_or_default(),
+ };
+ Err(Html(app.render().unwrap_or_default()))
+ }
+ Err(e) => {
+ let template = ResendVerificationTemplate {
+ error: Some(format!("Verification failed: {}", e)),
+ message: None,
+ };
+ let app = ApplicationTemplate {
+ content: template.render().unwrap_or_default(),
+ };
+ Err(Html(app.render().unwrap_or_default()))
+ }
+ }
+}
+
pub async fn show_resend_verification() -> Result<Html<String>, AppError> {
let template = ResendVerificationTemplate {
error: None,
- message: Some("Email verification is not required with the current setup.".to_string()),
+ message: None,
};
let app = ApplicationTemplate {
content: template.render()?,
@@ -174,16 +255,39 @@ pub async fn show_resend_verification() -> Result<Html<String>, AppError> {
#[derive(Deserialize)]
pub struct ResendVerificationForm {
- #[allow(dead_code)]
pub email: String,
}
pub async fn resend_verification(
- Form(_form): Form<ResendVerificationForm>,
+ State(state): State<AppState>,
+ Form(form): Form<ResendVerificationForm>,
) -> Result<Html<String>, AppError> {
+ let email = form.email.clone();
+ let new_token = uuid::Uuid::new_v4().to_string();
+ let new_token_clone = new_token.clone();
+
+ let user_found = state.db_query(move |db| {
+ let user = db.get_user_by_email(&email)?;
+ match user {
+ Some(u) if !u.email_verified => {
+ db.update_verification_token(u.id, &new_token_clone)?;
+ Ok(true)
+ }
+ Some(_) => Ok(false), // already verified
+ None => Ok(false), // user not found
+ }
+ }).await?;
+
+ if user_found {
+ if let Err(e) = send_verification_email(&state, &form.email, &new_token) {
+ eprintln!("Failed to send verification email: {}", e);
+ }
+ }
+
+ // Always show the same message to avoid leaking whether the email exists
let template = ResendVerificationTemplate {
error: None,
- message: Some("Email verification is not required with the current setup.".to_string()),
+ message: Some("If an account exists with that email, a new verification link has been sent.".to_string()),
};
let app = ApplicationTemplate {
content: template.render()?,
diff --git a/src/main.rs b/src/main.rs
@@ -4,6 +4,7 @@ mod database;
mod db;
mod error;
mod handlers;
+mod migrations;
mod routes;
mod sse;
mod state;
diff --git a/src/migrations.rs b/src/migrations.rs
@@ -0,0 +1,197 @@
+use rusqlite::Connection;
+
+/// Each migration is a SQL string. Migration 0 is the initial schema.
+/// Migrations are applied in order, and the current version is tracked
+/// in the `migration_version` table.
+const MIGRATIONS: &[&str] = &[
+ // Migration 0: Initial schema
+ r#"
+ CREATE TABLE IF NOT EXISTS user (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ username TEXT UNIQUE NOT NULL,
+ about TEXT,
+ karma INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS story (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url TEXT,
+ title TEXT NOT NULL,
+ text TEXT NOT NULL DEFAULT '',
+ published TEXT NOT NULL DEFAULT (datetime('now')),
+ author TEXT,
+ language TEXT NOT NULL DEFAULT 'en',
+ created_by INTEGER REFERENCES user(id),
+ score INTEGER NOT NULL DEFAULT 0,
+ comment_count INTEGER NOT NULL DEFAULT 0,
+ domain TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ story_id INTEGER NOT NULL REFERENCES story(id),
+ parent_id INTEGER REFERENCES comment(id),
+ path TEXT NOT NULL DEFAULT '',
+ depth INTEGER NOT NULL DEFAULT 0,
+ text TEXT NOT NULL DEFAULT '',
+ score INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ created_by INTEGER NOT NULL REFERENCES user(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS vote (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES user(id),
+ target_type INTEGER NOT NULL,
+ target_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(user_id, target_type, target_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS category (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS story_category (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ news_item_id INTEGER NOT NULL REFERENCES story(id),
+ category_id INTEGER NOT NULL REFERENCES category(id)
+ );
+
+ CREATE VIRTUAL TABLE IF NOT EXISTS story_fts USING fts5(
+ title, text,
+ content='story',
+ content_rowid='id',
+ tokenize='porter unicode61'
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_story_published ON story(published DESC);
+ CREATE INDEX IF NOT EXISTS idx_story_score ON story(score DESC);
+ CREATE INDEX IF NOT EXISTS idx_story_created_by ON story(created_by);
+ CREATE INDEX IF NOT EXISTS idx_comment_story_id ON comment(story_id);
+ CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id);
+ CREATE INDEX IF NOT EXISTS idx_comment_created_by ON comment(created_by);
+ CREATE INDEX IF NOT EXISTS idx_vote_user_target ON vote(user_id, target_type, target_id);
+ CREATE INDEX IF NOT EXISTS idx_story_category_news_item ON story_category(news_item_id);
+ CREATE INDEX IF NOT EXISTS idx_story_category_category ON story_category(category_id);
+
+ CREATE TRIGGER IF NOT EXISTS story_fts_insert AFTER INSERT ON story BEGIN
+ INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS story_fts_update AFTER UPDATE ON story BEGIN
+ INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text);
+ INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS story_fts_delete AFTER DELETE ON story BEGIN
+ INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS comment_count_insert AFTER INSERT ON comment BEGIN
+ UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id;
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS comment_count_delete AFTER DELETE ON comment BEGIN
+ UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id;
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS vote_insert_story AFTER INSERT ON vote WHEN new.target_type = 1 BEGIN
+ UPDATE story SET score = score + 1 WHERE id = new.target_id;
+ UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM story WHERE id = new.target_id);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS vote_delete_story AFTER DELETE ON vote WHEN old.target_type = 1 BEGIN
+ UPDATE story SET score = score - 1 WHERE id = old.target_id;
+ UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM story WHERE id = old.target_id);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS vote_insert_comment AFTER INSERT ON vote WHEN new.target_type = 2 BEGIN
+ UPDATE comment SET score = score + 1 WHERE id = new.target_id;
+ UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM comment WHERE id = new.target_id);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 BEGIN
+ UPDATE comment SET score = score - 1 WHERE id = old.target_id;
+ UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id);
+ END;
+ "#,
+
+ // Migration 1: Add email verification columns
+ r#"
+ ALTER TABLE user ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0;
+ ALTER TABLE user ADD COLUMN verification_token TEXT;
+ "#,
+];
+
+pub fn run(conn: &Connection) -> Result<(), String> {
+ // Create migration_version table if it doesn't exist
+ conn.execute_batch(
+ "CREATE TABLE IF NOT EXISTS migration_version (version INTEGER NOT NULL);"
+ ).map_err(|e| format!("Failed to create migration_version table: {}", e))?;
+
+ // Get current version
+ let current_version: i64 = conn
+ .query_row(
+ "SELECT COALESCE(MAX(version), 0) FROM migration_version",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap_or(0);
+
+ let total = MIGRATIONS.len() as i64;
+
+ if current_version >= total {
+ eprintln!("Database is up to date (version {})", current_version);
+ return Ok(());
+ }
+
+ eprintln!(
+ "Running migrations {} through {} (current version: {})",
+ current_version,
+ total - 1,
+ current_version
+ );
+
+ // Run all pending migrations in a single transaction
+ conn.execute_batch("BEGIN TRANSACTION;")
+ .map_err(|e| format!("Failed to begin transaction: {}", e))?;
+
+ for i in current_version..total {
+ eprintln!(" Applying migration {}...", i);
+ conn.execute_batch(MIGRATIONS[i as usize])
+ .map_err(|e| {
+ let _ = conn.execute_batch("ROLLBACK;");
+ format!("Migration {} failed: {}", i, e)
+ })?;
+ }
+
+ // Update version
+ if current_version == 0 {
+ // Check if there are any rows
+ let count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM migration_version", [], |row| row.get(0))
+ .map_err(|e| format!("Failed to count migration_version rows: {}", e))?;
+
+ if count == 0 {
+ conn.execute("INSERT INTO migration_version (version) VALUES (?1)", [total])
+ .map_err(|e| format!("Failed to insert migration version: {}", e))?;
+ } else {
+ conn.execute("UPDATE migration_version SET version = ?1", [total])
+ .map_err(|e| format!("Failed to update migration version: {}", e))?;
+ }
+ } else {
+ conn.execute("UPDATE migration_version SET version = ?1", [total])
+ .map_err(|e| format!("Failed to update migration version: {}", e))?;
+ }
+
+ conn.execute_batch("COMMIT;")
+ .map_err(|e| format!("Failed to commit transaction: {}", e))?;
+
+ eprintln!("Migrations complete. Database is now at version {}", total);
+ Ok(())
+}
diff --git a/src/routes.rs b/src/routes.rs
@@ -103,6 +103,7 @@ pub fn create_router(state: AppState) -> Router {
.route("/logout", get(handlers::logout))
.route("/resend-verification", get(handlers::show_resend_verification))
.route("/resend-verification", post(handlers::resend_verification))
+ .route("/verify-email", get(handlers::verify_email))
// Static files
.route("/static/style.css", get(serve_style))
diff --git a/src/state.rs b/src/state.rs
@@ -9,6 +9,10 @@ pub struct AppState {
pub db: Arc<Database>,
pub jwt_secret: String,
pub comment_tx: tokio::sync::broadcast::Sender<i64>,
+ pub smtp_host: String,
+ pub smtp_port: u16,
+ pub smtp_from: String,
+ pub site_url: String,
}
impl AppState {
@@ -18,6 +22,10 @@ impl AppState {
db: Arc::new(db),
jwt_secret: config.jwt_secret.clone(),
comment_tx,
+ smtp_host: config.smtp_host.clone(),
+ smtp_port: config.smtp_port,
+ smtp_from: config.smtp_from.clone(),
+ site_url: config.site_url.clone(),
}
}