Skip to main content

linkrs_server/http/handlers/
session.rs

1use axum::{
2    extract::{Extension, Json, Path, State},
3    http::StatusCode,
4    response::Json as JsonResponse,
5};
6use linkrs_wire::meta::{CreateSessionRequest, SessionResponse};
7
8use crate::http::{error::HttpError, state::AppState};
9use crate::storage::{
10    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
11};
12
13#[utoipa::path(
14    post,
15    operation_id = "post_v1_sessions",
16    path = "/v1/sessions",
17    tag = "Session",
18    request_body = CreateSessionRequest,
19    responses(
20        (status = 200, body = SessionResponse, description = "Session created"),
21        (status = 401, description = "Invalid credentials"),
22        (status = 403, description = "Account locked"),
23        (status = 500, description = "Internal error")
24    )
25)]
26pub async fn create<
27    S: StorageClient
28        + StorageSchemaContextOps
29        + StorageSyncContextOps
30        + StorageOperationContextOps
31        + Clone
32        + Send
33        + Sync
34        + 'static,
35>(
36    State(state): State<AppState<S>>,
37    Json(request): Json<CreateSessionRequest>,
38) -> Result<JsonResponse<SessionResponse>, HttpError> {
39    let graph_service = state.server.get_graph_service();
40
41    // Verify account is not disabled (checked regardless of auth mode).
42    if graph_service.is_user_locked(&request.username) {
43        return Err(HttpError::forbidden(format!(
44            "account {} is locked",
45            request.username
46        )));
47    }
48
49    // When authentication is active this endpoint must verify the password
50    // before issuing a session — the handler no longer bypasses the
51    // authenticator. Authentication already creates the session, so it is
52    // reused directly instead of creating a second one.
53    if !graph_service.is_auth_disabled() {
54        let password = request
55            .password
56            .as_deref()
57            .filter(|p| !p.is_empty())
58            .ok_or_else(|| HttpError::unauthorized("password required"))?;
59
60        let session = graph_service
61            .authenticate(&request.username, password)
62            .await
63            .map_err(|message| {
64                let lowered = message.to_lowercase();
65                if lowered.contains("locked") {
66                    HttpError::forbidden(message)
67                } else {
68                    HttpError::unauthorized(message)
69                }
70            })?;
71        return Ok(JsonResponse(SessionResponse {
72            session_id: session.id(),
73            username: session.user(),
74            created_at: std::time::SystemTime::now()
75                .duration_since(std::time::UNIX_EPOCH)
76                .expect("SystemTime before UNIX_EPOCH")
77                .as_secs(),
78        }));
79    }
80
81    let session_manager = state.server.get_session_manager();
82    let session = session_manager
83        .create_session(request.username.clone(), request.client_ip)
84        .await
85        .map_err(|e| HttpError::BadRequest(format!("Failed to create session: {}", e)))?;
86
87    Ok(JsonResponse(SessionResponse {
88        session_id: session.id(),
89        username: session.user(),
90        created_at: std::time::SystemTime::now()
91            .duration_since(std::time::UNIX_EPOCH)
92            .expect("SystemTime before UNIX_EPOCH")
93            .as_secs(),
94    }))
95}
96
97#[utoipa::path(
98    get,
99    operation_id = "get_v1_sessions",
100    path = "/v1/sessions",
101    tag = "Session",
102    responses(
103        (status = 200, body = serde_json::Value, description = "Session list"),
104        (status = 403, description = "Forbidden"),
105        (status = 500, description = "Internal error")
106    )
107)]
108pub async fn list_sessions<
109    S: StorageClient
110        + StorageSchemaContextOps
111        + StorageSyncContextOps
112        + StorageOperationContextOps
113        + Clone
114        + Send
115        + Sync
116        + 'static,
117>(
118    State(state): State<AppState<S>>,
119    Extension(session_id): Extension<i64>,
120) -> Result<JsonResponse<serde_json::Value>, HttpError> {
121    super::authz::require_admin_session(&state, session_id)?;
122    let sessions = state.server.get_graph_service().list_sessions().await;
123    let items: Vec<serde_json::Value> = sessions
124        .into_iter()
125        .map(|info| {
126            serde_json::json!({
127                "session_id": info.session_id,
128                "username": info.user_name,
129                "space_name": info.space_name,
130                "graph_addr": info.graph_addr,
131                "active_queries": info.active_queries,
132            })
133        })
134        .collect();
135    Ok(JsonResponse(serde_json::json!({ "sessions": items })))
136}
137
138#[utoipa::path(
139    get,
140    path = "/v1/sessions/{id}",
141    tag = "Session",
142    params(("id" = i64, Path, description = "Session id")),
143    responses(
144        (status = 200, body = serde_json::Value, description = "Session details"),
145        (status = 404, description = "Not found"),
146        (status = 500, description = "Not found")
147    )
148)]
149pub async fn get_session<
150    S: StorageClient
151        + StorageSchemaContextOps
152        + StorageSyncContextOps
153        + StorageOperationContextOps
154        + Clone
155        + Send
156        + Sync
157        + 'static,
158>(
159    State(state): State<AppState<S>>,
160    Extension(caller_session_id): Extension<i64>,
161    Path(session_id): Path<i64>,
162) -> Result<JsonResponse<serde_json::Value>, HttpError> {
163    let caller = super::authz::find_session(&state, caller_session_id)?;
164    let graph_service = state.server.get_graph_service();
165    let session_manager = state.server.get_session_manager();
166    let session = session_manager
167        .find_session(session_id)
168        .ok_or_else(|| HttpError::NotFound("Session not found".to_string()))?;
169    let is_admin = graph_service
170        .get_permission_manager()
171        .is_admin(&caller.user());
172    if !is_admin && session.user() != caller.user() {
173        return Err(HttpError::forbidden("Admin permission required"));
174    }
175
176    Ok(JsonResponse(serde_json::json!({
177        "session_id": session.id(),
178        "username": session.user(),
179        "space_name": session.space_name(),
180        "graph_addr": session.graph_addr(),
181        "timezone": session.timezone(),
182    })))
183}
184
185#[utoipa::path(
186    delete,
187    path = "/v1/sessions/{id}",
188    tag = "Session",
189    params(("id" = i64, Path, description = "Session id")),
190    responses(
191        (status = 204, description = "Session deleted"),
192        (status = 500, description = "Internal error")
193    )
194)]
195pub async fn delete_session<
196    S: StorageClient
197        + StorageSchemaContextOps
198        + StorageSyncContextOps
199        + StorageOperationContextOps
200        + Clone
201        + Send
202        + Sync
203        + 'static,
204>(
205    State(state): State<AppState<S>>,
206    Extension(caller_session_id): Extension<i64>,
207    Path(session_id): Path<i64>,
208) -> Result<StatusCode, HttpError> {
209    let caller = super::authz::find_session(&state, caller_session_id)?;
210    state
211        .server
212        .get_graph_service()
213        .kill_session(session_id, &caller.user())
214        .await
215        .map_err(|e| {
216            let msg = e.to_string().to_lowercase();
217            if msg.contains("not found") {
218                HttpError::NotFound(e.to_string())
219            } else if msg.contains("permission") {
220                HttpError::forbidden(e.to_string())
221            } else {
222                HttpError::BadRequest(e.to_string())
223            }
224        })?;
225    Ok(StatusCode::NO_CONTENT)
226}