Skip to main content

haystack_server/ops/
about.rs

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