Skip to main content

haystack_server/ops/
about.rs

1//! The `about` op — server identity and SCRAM authentication handshake.
2//!
3//! `GET /api/about` is dual-purpose:
4//! - **Unauthenticated** (HELLO / SCRAM): drives the three-phase SCRAM
5//!   SHA-256 handshake.
6//! - **Authenticated** (BEARER token): returns the server about grid.
7//!
8//! `POST /api/close` revokes the bearer token (logout).
9
10use axum::extract::State;
11use axum::http::{HeaderMap, StatusCode};
12use axum::response::{IntoResponse, Response};
13
14use haystack_core::auth::{AuthHeader, parse_auth_header};
15use haystack_core::data::{HCol, HDict, HGrid};
16use haystack_core::kinds::Kind;
17
18use crate::content;
19use crate::error::error_grid;
20use crate::state::SharedState;
21
22/// Build a haystack response from body bytes and content type.
23fn haystack_response(body: Vec<u8>, content_type: &str) -> Response {
24    (
25        [(axum::http::header::CONTENT_TYPE, content_type.to_string())],
26        body,
27    )
28        .into_response()
29}
30
31/// GET /api/about
32pub async fn handle(State(state): State<SharedState>, headers: HeaderMap) -> Response {
33    let accept = headers
34        .get("Accept")
35        .and_then(|v| v.to_str().ok())
36        .unwrap_or("");
37
38    // If auth is not enabled, just return the about grid
39    if !state.auth.is_enabled() {
40        return respond_about_grid(accept);
41    }
42
43    let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok());
44
45    match auth_header {
46        None => {
47            // No auth header: return 401 prompting for HELLO
48            (
49                StatusCode::UNAUTHORIZED,
50                [("WWW-Authenticate", "HELLO")],
51                "Authentication required",
52            )
53                .into_response()
54        }
55        Some(header) => match parse_auth_header(header) {
56            Ok(AuthHeader::Hello { username, data }) => {
57                match state.auth.handle_hello(&username, data.as_deref()) {
58                    Ok(www_auth) => (
59                        StatusCode::UNAUTHORIZED,
60                        [("WWW-Authenticate", www_auth.as_str())],
61                        "",
62                    )
63                        .into_response(),
64                    Err(e) => {
65                        log::warn!("HELLO failed for {username}: {e}");
66                        let grid = error_grid(&format!("authentication failed: {e}"));
67                        respond_error_grid(&grid, accept, StatusCode::FORBIDDEN)
68                    }
69                }
70            }
71            Ok(AuthHeader::Scram {
72                handshake_token,
73                data,
74            }) => match state.auth.handle_scram(&handshake_token, &data) {
75                Ok((_auth_token, auth_info)) => (
76                    StatusCode::OK,
77                    [("Authentication-Info", auth_info.as_str())],
78                    "",
79                )
80                    .into_response(),
81                Err(e) => {
82                    log::warn!("SCRAM verification failed: {e}");
83                    let grid = error_grid("authentication failed");
84                    respond_error_grid(&grid, accept, StatusCode::FORBIDDEN)
85                }
86            },
87            Ok(AuthHeader::Bearer { auth_token }) => match state.auth.validate_token(&auth_token) {
88                Some(_user) => respond_about_grid(accept),
89                None => {
90                    let grid = error_grid("invalid or expired auth token");
91                    respond_error_grid(&grid, accept, StatusCode::UNAUTHORIZED)
92                }
93            },
94            Err(e) => {
95                log::warn!("Invalid Authorization header: {e}");
96                (
97                    StatusCode::BAD_REQUEST,
98                    format!("Invalid Authorization header: {e}"),
99                )
100                    .into_response()
101            }
102        },
103    }
104}
105
106/// POST /api/close — revoke the bearer token (logout).
107pub async fn handle_close(State(state): State<SharedState>, headers: HeaderMap) -> Response {
108    let accept = headers
109        .get("Accept")
110        .and_then(|v| v.to_str().ok())
111        .unwrap_or("");
112
113    // Find the token from the Authorization header and revoke it
114    if let Some(auth_header) = headers.get("Authorization").and_then(|v| v.to_str().ok())
115        && let Ok(AuthHeader::Bearer { auth_token }) = parse_auth_header(auth_header)
116    {
117        state.auth.revoke_token(&auth_token);
118        log::info!("User logged out");
119    }
120
121    // Return empty grid
122    let grid = HGrid::new();
123    match content::encode_response_grid(&grid, accept) {
124        Ok((body, ct)) => haystack_response(body, ct),
125        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response(),
126    }
127}
128
129/// Build and encode the about grid response.
130fn respond_about_grid(accept: &str) -> Response {
131    let mut row = HDict::new();
132    row.set("haystackVersion", Kind::Str("4.0".to_string()));
133    row.set("serverName", Kind::Str("rusty-haystack".to_string()));
134    row.set("serverVersion", Kind::Str("0.7.2".to_string()));
135    row.set("productName", Kind::Str("rusty-haystack".to_string()));
136    row.set(
137        "productUri",
138        Kind::Uri(haystack_core::kinds::Uri::new(
139            "https://github.com/jscott3201/rusty-haystack",
140        )),
141    );
142    row.set("moduleName", Kind::Str("haystack-server".to_string()));
143    row.set("moduleVersion", Kind::Str("0.7.2".to_string()));
144
145    let cols = vec![
146        HCol::new("haystackVersion"),
147        HCol::new("serverName"),
148        HCol::new("serverVersion"),
149        HCol::new("productName"),
150        HCol::new("productUri"),
151        HCol::new("moduleName"),
152        HCol::new("moduleVersion"),
153    ];
154
155    let grid = HGrid::from_parts(HDict::new(), cols, vec![row]);
156    match content::encode_response_grid(&grid, accept) {
157        Ok((body, ct)) => haystack_response(body, ct),
158        Err(e) => {
159            log::error!("Failed to encode about grid: {e}");
160            (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response()
161        }
162    }
163}
164
165/// Encode an error grid and return it as a Response.
166fn respond_error_grid(grid: &HGrid, accept: &str, status: StatusCode) -> Response {
167    match content::encode_response_grid(grid, accept) {
168        Ok((body, ct)) => (
169            status,
170            [(axum::http::header::CONTENT_TYPE, ct.to_string())],
171            body,
172        )
173            .into_response(),
174        Err(_) => (status, "error").into_response(),
175    }
176}