Signed by hanna SSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/store/src/lib.rs +2 −0 crates/store/src/sessions.rs +127 −0 crates/store/src/tests.rs +56 −0 crates/store/src/users.rs +2 −2 crates/store/src/lib.rs +2 −0 Expand 28 hidden lines 29 29 mod invites; 30 30 mod keys; 31 31 mod repos; 32 + mod sessions; 32 33 mod tokens; 33 34 mod users; 34 35 Expand 8 hidden lines 43 44 pub use crate :: invites:: NewInvite ; 44 45 pub use crate :: keys:: NewKey ; 45 46 pub use crate :: repos:: NewRepo ; 47 + pub use crate :: sessions:: NewSession ; 46 48 pub use crate :: tokens:: NewToken ; 47 49 pub use crate :: users:: NewUser ; 48 50
crates/store/src/sessions.rs +127 −0 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + use model:: User ; 12 + use sqlx:: Row ; 13 + 14 + use crate :: users:: USER_COLUMNS ; 15 + use crate :: { Store , StoreError , now_ms} ; 16 + 17 + 18 + # [ derive ( Debug , Clone ) ] 19 + pub struct NewSession { 20 + 21 + pub id: String , 22 + 23 + pub user_id: String , 24 + 25 + pub user_agent: Option < String > , 26 + 27 + pub ip: Option < String > , 28 + 29 + pub expires_at: i64 , 30 + } 31 + 32 + impl Store { 33 + 34 + 35 + 36 + 37 + 38 + pub async fn create_session ( & self , new : NewSession ) -> Result < ( ) , StoreError > { 39 + let now = now_ms ( ) ; 40 + sqlx:: query ( 41 + "INSERT INTO sessions (id, user_id, user_agent, ip, created_at, last_seen_at, expires_at) \ 42 + VALUES ($1, $2, $3, $4, $5, $5, $6)" , 43 + ) 44 + . bind ( new. id) 45 + . bind ( new. user_id) 46 + . bind ( new. user_agent) 47 + . bind ( new. ip) 48 + . bind ( now) 49 + . bind ( new. expires_at) 50 + . execute ( & self . pool) 51 + . await ?; 52 + Ok ( ( ) ) 53 + } 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + pub async fn user_for_session ( & self , session_id : & str ) -> Result < Option < User > , StoreError > { 64 + let sql = format ! ( 65 + "SELECT {cols} FROM users u JOIN sessions s ON s.user_id = u.id \ 66 + WHERE s.id = $1 AND s.expires_at > $2 AND u.disabled_at IS NULL" , 67 + cols = USER_COLUMNS 68 + . split ( ", " ) 69 + . map ( |c| format ! ( "u.{c}" ) ) 70 + . collect :: < Vec < _ > > ( ) 71 + . join ( ", " ) , 72 + ) ; 73 + let row = sqlx:: query ( sqlx:: AssertSqlSafe ( sql) ) 74 + . bind ( session_id) 75 + . bind ( now_ms ( ) ) 76 + . fetch_optional ( & self . pool) 77 + . await ?; 78 + Ok ( row. as_ref ( ) . map ( |r| self . map_user ( r) ) . transpose ( ) ?) 79 + } 80 + 81 + 82 + 83 + 84 + 85 + 86 + pub async fn touch_session ( & self , session_id : & str ) -> Result < ( ) , StoreError > { 87 + let now = now_ms ( ) ; 88 + sqlx:: query ( 89 + "UPDATE sessions SET last_seen_at = $1 \ 90 + WHERE id = $2 AND last_seen_at < $3" , 91 + ) 92 + . bind ( now) 93 + . bind ( session_id) 94 + . bind ( now - 60_000 ) 95 + . execute ( & self . pool) 96 + . await ?; 97 + Ok ( ( ) ) 98 + } 99 + 100 + 101 + 102 + 103 + 104 + 105 + pub async fn delete_session ( & self , session_id : & str ) -> Result < bool , StoreError > { 106 + let deleted = sqlx:: query ( "DELETE FROM sessions WHERE id = $1" ) 107 + . bind ( session_id) 108 + . execute ( & self . pool) 109 + . await ? 110 + . rows_affected ( ) ; 111 + Ok ( deleted > 0 ) 112 + } 113 + 114 + 115 + 116 + 117 + 118 + 119 + pub async fn delete_expired_sessions ( & self ) -> Result < u64 , StoreError > { 120 + let deleted = sqlx:: query ( "DELETE FROM sessions WHERE expires_at <= $1" ) 121 + . bind ( now_ms ( ) ) 122 + . execute ( & self . pool) 123 + . await ? 124 + . rows_affected ( ) ; 125 + Ok ( deleted) 126 + } 127 + }
crates/store/src/tests.rs +56 −0 Expand 732 hidden lines 733 733 } 734 734 735 735 # [ tokio :: test ] 736 + async fn sessions_resolve_the_user_and_respect_expiry ( ) { 737 + use super :: NewSession ; 738 + 739 + for fx in sqlite_fixtures ( ) . await { 740 + let user = fx 741 + . store 742 + . create_user ( sample_user ( "s" , "s@example.com" ) ) 743 + . await 744 + . unwrap ( ) ; 745 + 746 + fx. store 747 + . create_session ( NewSession { 748 + id: "sess-live" . to_string ( ) , 749 + user_id: user. id. clone ( ) , 750 + user_agent: Some ( "curl" . to_string ( ) ) , 751 + ip: None , 752 + expires_at: now_plus_a_day ( ) , 753 + } ) 754 + . await 755 + . unwrap ( ) ; 756 + let resolved = fx. store. user_for_session ( "sess-live" ) . await . unwrap ( ) ; 757 + assert_eq ! ( resolved. map ( |u| u. id) , Some ( user. id. clone ( ) ) ) ; 758 + 759 + 760 + fx. store 761 + . create_session ( NewSession { 762 + id: "sess-dead" . to_string ( ) , 763 + user_id: user. id. clone ( ) , 764 + user_agent: None , 765 + ip: None , 766 + expires_at: 1 , 767 + } ) 768 + . await 769 + . unwrap ( ) ; 770 + assert ! ( 771 + fx. store 772 + . user_for_session ( "sess-dead" ) 773 + . await 774 + . unwrap ( ) 775 + . is_none ( ) 776 + ) ; 777 + assert_eq ! ( fx. store. delete_expired_sessions ( ) . await . unwrap ( ) , 1 ) ; 778 + 779 + 780 + assert ! ( fx. store. delete_session ( "sess-live" ) . await . unwrap ( ) ) ; 781 + assert ! ( 782 + fx. store 783 + . user_for_session ( "sess-live" ) 784 + . await 785 + . unwrap ( ) 786 + . is_none ( ) 787 + ) ; 788 + } 789 + } 790 + 791 + # [ tokio :: test ] 736 792 async fn invites_round_trip_and_are_single_use ( ) { 737 793 use super :: NewInvite ; 738 794
crates/store/src/users.rs +2 −2 Expand 11 hidden lines 12 12 13 13 14 14 15 - const USER_COLUMNS : & str = "id, username, email, display_name, password_hash, \ 15 + pub ( crate ) const USER_COLUMNS : & str = "id, username, email, display_name, password_hash, \ 16 16 must_change_password, is_admin, disabled_at, created_at, updated_at" ; 17 17 18 18 Expand 202 hidden lines 221 221 } 222 222 223 223 224 - fn map_user ( & self , row : & AnyRow ) -> Result < User , sqlx:: Error > { 224 + pub ( crate ) fn map_user ( & self , row : & AnyRow ) -> Result < User , sqlx:: Error > { 225 225 Ok ( User { 226 226 id: row. try_get ( "id" ) ?, 227 227 username: row. try_get ( "username" ) ?,