Skip to main content

rws_server/session/
mod.rs

1//! Server-side session management.
2//!
3//! Three session store implementations are provided:
4//!
5//! * [`SessionStore`] — in-process `HashMap`; fast, zero-config, but sessions
6//!   are lost on restart. Good for single-instance deployments.
7//! * [`DbSessionStore`] — backed by the model-layer [`DbPool`]; sessions
8//!   survive restarts and are shared across multiple processes that use the
9//!   same database. Requires a `model-sqlite`, `model-postgres`, or
10//!   `model-mysql` feature.
11//! * [`RedisSessionStore`] — backed by a Redis server via a hand-rolled RESP
12//!   client; scales horizontally with no shared database needed. Requires a
13//!   running Redis server.
14//!
15//! All three expose the same public API: `create`, `create_with_id`, `load`,
16//! `save`, `destroy`, `purge_expired`, `len`, `is_empty`.
17//!
18//! [`Session`] holds the key/value data for one session. Retrieve it with
19//! the store's `load`, mutate it, then persist changes with `save`.
20//!
21//! Helper functions [`session_id_from_request`], [`session_cookie`], and
22//! [`destroy_cookie`] translate between the HTTP cookie layer and the store.
23//!
24//! # Security note
25//!
26//! Session IDs are generated from a non-cryptographic hash of the system
27//! clock and an atomic counter. Sufficient for most internal applications.
28//! For public-facing services requiring unpredictable IDs, supply your own
29//! CSPRNG via [`SessionStore::create_with_id`].
30//!
31//! # Example
32//!
33//! ```rust,no_run
34//! # extern crate rws_server as rust_web_server;
35//! use rust_web_server::app::App;
36//! use rust_web_server::core::New;
37//! use rust_web_server::session::{self, SessionStore};
38//! use rust_web_server::header::Header;
39//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
40//!
41//! struct State { sessions: SessionStore }
42//!
43//! let app = App::with_state(State { sessions: SessionStore::new(3600) })
44//!     .post("/login", |req, _params, _conn, state| {
45//!         // verify credentials …
46//!         let mut sess = state.sessions.create();
47//!         sess.set("user_id", "42");
48//!         state.sessions.save(&sess);
49//!
50//!         let mut r = Response::new();
51//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
52//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
53//!         r.headers.push(Header::new("Set-Cookie", session::session_cookie(&sess.id, "sid", 3600)));
54//!         r
55//!     })
56//!     .get("/profile", |req, _params, _conn, state| {
57//!         let mut r = Response::new();
58//!         let sid = match session::session_id_from_request(&req, "sid") {
59//!             Some(id) => id,
60//!             None => {
61//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
62//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
63//!                 return r;
64//!             }
65//!         };
66//!         let sess = match state.sessions.load(&sid) {
67//!             Some(s) => s,
68//!             None => {
69//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
70//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
71//!                 return r;
72//!             }
73//!         };
74//!         let user_id = sess.get("user_id").unwrap_or("guest");
75//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
76//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
77//!         r
78//!     });
79//! ```
80
81#[cfg(test)]
82mod tests;
83
84use std::collections::HashMap;
85use std::sync::atomic::{AtomicU64, Ordering};
86use std::sync::{Arc, Mutex};
87use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
88
89use crate::cookie::{CookieJar, SetCookie};
90use crate::request::Request;
91
92// ── ID generation ─────────────────────────────────────────────────────────────
93
94static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
95
96fn generate_id() -> String {
97    let nanos = SystemTime::now()
98        .duration_since(UNIX_EPOCH)
99        .map(|d| d.as_nanos() as u64)
100        .unwrap_or(0);
101    let count = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
102    // splitmix64 finalizer applied to two independent seeds
103    let mut x = nanos ^ count.wrapping_mul(0x9e3779b97f4a7c15);
104    x ^= x >> 30;
105    x = x.wrapping_mul(0xbf58476d1ce4e5b9);
106    x ^= x >> 27;
107    x = x.wrapping_mul(0x94d049bb133111eb);
108    x ^= x >> 31;
109    let mut y = count ^ nanos.wrapping_mul(0x517cc1b727220a95);
110    y ^= y >> 30;
111    y = y.wrapping_mul(0xbf58476d1ce4e5b9);
112    y ^= y >> 27;
113    y = y.wrapping_mul(0x94d049bb133111eb);
114    y ^= y >> 31;
115    format!("{:016x}{:016x}", x, y)
116}
117
118// ── Session ───────────────────────────────────────────────────────────────────
119
120/// Data for a single session, keyed by [`Session::id`].
121///
122/// After mutating the session, call [`SessionStore::save`] to persist the
123/// changes back to the store.
124pub struct Session {
125    /// Opaque session identifier — store this in the client cookie.
126    pub id: String,
127    #[doc(hidden)]
128    pub data: HashMap<String, String>,
129}
130
131impl Session {
132    /// Return the value for `key`, or `None` if absent.
133    pub fn get(&self, key: &str) -> Option<&str> {
134        self.data.get(key).map(String::as_str)
135    }
136
137    /// Insert or update `key` with `value`.
138    pub fn set(&mut self, key: &str, value: impl Into<String>) {
139        self.data.insert(key.to_string(), value.into());
140    }
141
142    /// Remove `key`. No-op if absent.
143    pub fn remove(&mut self, key: &str) {
144        self.data.remove(key);
145    }
146
147    /// Return `true` if `key` is present.
148    pub fn contains(&self, key: &str) -> bool {
149        self.data.contains_key(key)
150    }
151}
152
153// ── SessionStore ──────────────────────────────────────────────────────────────
154
155struct Entry {
156    data: HashMap<String, String>,
157    expires_at: Instant,
158}
159
160struct Inner {
161    sessions: HashMap<String, Entry>,
162}
163
164/// Thread-safe in-memory session store with TTL-based expiry.
165///
166/// Cloning is cheap — all clones share the same backing map via `Arc`.
167/// Place one instance in your application state and share it across handlers.
168pub struct SessionStore {
169    inner: Arc<Mutex<Inner>>,
170    ttl: Duration,
171}
172
173impl Clone for SessionStore {
174    fn clone(&self) -> Self {
175        SessionStore { inner: Arc::clone(&self.inner), ttl: self.ttl }
176    }
177}
178
179impl SessionStore {
180    /// Create a new store where sessions expire `ttl_secs` seconds after
181    /// creation.
182    pub fn new(ttl_secs: u64) -> Self {
183        SessionStore {
184            inner: Arc::new(Mutex::new(Inner { sessions: HashMap::new() })),
185            ttl: Duration::from_secs(ttl_secs),
186        }
187    }
188
189    /// Create a new empty session with a generated ID, insert it into the
190    /// store, and return it. Mutate the session then call [`save`][Self::save].
191    pub fn create(&self) -> Session {
192        self.create_with_id(generate_id())
193    }
194
195    /// Create a new empty session using `id` (caller supplies the value,
196    /// e.g. from a CSPRNG). Inserts the session and returns it.
197    pub fn create_with_id(&self, id: String) -> Session {
198        let entry = Entry {
199            data: HashMap::new(),
200            expires_at: Instant::now() + self.ttl,
201        };
202        self.inner.lock().unwrap().sessions.insert(id.clone(), entry);
203        Session { id, data: HashMap::new() }
204    }
205
206    /// Load a session by ID. Returns `None` if unknown or expired.
207    pub fn load(&self, id: &str) -> Option<Session> {
208        let inner = self.inner.lock().unwrap();
209        let entry = inner.sessions.get(id)?;
210        if Instant::now() > entry.expires_at {
211            return None;
212        }
213        Some(Session { id: id.to_string(), data: entry.data.clone() })
214    }
215
216    /// Persist a session's data back to the store. No-op if the session ID
217    /// is no longer present (e.g. already destroyed or expired and purged).
218    pub fn save(&self, session: &Session) {
219        let mut inner = self.inner.lock().unwrap();
220        if let Some(entry) = inner.sessions.get_mut(&session.id) {
221            entry.data = session.data.clone();
222        }
223    }
224
225    /// Delete a session immediately. Also clear the client cookie using
226    /// [`destroy_cookie`].
227    pub fn destroy(&self, id: &str) {
228        self.inner.lock().unwrap().sessions.remove(id);
229    }
230
231    /// Remove all sessions whose TTL has elapsed. Call periodically to
232    /// reclaim memory (e.g. once per minute from a background thread).
233    pub fn purge_expired(&self) {
234        let now = Instant::now();
235        self.inner.lock().unwrap().sessions.retain(|_, e| e.expires_at > now);
236    }
237
238    /// Number of sessions in the store, including expired but not yet purged.
239    pub fn len(&self) -> usize {
240        self.inner.lock().unwrap().sessions.len()
241    }
242
243    /// `true` if the store contains no sessions.
244    pub fn is_empty(&self) -> bool {
245        self.len() == 0
246    }
247}
248
249// ── Cookie helpers ────────────────────────────────────────────────────────────
250
251/// Extract the session ID from the named cookie in a request's `Cookie`
252/// header. Returns `None` if the header is absent or the cookie is missing.
253pub fn session_id_from_request(request: &Request, cookie_name: &str) -> Option<String> {
254    let header = request.get_header("Cookie".to_string())?;
255    let jar = CookieJar::parse(&header.value);
256    jar.get(cookie_name).map(|c| c.value.clone())
257}
258
259/// Build a `Set-Cookie` header value that stores `session_id` in
260/// `cookie_name` with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
261///
262/// # Example
263///
264/// ```rust,no_run
265/// # extern crate rws_server as rust_web_server;
266/// use rust_web_server::{session, header::Header};
267///
268/// let value = session::session_cookie("abc123", "sid", 3600);
269/// // response.headers.push(Header::new("Set-Cookie", value));
270/// ```
271pub fn session_cookie(session_id: &str, cookie_name: &str, ttl_secs: u64) -> String {
272    SetCookie::new(cookie_name, session_id)
273        .path("/")
274        .http_only()
275        .same_site("Lax")
276        .max_age(ttl_secs as i64)
277        .build()
278}
279
280/// Build a `Set-Cookie` header value that clears `cookie_name` in the
281/// browser (`Max-Age=0`). Use after calling [`SessionStore::destroy`].
282pub fn destroy_cookie(cookie_name: &str) -> String {
283    SetCookie::new(cookie_name, "").path("/").max_age(0).build()
284}
285
286// ── DbSessionStore ────────────────────────────────────────────────────────────
287
288/// Session store backed by a relational database via the model-layer
289/// [`DbPool`][crate::model::DbPool].
290///
291/// Sessions survive process restarts and are visible to every process that
292/// connects to the same database, making this suitable for multi-instance
293/// deployments and zero-downtime restarts.
294///
295/// The first call to [`DbSessionStore::new`] creates the `rws_sessions` table
296/// if it does not already exist:
297///
298/// ```sql
299/// CREATE TABLE IF NOT EXISTS rws_sessions (
300///     id         TEXT    PRIMARY KEY,
301///     data       TEXT    NOT NULL DEFAULT '',
302///     expires_at INTEGER NOT NULL
303/// )
304/// ```
305///
306/// Session data is serialized as a URL-encoded string
307/// (`key1=val1&key2=val2`). Expired sessions are **not** removed
308/// automatically — call [`purge_expired`][DbSessionStore::purge_expired]
309/// periodically.
310///
311/// All methods are `async fn` — `model-*` features imply the `http2` feature
312/// which provides a tokio runtime.
313///
314/// # Example
315///
316/// ```rust,no_run
317/// # extern crate rws_server as rust_web_server;
318/// # #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
319/// # async fn example() -> Result<(), rust_web_server::model::DbError> {
320/// use rust_web_server::model::DbPool;
321/// use rust_web_server::session::DbSessionStore;
322///
323/// let pool = DbPool::memory().await?;
324/// let store = DbSessionStore::new(pool, 3600).await?;
325///
326/// let mut sess = store.create().await?;
327/// sess.set("user_id", "42");
328/// store.save(&sess).await?;
329///
330/// let loaded = store.load(&sess.id).await?.unwrap();
331/// assert_eq!(Some("42"), loaded.get("user_id"));
332/// # Ok(())
333/// # }
334/// ```
335#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
336pub struct DbSessionStore {
337    pool: Arc<crate::model::DbPool>,
338    ttl: Duration,
339}
340
341#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
342impl Clone for DbSessionStore {
343    fn clone(&self) -> Self {
344        DbSessionStore { pool: Arc::clone(&self.pool), ttl: self.ttl }
345    }
346}
347
348#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
349impl DbSessionStore {
350    /// Open (or reuse) a `DbSessionStore` backed by `pool`.
351    ///
352    /// Creates the `rws_sessions` table on the first call if it is absent.
353    /// Returns `Err` if the DDL fails.
354    pub async fn new(pool: crate::model::DbPool, ttl_secs: u64) -> Result<Self, crate::model::DbError> {
355        let store = DbSessionStore {
356            pool: Arc::new(pool),
357            ttl: Duration::from_secs(ttl_secs),
358        };
359        store.ensure_table().await?;
360        Ok(store)
361    }
362
363    async fn ensure_table(&self) -> Result<(), crate::model::DbError> {
364        self.pool.execute(
365            "CREATE TABLE IF NOT EXISTS rws_sessions \
366             (id TEXT PRIMARY KEY, data TEXT NOT NULL DEFAULT '', expires_at INTEGER NOT NULL)",
367            &[],
368        ).await?;
369        Ok(())
370    }
371
372    fn now_epoch() -> i64 {
373        SystemTime::now()
374            .duration_since(UNIX_EPOCH)
375            .map(|d| d.as_secs() as i64)
376            .unwrap_or(0)
377    }
378
379    fn serialize(data: &HashMap<String, String>) -> String {
380        crate::url::URL::build_query(data.clone())
381    }
382
383    fn deserialize(s: &str) -> HashMap<String, String> {
384        crate::url::URL::parse_query(s)
385    }
386
387    /// Create a new empty session and persist it immediately.
388    pub async fn create(&self) -> Result<Session, crate::model::DbError> {
389        self.create_with_id(generate_id()).await
390    }
391
392    /// Create a new empty session with a caller-supplied ID and persist it.
393    pub async fn create_with_id(&self, id: String) -> Result<Session, crate::model::DbError> {
394        let expires_at = Self::now_epoch() + self.ttl.as_secs() as i64;
395        self.pool.execute(
396            "INSERT INTO rws_sessions (id, data, expires_at) VALUES (?, ?, ?)",
397            &[
398                crate::model::Value::Text(id.clone()),
399                crate::model::Value::Text(String::new()),
400                crate::model::Value::Int(expires_at),
401            ],
402        ).await?;
403        Ok(Session { id, data: HashMap::new() })
404    }
405
406    /// Load a session by ID. Returns `None` if unknown or expired.
407    pub async fn load(&self, id: &str) -> Result<Option<Session>, crate::model::DbError> {
408        let now = Self::now_epoch();
409        let rows = self.pool.query_rows(
410            "SELECT data FROM rws_sessions WHERE id = ? AND expires_at > ?",
411            &[
412                crate::model::Value::Text(id.to_string()),
413                crate::model::Value::Int(now),
414            ],
415        ).await?;
416        if rows.is_empty() {
417            return Ok(None);
418        }
419        let raw: String = rows[0].get("data")?;
420        Ok(Some(Session { id: id.to_string(), data: Self::deserialize(&raw) }))
421    }
422
423    /// Persist a session's data back to the store.
424    pub async fn save(&self, session: &Session) -> Result<(), crate::model::DbError> {
425        self.pool.execute(
426            "UPDATE rws_sessions SET data = ? WHERE id = ?",
427            &[
428                crate::model::Value::Text(Self::serialize(&session.data)),
429                crate::model::Value::Text(session.id.clone()),
430            ],
431        ).await?;
432        Ok(())
433    }
434
435    /// Delete a session immediately.
436    pub async fn destroy(&self, id: &str) -> Result<(), crate::model::DbError> {
437        self.pool.execute(
438            "DELETE FROM rws_sessions WHERE id = ?",
439            &[crate::model::Value::Text(id.to_string())],
440        ).await?;
441        Ok(())
442    }
443
444    /// Delete all sessions whose TTL has elapsed.
445    pub async fn purge_expired(&self) -> Result<(), crate::model::DbError> {
446        let now = Self::now_epoch();
447        self.pool.execute(
448            "DELETE FROM rws_sessions WHERE expires_at <= ?",
449            &[crate::model::Value::Int(now)],
450        ).await?;
451        Ok(())
452    }
453
454    /// Total number of sessions in the store, including expired ones not yet purged.
455    pub async fn len(&self) -> Result<usize, crate::model::DbError> {
456        let rows = self.pool.query_rows("SELECT COUNT(*) AS n FROM rws_sessions", &[]).await?;
457        if rows.is_empty() {
458            return Ok(0);
459        }
460        let n: i64 = rows[0].get("n")?;
461        Ok(n as usize)
462    }
463
464    /// `true` if the store contains no sessions.
465    pub async fn is_empty(&self) -> Result<bool, crate::model::DbError> {
466        Ok(self.len().await? == 0)
467    }
468}
469
470// ── RedisSessionStore ─────────────────────────────────────────────────────────
471
472#[cfg(not(target_arch = "wasm32"))]
473use crate::redis_protocol::{RespConn, RespReply};
474
475/// Session store backed by a Redis server.
476///
477/// Sessions are stored as Redis strings keyed by `rws:sess:{id}` and given
478/// a Redis TTL via `SET … EX`. Expired sessions are removed automatically by
479/// Redis — no `purge_expired` sweep is needed.
480///
481/// Cloning is cheap — all clones share the same underlying TCP connection
482/// (one persistent connection per `RedisSessionStore` instance).
483///
484/// # Connection
485///
486/// Specify the server address as `host:port` (e.g. `"127.0.0.1:6379"`).
487/// Pass `Some("password")` for Redis servers that require AUTH.
488/// Use [`RedisSessionStore::from_env`] to read connection details from
489/// `RWS_REDIS_HOST`, `RWS_REDIS_PORT`, and `RWS_REDIS_PASSWORD`.
490///
491/// # Example
492///
493/// ```rust,no_run
494/// # extern crate rws_server as rust_web_server;
495/// use rust_web_server::session::RedisSessionStore;
496///
497/// let store = RedisSessionStore::new("127.0.0.1:6379", None, 3600);
498///
499/// let mut sess = store.create().expect("create session");
500/// sess.set("user_id", "42");
501/// store.save(&sess).expect("save session");
502///
503/// let loaded = store.load(&sess.id).expect("load session").unwrap();
504/// assert_eq!(Some("42"), loaded.get("user_id"));
505/// ```
506#[cfg(not(target_arch = "wasm32"))]
507pub struct RedisSessionStore {
508    conn: Arc<RespConn>,
509    ttl: u64,
510}
511
512#[cfg(not(target_arch = "wasm32"))]
513impl Clone for RedisSessionStore {
514    fn clone(&self) -> Self {
515        RedisSessionStore { conn: Arc::clone(&self.conn), ttl: self.ttl }
516    }
517}
518
519#[cfg(not(target_arch = "wasm32"))]
520impl RedisSessionStore {
521    /// Create a store that connects to `addr` (e.g. `"127.0.0.1:6379"`).
522    /// `password` is passed to Redis `AUTH` if `Some`.
523    pub fn new(addr: impl Into<String>, password: Option<String>, ttl_secs: u64) -> Self {
524        RedisSessionStore {
525            conn: Arc::new(RespConn::new(addr, password)),
526            ttl: ttl_secs,
527        }
528    }
529
530    /// Build a store from environment variables:
531    /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
532    /// - `RWS_REDIS_PORT` (default `6379`)
533    /// - `RWS_REDIS_PASSWORD` (optional)
534    /// - `RWS_REDIS_TTL_SECS` (default `3600`)
535    pub fn from_env() -> Self {
536        let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
537        let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
538        let addr = format!("{}:{}", host, port);
539        let password = std::env::var("RWS_REDIS_PASSWORD").ok();
540        let ttl = std::env::var("RWS_REDIS_TTL_SECS")
541            .ok()
542            .and_then(|v| v.parse().ok())
543            .unwrap_or(3600u64);
544        Self::new(addr, password, ttl)
545    }
546
547    fn key(id: &str) -> Vec<u8> {
548        format!("rws:sess:{}", id).into_bytes()
549    }
550
551    fn serialize(data: &HashMap<String, String>) -> Vec<u8> {
552        crate::url::URL::build_query(data.clone()).into_bytes()
553    }
554
555    fn deserialize(bytes: Vec<u8>) -> HashMap<String, String> {
556        let s = String::from_utf8(bytes).unwrap_or_default();
557        crate::url::URL::parse_query(&s)
558    }
559
560    /// Create a new empty session and persist it to Redis.
561    pub fn create(&self) -> std::io::Result<Session> {
562        self.create_with_id(generate_id())
563    }
564
565    /// Create a new empty session with a caller-supplied ID and persist it.
566    pub fn create_with_id(&self, id: String) -> std::io::Result<Session> {
567        let ttl_str = self.ttl.to_string();
568        self.conn.cmd(&[
569            b"SET",
570            &Self::key(&id),
571            b"",
572            b"EX",
573            ttl_str.as_bytes(),
574        ])?;
575        Ok(Session { id, data: HashMap::new() })
576    }
577
578    /// Load a session by ID. Returns `None` if unknown or expired.
579    pub fn load(&self, id: &str) -> std::io::Result<Option<Session>> {
580        match self.conn.cmd(&[b"GET", &Self::key(id)])? {
581            RespReply::Bulk(Some(bytes)) => {
582                Ok(Some(Session { id: id.to_string(), data: Self::deserialize(bytes) }))
583            }
584            _ => Ok(None),
585        }
586    }
587
588    /// Persist a session's data back to Redis, resetting the TTL.
589    pub fn save(&self, session: &Session) -> std::io::Result<()> {
590        let ttl_str = self.ttl.to_string();
591        let data = Self::serialize(&session.data);
592        self.conn.cmd(&[
593            b"SET",
594            &Self::key(&session.id),
595            &data,
596            b"EX",
597            ttl_str.as_bytes(),
598        ])?;
599        Ok(())
600    }
601
602    /// Delete a session immediately.
603    pub fn destroy(&self, id: &str) -> std::io::Result<()> {
604        self.conn.cmd(&[b"DEL", &Self::key(id)])?;
605        Ok(())
606    }
607
608    /// No-op — Redis expiry removes sessions automatically.
609    pub fn purge_expired(&self) {}
610
611    /// Total number of keys in the Redis database.
612    ///
613    /// Uses `DBSIZE`, which counts *all* keys, not just session keys.
614    /// Useful as a rough indicator; not exact for mixed-use Redis instances.
615    pub fn len(&self) -> std::io::Result<usize> {
616        match self.conn.cmd(&[b"DBSIZE"])? {
617            RespReply::Int(n) => Ok(n as usize),
618            _ => Ok(0),
619        }
620    }
621
622    /// `true` if `DBSIZE` returns 0.
623    pub fn is_empty(&self) -> std::io::Result<bool> {
624        Ok(self.len()? == 0)
625    }
626}