Skip to main content

max / makenotwork

server: audit MaybeUser sites; introduce MaybeUserVerified Rename MaybeUser → MaybeUserUnverified to make the lack of session revocation check visible at every call site. Add MaybeUserVerified extractor that runs touch_session (cached) and refreshes suspended / tier / can_create_projects flags during extraction, matching AuthUser's guarantees for the optional-login case. Convert identity-gated handlers to MaybeUserVerified: - oauth.rs authorize_{get,post} — grant issuance; deletes 40-line inline touch_session block in POST, keeps explicit tracking-ID gate so legacy untracked sessions still must re-auth for grants - storage/downloads.rs stream_url, version_download — paid stream/ download access checks - pages/public/content/library.rs library_page — paid content reader - pages/public/content/project.rs project_page — project paywall - pages/public/content/mod.rs receipt_page, collection_page — buyer/seller-only transactions; private collection visibility - stripe/checkout/mod.rs checkout_success — cart-queue checkout - git_issues/issues.rs issue_list, issue_detail — private repo visibility via resolve_repo Public read-only surfaces (blog, docs, discover, landing, raw git browsing, store item_page, user profile, purchase_page, join wizard) stay on MaybeUserUnverified per the documented happy path.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-25 23:31 UTC
Commit: 2a7414caa6ef33a51129e3284f78dc889adc5055
Parent: 87e3e66
18 files changed, +184 insertions, -116 deletions
@@ -12,7 +12,9 @@
12 12 //! on the user row. New-device login notifications are sent via Postmark
13 13 //! when enabled.
14 14 //!
15 - //! Extractors: [`AuthUser`] (required login), [`MaybeUser`] (optional),
15 + //! Extractors: [`AuthUser`] (required login), [`MaybeUserUnverified`] (optional,
16 + //! no revocation check — public read-only pages only), [`MaybeUserVerified`]
17 + //! (optional with revocation check — anywhere identity actually gates behavior),
16 18 //! [`AdminUser`] (admin-only, hides routes with 404).
17 19
18 20 use argon2::{
@@ -199,13 +201,24 @@ impl FromRequestParts<crate::AppState> for AuthUser {
199 201
200 202 /// Extractor for optional authenticated users — returns None if not logged in.
201 203 ///
202 - /// **Security note:** Unlike `AuthUser`, this does NOT validate the session tracking ID
203 - /// against the database. Revoked sessions may still appear authenticated. Use only on
204 - /// read-only or public endpoints where this is acceptable. For any endpoint that modifies
205 - /// data or displays sensitive information, use `AuthUser` instead.
206 - pub struct MaybeUser(pub Option<SessionUser>);
207 -
208 - impl<S> FromRequestParts<S> for MaybeUser
204 + /// **DANGER — this extractor does NOT validate the session against the database.**
205 + /// A revoked session (user clicked "log out everywhere", account suspended,
206 + /// session row deleted) will still resolve to `Some(SessionUser)` here until
207 + /// the cookie naturally expires. The name carries the warning: any handler
208 + /// that uses this type accepts that consequence.
209 + ///
210 + /// Use ONLY for cheap anonymous-or-logged-in rendering on public read-only
211 + /// pages where displaying stale identity is acceptable (blog views, docs,
212 + /// discover feed). For any handler that:
213 + /// - modifies data,
214 + /// - gates paid content or downloads,
215 + /// - issues OAuth tokens / grants,
216 + /// - exposes account-private information,
217 + /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login
218 + /// with revocation check) instead.
219 + pub struct MaybeUserUnverified(pub Option<SessionUser>);
220 +
221 + impl<S> FromRequestParts<S> for MaybeUserUnverified
209 222 where
210 223 S: Send + Sync,
211 224 {
@@ -222,7 +235,83 @@ where
222 235 .await
223 236 .context("session error")?;
224 237
225 - Ok(MaybeUser(user))
238 + Ok(MaybeUserUnverified(user))
239 + }
240 + }
241 +
242 + /// Extractor for optional authenticated users WITH revocation check.
243 + ///
244 + /// Like [`MaybeUserUnverified`] but runs the same session-tracking validation
245 + /// as [`AuthUser`]: if the tracking row has been deleted (revoked) or the
246 + /// account is suspended, the session is flushed and `None` is returned (the
247 + /// request continues as anonymous rather than 401, since the handler chose
248 + /// "optional auth"). Legacy sessions without a tracking ID pass through.
249 + ///
250 + /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`).
251 + /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates
252 + /// behavior — paid content access, OAuth flows, download grants, comments,
253 + /// or anything that writes to the DB on behalf of the user.
254 + pub struct MaybeUserVerified(pub Option<SessionUser>);
255 +
256 + impl FromRequestParts<crate::AppState> for MaybeUserVerified {
257 + type Rejection = AppError;
258 +
259 + async fn from_request_parts(
260 + parts: &mut Parts,
261 + state: &crate::AppState,
262 + ) -> Result<Self, Self::Rejection> {
263 + let session = parts
264 + .extensions
265 + .get::<Session>()
266 + .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
267 +
268 + let Some(mut user): Option<SessionUser> = session
269 + .get(USER_SESSION_KEY)
270 + .await
271 + .context("session error")?
272 + else {
273 + return Ok(MaybeUserVerified(None));
274 + };
275 +
276 + if let Ok(Some(tracking_id)) = session
277 + .get::<UserSessionId>(SESSION_TRACKING_KEY)
278 + .await
279 + {
280 + let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
281 + let cached = state.session_cache.get(&tracking_id)
282 + .map(|entry| entry.elapsed() < cache_ttl)
283 + .unwrap_or(false);
284 +
285 + if !cached {
286 + let result = match db::sessions::touch_session(&state.db, tracking_id).await {
287 + Ok(r) => r,
288 + Err(e) => {
289 + tracing::warn!(error = ?e, "session touch failed in MaybeUserVerified, treating as anonymous");
290 + db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None }
291 + }
292 + };
293 + if !result.valid {
294 + state.session_cache.remove(&tracking_id);
295 + let _ = session.flush().await;
296 + return Ok(MaybeUserVerified(None));
297 + }
298 + let live_tier: Option<db::CreatorTier> = result.creator_tier.as_deref().and_then(|s| s.parse().ok());
299 + if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier {
300 + user.suspended = result.suspended;
301 + user.is_fan_plus = result.is_fan_plus;
302 + user.can_create_projects = result.can_create_projects;
303 + user.creator_tier = live_tier;
304 + if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
305 + tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
306 + }
307 + }
308 + state.session_cache.insert(tracking_id, Instant::now());
309 + }
310 + }
311 +
312 + tracing::Span::current().record("user_id", tracing::field::display(&user.id));
313 +
314 + Ok(MaybeUserVerified(Some(user)))
226 315 }
227 316 }
228 317
@@ -12,7 +12,7 @@ use axum::{
12 12 use tower_sessions::Session;
13 13
14 14 use crate::{
15 - auth::{MaybeUser, SessionUser},
15 + auth::{MaybeUserUnverified, SessionUser},
16 16 db::{self, Slug},
17 17 error::{AppError, Result},
18 18 helpers::get_csrf_token,
@@ -100,7 +100,7 @@ pub async fn custom_domain_fallback(
100 100 headers: HeaderMap,
101 101 uri: Uri,
102 102 session: Session,
103 - MaybeUser(maybe_user): MaybeUser,
103 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
104 104 ) -> Response {
105 105 let Some(host) = extract_host(&headers) else {
106 106 return StatusCode::NOT_FOUND.into_response();
@@ -9,7 +9,7 @@ use serde::Deserialize;
9 9 use tower_sessions::Session;
10 10
11 11 use crate::{
12 - auth::MaybeUser,
12 + auth::MaybeUserUnverified,
13 13 constants,
14 14 db,
15 15 error::{AppError, Result},
@@ -28,7 +28,7 @@ use super::{
28 28 pub(super) async fn repo_overview(
29 29 State(state): State<AppState>,
30 30 session: Session,
31 - MaybeUser(maybe_user): MaybeUser,
31 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
32 32 Path((owner, repo_name)): Path<(String, String)>,
33 33 ) -> Result<impl IntoResponse> {
34 34 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -76,7 +76,7 @@ pub(super) async fn repo_overview(
76 76 pub(super) async fn tree_root(
77 77 State(state): State<AppState>,
78 78 session: Session,
79 - MaybeUser(maybe_user): MaybeUser,
79 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
80 80 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
81 81 ) -> Result<impl IntoResponse> {
82 82 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -122,7 +122,7 @@ pub(super) async fn tree_root(
122 122 pub(super) async fn tree_or_file(
123 123 State(state): State<AppState>,
124 124 session: Session,
125 - MaybeUser(maybe_user): MaybeUser,
125 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
126 126 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
127 127 ) -> Result<Response> {
128 128 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -220,7 +220,7 @@ pub(super) struct CommitQuery {
220 220 pub(super) async fn commit_log(
221 221 State(state): State<AppState>,
222 222 session: Session,
223 - MaybeUser(maybe_user): MaybeUser,
223 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
224 224 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
225 225 Query(query): Query<CommitQuery>,
226 226 ) -> Result<impl IntoResponse> {
@@ -262,7 +262,7 @@ pub(super) async fn commit_log(
262 262 pub(super) async fn commit_detail_page(
263 263 State(state): State<AppState>,
264 264 session: Session,
265 - MaybeUser(maybe_user): MaybeUser,
265 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
266 266 Path((owner, repo_name, oid_str)): Path<(String, String, String)>,
267 267 ) -> Result<impl IntoResponse> {
268 268 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -311,7 +311,7 @@ pub(super) async fn commit_detail_page(
311 311 pub(super) async fn blame_view(
312 312 State(state): State<AppState>,
313 313 session: Session,
314 - MaybeUser(maybe_user): MaybeUser,
314 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
315 315 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
316 316 ) -> Result<impl IntoResponse> {
317 317 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -348,7 +348,7 @@ pub(super) async fn blame_view(
348 348 pub(super) async fn user_repos(
349 349 State(state): State<AppState>,
350 350 session: Session,
351 - MaybeUser(maybe_user): MaybeUser,
351 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
352 352 Path(owner): Path<String>,
353 353 ) -> Result<impl IntoResponse> {
354 354 let username = db::Username::new(&owner).map_err(|_| AppError::NotFound)?;
@@ -386,7 +386,7 @@ pub(super) struct ExploreQuery {
386 386 pub(super) async fn git_landing(
387 387 State(state): State<AppState>,
388 388 session: Session,
389 - MaybeUser(maybe_user): MaybeUser,
389 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
390 390 Query(query): Query<ExploreQuery>,
391 391 ) -> Result<impl IntoResponse> {
392 392 let page = query.page.unwrap_or(1).clamp(1, 10_000);
@@ -415,7 +415,7 @@ pub(super) async fn git_landing(
415 415 pub(super) async fn file_log(
416 416 State(state): State<AppState>,
417 417 session: Session,
418 - MaybeUser(maybe_user): MaybeUser,
418 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
419 419 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
420 420 Query(query): Query<CommitQuery>,
421 421 ) -> Result<impl IntoResponse> {
@@ -9,7 +9,7 @@ use axum::{
9 9 use serde::Deserialize;
10 10
11 11 use crate::{
12 - auth::MaybeUser,
12 + auth::MaybeUserUnverified,
13 13 constants,
14 14 error::{AppError, Result, ResultExt},
15 15 git,
@@ -22,7 +22,7 @@ use super::{repos_root, resolve_repo, resolve_repo_name};
22 22 #[tracing::instrument(skip_all, name = "git::raw_file")]
23 23 pub(super) async fn raw_file(
24 24 State(state): State<AppState>,
25 - MaybeUser(maybe_user): MaybeUser,
25 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
26 26 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
27 27 ) -> Result<Response> {
28 28 let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -93,7 +93,7 @@ pub(super) struct InfoRefsQuery {
93 93 #[tracing::instrument(skip_all, name = "git::smart_http_info_refs")]
94 94 pub(super) async fn smart_http_info_refs(
95 95 State(state): State<AppState>,
96 - MaybeUser(maybe_user): MaybeUser,
96 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
97 97 Path((owner, repo_name)): Path<(String, String)>,
98 98 Query(query): Query<InfoRefsQuery>,
99 99 ) -> Result<Response> {
@@ -146,7 +146,7 @@ pub(super) async fn smart_http_info_refs(
146 146 #[tracing::instrument(skip_all, name = "git::smart_http_upload_pack")]
147 147 pub(super) async fn smart_http_upload_pack(
148 148 State(state): State<AppState>,
149 - MaybeUser(maybe_user): MaybeUser,
149 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
150 150 Path((owner, repo_name)): Path<(String, String)>,
151 151 body: axum::body::Bytes,
152 152 ) -> Result<Response> {
@@ -8,7 +8,7 @@ use serde::Deserialize;
8 8 use tower_sessions::Session;
9 9
10 10 use crate::{
11 - auth::MaybeUser,
11 + auth::MaybeUserVerified,
12 12 db::{self, IssueStatus},
13 13 error::{AppError, Result},
14 14 helpers::get_csrf_token,
@@ -32,7 +32,7 @@ pub(super) struct IssueListQuery {
32 32 pub(super) async fn issue_list(
33 33 State(state): State<AppState>,
34 34 session: Session,
35 - MaybeUser(maybe_user): MaybeUser,
35 + MaybeUserVerified(maybe_user): MaybeUserVerified,
36 36 Path((owner, repo_name)): Path<(String, String)>,
37 37 Query(query): Query<IssueListQuery>,
38 38 ) -> Result<impl IntoResponse> {
@@ -84,7 +84,7 @@ pub(super) async fn issue_list(
84 84 pub(super) async fn issue_detail(
85 85 State(state): State<AppState>,
86 86 session: Session,
87 - MaybeUser(maybe_user): MaybeUser,
87 + MaybeUserVerified(maybe_user): MaybeUserVerified,
88 88 Path((owner, repo_name, number)): Path<(String, String, i32)>,
89 89 ) -> Result<impl IntoResponse> {
90 90 let resolved = super::resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
@@ -18,10 +18,10 @@ use tower_governor::GovernorLayer;
18 18 use tower_sessions::Session;
19 19
20 20 use crate::{
21 - auth::{verify_password, MaybeUser, SESSION_TRACKING_KEY},
21 + auth::{verify_password, MaybeUserVerified},
22 22 constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS},
23 23 csrf,
24 - db::{self, CreatorTier, SyncAppId, UserId, UserSessionId, Username},
24 + db::{self, CreatorTier, SyncAppId, UserId, Username},
25 25 error::{AppError, Result},
26 26 synckit_auth,
27 27 templates::OAuthAuthorizeTemplate,
@@ -143,7 +143,7 @@ fn render_authorize_error(
143 143 #[tracing::instrument(skip_all, name = "oauth::authorize_get")]
144 144 async fn authorize_get(
145 145 State(state): State<AppState>,
146 - MaybeUser(session_user): MaybeUser,
146 + MaybeUserVerified(session_user): MaybeUserVerified,
147 147 session: Session,
148 148 Query(params): Query<AuthorizeQuery>,
149 149 ) -> Result<Response> {
@@ -197,7 +197,7 @@ async fn authorize_get(
197 197 #[tracing::instrument(skip_all, name = "oauth::authorize_post")]
198 198 async fn authorize_post(
199 199 State(state): State<AppState>,
200 - MaybeUser(session_user): MaybeUser,
200 + MaybeUserVerified(session_user): MaybeUserVerified,
201 201 session: Session,
202 202 Form(form): Form<AuthorizeForm>,
203 203 ) -> Result<Response> {
@@ -222,49 +222,16 @@ async fn authorize_post(
222 222
223 223 let csrf_token = csrf::get_or_create_token(&session).await?;
224 224
225 - // Determine the authenticated user.
226 - // When a session user is present, validate the session tracking row (mirrors
227 - // AuthUser logic) so that revoked sessions cannot authorize OAuth grants.
228 - let validated_session_user = if let Some(ref user) = session_user {
229 - // Check session tracking: if the tracked session has been revoked, discard it
230 - let still_valid = if let Ok(Some(tracking_id)) = session
231 - .get::<UserSessionId>(SESSION_TRACKING_KEY)
232 - .await
233 - {
234 - let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
235 - let cached = state.session_cache.get(&tracking_id)
236 - .map(|entry| entry.elapsed() < cache_ttl)
237 - .unwrap_or(false);
238 -
239 - if cached {
240 - true
241 - } else {
242 - let result = match db::sessions::touch_session(&state.db, tracking_id).await {
243 - Ok(r) => r,
244 - Err(e) => {
245 - tracing::warn!(error = ?e, "oauth session touch failed, invalidating");
246 - db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None }
247 - }
248 - };
249 - if result.valid && !result.suspended {
250 - state.session_cache.insert(tracking_id, std::time::Instant::now());
251 - true
252 - } else {
253 - state.session_cache.remove(&tracking_id);
254 - let _ = session.flush().await;
255 - false
256 - }
257 - }
258 - } else {
259 - // Legacy session without tracking ID — require re-authentication
260 - // for OAuth grants (stale session data could mask suspension)
261 - false
262 - };
263 -
264 - if still_valid { Some(user) } else { None }
265 - } else {
266 - None
267 - };
225 + // Session revocation/suspension is checked by MaybeUserVerified at extraction.
226 + // For OAuth grants specifically, also require a tracking ID — legacy
227 + // sessions predating session tracking must re-authenticate via password.
228 + let has_tracking = session
229 + .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
230 + .await
231 + .ok()
232 + .flatten()
233 + .is_some();
234 + let validated_session_user = session_user.as_ref().filter(|_| has_tracking);
268 235
269 236 let user_id = if let Some(user) = validated_session_user {
270 237 // Already logged in via validated MNW session — skip password check
@@ -9,7 +9,7 @@ use axum::{
9 9 use tower_sessions::Session;
10 10
11 11 use crate::{
12 - auth::MaybeUser,
12 + auth::MaybeUserUnverified,
13 13 constants,
14 14 db::{self, Slug},
15 15 error::{AppError, Result},
@@ -33,7 +33,7 @@ pub fn blog_routes() -> Router<AppState> {
33 33 async fn project_blog_page(
34 34 State(state): State<AppState>,
35 35 session: Session,
36 - MaybeUser(maybe_user): MaybeUser,
36 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
37 37 Path(slug): Path<String>,
38 38 ) -> Result<impl IntoResponse> {
39 39 let csrf_token = get_csrf_token(&session).await;
@@ -68,7 +68,7 @@ async fn project_blog_page(
68 68 async fn blog_post_page(
69 69 State(state): State<AppState>,
70 70 session: Session,
71 - MaybeUser(maybe_user): MaybeUser,
71 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
72 72 Path((slug, post_slug)): Path<(String, String)>,
73 73 ) -> Result<impl IntoResponse> {
74 74 let csrf_token = get_csrf_token(&session).await;
@@ -132,7 +132,7 @@ async fn blog_post_page(
132 132 async fn changelog_index(
133 133 State(state): State<AppState>,
134 134 session: Session,
135 - MaybeUser(maybe_user): MaybeUser,
135 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
136 136 ) -> Result<impl IntoResponse> {
137 137 let csrf_token = get_csrf_token(&session).await;
138 138
@@ -170,7 +170,7 @@ async fn changelog_index(
170 170 async fn changelog_post(
171 171 State(state): State<AppState>,
172 172 session: Session,
173 - MaybeUser(maybe_user): MaybeUser,
173 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
174 174 Path(post_slug): Path<String>,
175 175 ) -> Result<impl IntoResponse> {
176 176 let csrf_token = get_csrf_token(&session).await;
@@ -7,7 +7,7 @@ use axum::{
7 7 use tower_sessions::Session;
8 8
9 9 use crate::{
10 - auth::{MaybeUser, SessionUser},
10 + auth::{MaybeUserUnverified, SessionUser},
11 11 db::{self, ContentData, ItemId, ItemType},
12 12 error::{AppError, Result},
13 13 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
@@ -22,7 +22,7 @@ use crate::{
22 22 pub(in crate::routes::pages::public) async fn item_page(
23 23 State(state): State<AppState>,
24 24 session: Session,
25 - MaybeUser(maybe_user): MaybeUser,
25 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
26 26 Path(item_id): Path<String>,
27 27 ) -> Result<Response> {
28 28 let csrf_token = get_csrf_token(&session).await;
@@ -10,7 +10,7 @@ use axum::{
10 10 use tower_sessions::Session;
11 11
12 12 use crate::{
13 - auth::MaybeUser,
13 + auth::MaybeUserVerified,
14 14 constants,
15 15 db::{self, ContentData, ItemId, ItemType},
16 16 error::{AppError, Result},
@@ -27,7 +27,7 @@ pub(in crate::routes::pages::public) async fn library_page(
27 27 State(state): State<AppState>,
28 28 session: Session,
29 29 headers: axum::http::HeaderMap,
30 - MaybeUser(maybe_user): MaybeUser,
30 + MaybeUserVerified(maybe_user): MaybeUserVerified,
31 31 Path(item_id): Path<String>,
32 32 ) -> Result<Response> {
33 33 let csrf_token = get_csrf_token(&session).await;
@@ -18,7 +18,7 @@ use serde::Deserialize;
18 18 use tower_sessions::Session;
19 19
20 20 use crate::{
21 - auth::{MaybeUser, SessionUser},
21 + auth::{MaybeUserUnverified, MaybeUserVerified, SessionUser},
22 22 db::{self, FollowTargetType, ItemId, Username},
23 23 error::{AppError, Result},
24 24 helpers::get_csrf_token,
@@ -66,7 +66,7 @@ pub(super) async fn user_page(
66 66 State(state): State<AppState>,
67 67 session: Session,
68 68 headers: axum::http::HeaderMap,
69 - MaybeUser(maybe_user): MaybeUser,
69 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
70 70 Path(username): Path<String>,
71 71 ) -> Result<Response> {
72 72 let csrf_token = get_csrf_token(&session).await;
@@ -150,7 +150,7 @@ pub(crate) async fn render_user_profile(
150 150 pub(super) async fn purchase_page(
151 151 State(state): State<AppState>,
152 152 session: Session,
153 - MaybeUser(maybe_user): MaybeUser,
153 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
154 154 Path(item_id): Path<String>,
155 155 Query(query): Query<PurchaseQuery>,
156 156 ) -> Result<impl IntoResponse> {
@@ -239,7 +239,7 @@ fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String {
239 239 pub(super) async fn receipt_page(
240 240 State(state): State<AppState>,
241 241 session: Session,
242 - MaybeUser(maybe_user): MaybeUser,
242 + MaybeUserVerified(maybe_user): MaybeUserVerified,
243 243 Path(transaction_id): Path<String>,
244 244 ) -> Result<impl IntoResponse> {
245 245 let csrf_token = get_csrf_token(&session).await;
@@ -293,7 +293,7 @@ pub(super) async fn receipt_page(
293 293 pub(super) async fn collection_page(
294 294 State(state): State<AppState>,
295 295 session: Session,
296 - MaybeUser(maybe_user): MaybeUser,
296 + MaybeUserVerified(maybe_user): MaybeUserVerified,
297 297 Path((username, slug)): Path<(String, String)>,
298 298 ) -> Result<impl IntoResponse> {
299 299 let csrf_token = get_csrf_token(&session).await;
@@ -7,7 +7,7 @@ use axum::{
7 7 use tower_sessions::Session;
8 8
9 9 use crate::{
10 - auth::{MaybeUser, SessionUser},
10 + auth::{MaybeUserVerified, SessionUser},
11 11 db::{self, FollowTargetType, ItemId, ItemType, Slug},
12 12 error::{AppError, Result},
13 13 helpers::get_csrf_token,
@@ -23,7 +23,7 @@ pub(in crate::routes::pages::public) async fn project_page(
23 23 State(state): State<AppState>,
24 24 session: Session,
25 25 headers: axum::http::HeaderMap,
26 - MaybeUser(maybe_user): MaybeUser,
26 + MaybeUserVerified(maybe_user): MaybeUserVerified,
27 27 Path(slug): Path<String>,
28 28 ) -> Result<Response> {
29 29 let csrf_token = get_csrf_token(&session).await;
@@ -8,7 +8,7 @@ use sqlx::PgPool;
8 8 use tower_sessions::Session;
9 9
10 10 use crate::{
11 - auth::MaybeUser,
11 + auth::MaybeUserUnverified,
12 12 constants,
13 13 db::{self, discover::DiscoverFilters, DiscoverSort, ItemType},
14 14 error::Result,
@@ -221,7 +221,7 @@ pub struct TagTreeQuery {
221 221 pub(super) async fn tag_tree(
222 222 State(state): State<AppState>,
223 223 session: Session,
224 - MaybeUser(maybe_user): MaybeUser,
224 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
225 225 Query(query): Query<TagTreeQuery>,
226 226 ) -> Result<impl IntoResponse> {
227 227 let csrf_token = get_csrf_token(&session).await;
@@ -291,7 +291,7 @@ pub(super) async fn tag_tree(
291 291 pub(super) async fn discover(
292 292 State(state): State<AppState>,
293 293 session: Session,
294 - MaybeUser(maybe_user): MaybeUser,
294 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
295 295 Query(query): Query<DiscoverQuery>,
296 296 ) -> Result<impl IntoResponse> {
297 297 let csrf_token = get_csrf_token(&session).await;
@@ -488,7 +488,7 @@ pub(super) async fn discover(
488 488 #[tracing::instrument(skip_all, name = "discover::discover_results")]
489 489 pub(super) async fn discover_results(
490 490 State(state): State<AppState>,
491 - MaybeUser(maybe_user): MaybeUser,
491 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
492 492 Query(query): Query<DiscoverQuery>,
493 493 ) -> Result<impl IntoResponse> {
494 494 let data = fetch_discover_data(&state.db, &query).await?;
@@ -8,7 +8,7 @@ use axum::{
8 8 use tower_sessions::Session;
9 9
10 10 use crate::{
11 - auth::MaybeUser,
11 + auth::MaybeUserUnverified,
12 12 error::{AppError, Result},
13 13 helpers::get_csrf_token,
14 14 templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate},
@@ -20,7 +20,7 @@ use crate::{
20 20 pub async fn docs_index(
21 21 State(state): State<AppState>,
22 22 session: Session,
23 - MaybeUser(maybe_user): MaybeUser,
23 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
24 24 ) -> Result<impl IntoResponse> {
25 25 let csrf_token = get_csrf_token(&session).await;
26 26
@@ -99,7 +99,7 @@ pub async fn docs_search_index(
99 99 pub async fn doc_page(
100 100 State(state): State<AppState>,
101 101 session: Session,
102 - MaybeUser(maybe_user): MaybeUser,
102 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
103 103 Path(slug): Path<String>,
104 104 ) -> Result<impl IntoResponse> {
105 105 let page = state.docs.get(&slug).ok_or(AppError::NotFound)?;
@@ -14,8 +14,8 @@ use serde::Deserialize;
14 14 use tower_sessions::Session;
15 15
16 16 use crate::{
17 - auth::{hash_password, login_user, track_session, AuthUser, MaybeUser, SessionUser},
18 - db::{self, Username},
17 + auth::{hash_password, login_user, track_session, AuthUser, MaybeUserUnverified, SessionUser},
18 + db::{self},
19 19 email,
20 20 error::{AppError, Result},
21 21 helpers::{get_csrf_token, is_htmx_request},
@@ -38,7 +38,7 @@ pub struct JoinQuery {
38 38 #[tracing::instrument(skip_all, name = "join_wizard::page")]
39 39 pub async fn wizard_page(
40 40 session: Session,
41 - MaybeUser(maybe_user): MaybeUser,
41 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
42 42 Query(query): Query<JoinQuery>,
43 43 ) -> Response {
44 44 if maybe_user.is_some() {
@@ -55,7 +55,7 @@ pub async fn wizard_page(
55 55 /// Form input for account creation (step 1).
56 56 #[derive(Debug, Deserialize)]
57 57 pub struct AccountForm {
58 - pub username: Username,
58 + pub username: String,
59 59 pub email: String,
60 60 pub password: String,
61 61 pub invite_code: Option<String>,
@@ -94,6 +94,18 @@ pub async fn step_account_create(
94 94 }
95 95 };
96 96
97 + // Validate username in-handler so field-aware error UX is reachable. If
98 + // `username` were typed as `Username` on `AccountForm`, axum would 400 in
99 + // form extraction and the form would be blanked out before this handler
100 + // ever ran.
101 + let username = match db::Username::new(&form.username) {
102 + Ok(u) => u,
103 + Err(e) => {
104 + let msg = e.to_string();
105 + return return_error(&msg, &[("username", msg.as_str())]);
106 + }
107 + };
108 +
97 109 // Validate and normalize email
98 110 let email = match db::Email::new(&form.email) {
99 111 Ok(e) => e,
@@ -104,7 +116,7 @@ pub async fn step_account_create(
104 116 };
105 117
106 118 // Check uniqueness
107 - let username_taken = db::users::get_user_by_username(&state.db, &form.username)
119 + let username_taken = db::users::get_user_by_username(&state.db, &username)
108 120 .await?
109 121 .is_some();
110 122 let email_taken = db::users::get_user_by_email(&state.db, &email)
@@ -163,7 +175,7 @@ pub async fn step_account_create(
163 175 // Hash password and create user
164 176 let password_hash = hash_password(&form.password)?;
165 177 let user =
166 - db::users::create_user(&state.db, &form.username, &email, &password_hash).await?;
178 + db::users::create_user(&state.db, &username, &email, &password_hash).await?;
167 179
168 180 // Process invite code (if provided and valid)
169 181 if let Some(ref code_raw) = form.invite_code {
@@ -9,7 +9,7 @@ use serde::Deserialize;
9 9 use tower_sessions::Session;
10 10
11 11 use crate::{
12 - auth::{AuthUser, MaybeUser},
12 + auth::{AuthUser, MaybeUserUnverified},
13 13 constants,
14 14 db,
15 15 error::{AppError, Result},
@@ -30,7 +30,7 @@ pub(super) async fn index(
30 30 State(state): State<AppState>,
31 31 headers: HeaderMap,
32 32 session: Session,
33 - MaybeUser(maybe_user): MaybeUser,
33 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
34 34 ) -> Result<Response> {
35 35 // Check for custom domain — delegate to the custom domain handler
36 36 if let Some(response) =
@@ -386,7 +386,7 @@ pub(super) async fn checkout_complete() -> impl IntoResponse {
386 386 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
387 387 pub(super) async fn use_cases_page(
388 388 session: Session,
389 - MaybeUser(maybe_user): MaybeUser,
389 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
390 390 ) -> impl IntoResponse {
391 391 UseCasesTemplate {
392 392 csrf_token: get_csrf_token(&session).await,
@@ -398,7 +398,7 @@ pub(super) async fn use_cases_page(
398 398 #[tracing::instrument(skip_all, name = "landing::team_page")]
399 399 pub(super) async fn team_page(
400 400 session: Session,
401 - MaybeUser(maybe_user): MaybeUser,
401 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
402 402 ) -> impl IntoResponse {
403 403 TeamTemplate {
404 404 csrf_token: get_csrf_token(&session).await,
@@ -410,7 +410,7 @@ pub(super) async fn team_page(
410 410 #[tracing::instrument(skip_all, name = "landing::policy_page")]
411 411 pub(super) async fn policy_page(
412 412 session: Session,
413 - MaybeUser(maybe_user): MaybeUser,
413 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
414 414 ) -> impl IntoResponse {
415 415 let csrf_token = get_csrf_token(&session).await;
416 416 PolicyTemplate {
@@ -431,7 +431,7 @@ pub(super) struct FanPlusQuery {
431 431 pub(super) async fn fan_plus_page(
432 432 State(state): State<AppState>,
433 433 session: Session,
434 - MaybeUser(maybe_user): MaybeUser,
434 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
435 435 Query(query): Query<FanPlusQuery>,
436 436 ) -> Result<impl IntoResponse> {
437 437 let csrf_token = get_csrf_token(&session).await;
@@ -18,7 +18,7 @@ use axum::{
18 18 use tower_sessions::Session;
19 19
20 20 use crate::{
21 - auth::MaybeUser,
21 + auth::MaybeUserUnverified,
22 22 constants,
23 23 db,
24 24 error::Result,
@@ -96,7 +96,7 @@ pub fn public_routes() -> Router<AppState> {
96 96 async fn creators_page(
97 97 State(state): State<AppState>,
98 98 session: Session,
99 - MaybeUser(maybe_user): MaybeUser,
99 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
100 100 ) -> Result<impl IntoResponse> {
101 101 let csrf_token = get_csrf_token(&session).await;
102 102
@@ -8,7 +8,7 @@ use axum::{
8 8 use serde::Serialize;
9 9
10 10 use crate::{
11 - auth::MaybeUser,
11 + auth::MaybeUserVerified,
12 12 db::{self, ContentData, ItemId, VersionId},
13 13 error::{AppError, Result, ResultExt},
14 14 pricing,
@@ -59,7 +59,7 @@ async fn resolve_content_url(
59 59 #[tracing::instrument(skip_all, name = "storage::stream_url", fields(item_id))]
60 60 pub(super) async fn stream_url(
61 61 State(state): State<AppState>,
62 - MaybeUser(maybe_user): MaybeUser,
62 + MaybeUserVerified(maybe_user): MaybeUserVerified,
63 63 Path(item_id): Path<ItemId>,
64 64 ) -> Result<impl IntoResponse> {
65 65 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
@@ -146,7 +146,7 @@ pub(super) async fn stream_url(
146 146 #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))]
147 147 pub(super) async fn version_download(
148 148 State(state): State<AppState>,
149 - MaybeUser(maybe_user): MaybeUser,
149 + MaybeUserVerified(maybe_user): MaybeUserVerified,
150 150 Path(version_id): Path<VersionId>,
151 151 ) -> Result<impl IntoResponse> {
152 152 tracing::Span::current().record("version_id", tracing::field::display(&version_id));
@@ -58,7 +58,7 @@ pub struct CancelQuery {
58 58 pub(super) async fn checkout_success(
59 59 State(state): State<AppState>,
60 60 session: Session,
61 - crate::auth::MaybeUser(maybe_user): crate::auth::MaybeUser,
61 + crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified,
62 62 Query(query): Query<SuccessQuery>,
63 63 ) -> impl IntoResponse {
64 64 if let Some(session_id) = &query.session_id {