use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use std::sync::Arc;
use crate::{ApiError, AppState, HttpError};
fn check_pty_id(id: &str) -> Result<(), ApiError> {
let ok = id.starts_with("pty_") && id.len() > 4;
if ok {
return Ok(());
}
Err(ApiError {
status: StatusCode::BAD_REQUEST,
name: "BadRequest",
message: format!("Expected a string starting with \"pty\", got \"{id}\"\n at [\"ptyID\"]"),
})
}
fn not_found(id: &str) -> HttpError {
HttpError::TaggedData {
status: StatusCode::NOT_FOUND,
tag: "PtyNotFoundError",
fields: json!({"ptyID": id, "message": format!("PTY session not found: {id}")}),
}
}
pub async fn get_shells(State(st): State<Arc<AppState>>) -> Response {
axum::Json(st.pty.shells()).into_response()
}
pub async fn list(State(st): State<Arc<AppState>>) -> Response {
axum::Json(Value::Array(st.pty.list_running())).into_response()
}
pub async fn create(State(st): State<Arc<AppState>>, body: Option<axum::Json<Value>>) -> Response {
let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
match st.pty.create(&input) {
Ok(info) => axum::Json(info).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"name": "UnknownError", "data": {"message": e}})),
)
.into_response(),
}
}
pub async fn get(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Response {
if let Err(e) = check_pty_id(&id) {
return e.into_response();
}
match st.pty.get_running(&id) {
Ok(info) => axum::Json(info).into_response(),
Err(_) => not_found(&id).into_response(),
}
}
pub async fn update(
Path(id): Path<String>,
State(st): State<Arc<AppState>>,
body: Option<axum::Json<Value>>,
) -> Response {
if let Err(e) = check_pty_id(&id) {
return e.into_response();
}
let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
match st.pty.update(&id, &input) {
Ok(info) => axum::Json(info).into_response(),
Err(_) => not_found(&id).into_response(),
}
}
pub async fn remove(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Response {
if let Err(e) = check_pty_id(&id) {
return e.into_response();
}
match st.pty.remove(&id) {
Ok(v) => axum::Json(v).into_response(),
Err(_) => not_found(&id).into_response(),
}
}
pub async fn connect_token(
Path(id): Path<String>,
State(st): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
if let Err(e) = check_pty_id(&id) {
return e.into_response();
}
let marker_ok = headers
.get("x-opencode-ticket")
.and_then(|v| v.to_str().ok())
== Some("1");
let origin_ok = match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
None => true, Some(o) => {
crate::ui::is_allowed_cors_origin(o, &st.cors_extra)
|| headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(|h| o.ends_with(h))
.unwrap_or(false)
}
};
if !marker_ok || !origin_ok {
return (HttpError::TaggedData {
status: StatusCode::FORBIDDEN,
tag: "PtyForbiddenError",
fields: json!({"message": "Invalid PTY connect token request"}),
})
.into_response();
}
if st.pty.get_running(&id).is_err() {
return not_found(&id).into_response();
}
axum::Json(st.pty.issue_ticket(&id)).into_response()
}
#[derive(serde::Deserialize)]
pub struct ConnectQuery {
pub cursor: Option<String>,
pub ticket: Option<String>,
#[allow(dead_code)]
pub directory: Option<String>,
#[allow(dead_code)]
pub workspace: Option<String>,
}
pub async fn connect(
Path(id): Path<String>,
Query(q): Query<ConnectQuery>,
State(st): State<Arc<AppState>>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Response {
if let Err(e) = check_pty_id(&id) {
return e.into_response();
}
if let Some(ticket) = q.ticket.as_deref() {
let origin_ok = match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
None => true,
Some(o) => crate::ui::is_allowed_cors_origin(o, &st.cors_extra),
};
if !origin_ok || !st.pty.consume_ticket(&id, ticket) {
return StatusCode::FORBIDDEN.into_response();
}
} else {
if let Some(o) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
&& !crate::ui::is_allowed_cors_origin(o, &st.cors_extra)
{
return StatusCode::FORBIDDEN.into_response();
}
}
let cursor = q.cursor.and_then(|c| c.parse::<i64>().ok());
match st.pty.attach(&id, cursor) {
Err(_) => {
ws.on_upgrade(move |mut socket| async move {
let _ = socket
.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: 4404,
reason: "session not found".into(),
})))
.await;
})
}
Ok(attachment) => {
let st2 = st.clone();
let id2 = id.clone();
ws.on_upgrade(move |socket| pty_ws_loop(socket, st2, id2, attachment))
}
}
}
async fn pty_ws_loop(
mut socket: WebSocket,
st: Arc<AppState>,
id: String,
mut attachment: ocserve_pty::Attachment,
) {
const REPLAY_CHUNK: usize = 64 * 1024;
let replay = attachment.replay.clone();
let mut rest = replay.as_str();
while !rest.is_empty() {
let cut = floor_char_boundary(rest, REPLAY_CHUNK);
if socket
.send(Message::Text(rest[..cut].to_string().into()))
.await
.is_err()
{
st.pty.detach(&id, attachment.sub_id);
return;
}
rest = &rest[cut..];
}
let meta = format!("\u{0}{}", json!({"cursor": attachment.cursor}));
if socket.send(Message::Text(meta.into())).await.is_err() {
st.pty.detach(&id, attachment.sub_id);
return;
}
loop {
tokio::select! {
msg = attachment.rx.recv() => match msg {
Some(ocserve_pty::Msg::Data(chunk)) => {
if socket.send(Message::Text(chunk.into())).await.is_err() { break; }
}
Some(ocserve_pty::Msg::End { .. }) | None => {
let _ = socket.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: 1000, reason: "".into(),
}))).await;
break;
}
},
inbound = socket.recv() => match inbound {
Some(Ok(Message::Text(t))) => attachment_write(&st, &id, t.as_str()),
Some(Ok(Message::Binary(b))) => {
attachment_write(&st, &id, &String::from_utf8_lossy(&b));
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {}
Some(Err(_)) => break,
},
}
}
st.pty.detach(&id, attachment.sub_id);
}
fn attachment_write(st: &Arc<AppState>, id: &str, data: &str) {
st.pty.write_session(id, data);
}
fn floor_char_boundary(s: &str, want: usize) -> usize {
if want >= s.len() {
return s.len();
}
let mut end = want;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
end
}