1use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
17use axum::extract::{Path, Query, State};
18use axum::http::{HeaderMap, StatusCode, header};
19use axum::response::{IntoResponse, Response};
20use serde_json::{Value, json};
21use std::sync::Arc;
22
23use crate::{ApiError, AppState, HttpError};
24
25fn check_pty_id(id: &str) -> Result<(), ApiError> {
28 let ok = id.starts_with("pty_") && id.len() > 4;
29 if ok {
30 return Ok(());
31 }
32 Err(ApiError {
33 status: StatusCode::BAD_REQUEST,
34 name: "BadRequest",
35 message: format!("Expected a string starting with \"pty\", got \"{id}\"\n at [\"ptyID\"]"),
36 })
37}
38
39fn not_found(id: &str) -> HttpError {
40 HttpError::TaggedData {
42 status: StatusCode::NOT_FOUND,
43 tag: "PtyNotFoundError",
44 fields: json!({"ptyID": id, "message": format!("PTY session not found: {id}")}),
45 }
46}
47
48pub async fn get_shells(State(st): State<Arc<AppState>>) -> Response {
49 axum::Json(st.pty.shells()).into_response()
50}
51
52pub async fn list(State(st): State<Arc<AppState>>) -> Response {
53 axum::Json(Value::Array(st.pty.list_running())).into_response()
54}
55
56pub async fn create(State(st): State<Arc<AppState>>, body: Option<axum::Json<Value>>) -> Response {
57 let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
58 match st.pty.create(&input) {
59 Ok(info) => axum::Json(info).into_response(),
60 Err(e) => (
61 StatusCode::INTERNAL_SERVER_ERROR,
62 axum::Json(json!({"name": "UnknownError", "data": {"message": e}})),
63 )
64 .into_response(),
65 }
66}
67
68pub async fn get(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Response {
69 if let Err(e) = check_pty_id(&id) {
70 return e.into_response();
71 }
72 match st.pty.get_running(&id) {
73 Ok(info) => axum::Json(info).into_response(),
74 Err(_) => not_found(&id).into_response(),
75 }
76}
77
78pub async fn update(
79 Path(id): Path<String>,
80 State(st): State<Arc<AppState>>,
81 body: Option<axum::Json<Value>>,
82) -> Response {
83 if let Err(e) = check_pty_id(&id) {
84 return e.into_response();
85 }
86 let input = body.map(|b| b.0).unwrap_or_else(|| json!({}));
87 match st.pty.update(&id, &input) {
88 Ok(info) => axum::Json(info).into_response(),
89 Err(_) => not_found(&id).into_response(),
90 }
91}
92
93pub async fn remove(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Response {
94 if let Err(e) = check_pty_id(&id) {
95 return e.into_response();
96 }
97 match st.pty.remove(&id) {
98 Ok(v) => axum::Json(v).into_response(),
99 Err(_) => not_found(&id).into_response(),
100 }
101}
102
103pub async fn connect_token(
107 Path(id): Path<String>,
108 State(st): State<Arc<AppState>>,
109 headers: HeaderMap,
110) -> Response {
111 if let Err(e) = check_pty_id(&id) {
112 return e.into_response();
113 }
114 let marker_ok = headers
115 .get("x-opencode-ticket")
116 .and_then(|v| v.to_str().ok())
117 == Some("1");
118 let origin_ok = match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
119 None => true, Some(o) => {
121 crate::ui::is_allowed_cors_origin(o, &st.cors_extra)
122 || headers
123 .get(header::HOST)
124 .and_then(|v| v.to_str().ok())
125 .map(|h| o.ends_with(h))
126 .unwrap_or(false)
127 }
128 };
129 if !marker_ok || !origin_ok {
130 return (HttpError::TaggedData {
131 status: StatusCode::FORBIDDEN,
132 tag: "PtyForbiddenError",
133 fields: json!({"message": "Invalid PTY connect token request"}),
134 })
135 .into_response();
136 }
137 if st.pty.get_running(&id).is_err() {
138 return not_found(&id).into_response();
139 }
140 axum::Json(st.pty.issue_ticket(&id)).into_response()
141}
142
143#[derive(serde::Deserialize)]
144pub struct ConnectQuery {
145 pub cursor: Option<String>,
146 pub ticket: Option<String>,
147 #[allow(dead_code)]
148 pub directory: Option<String>,
149 #[allow(dead_code)]
150 pub workspace: Option<String>,
151}
152
153pub async fn connect(
158 Path(id): Path<String>,
159 Query(q): Query<ConnectQuery>,
160 State(st): State<Arc<AppState>>,
161 headers: HeaderMap,
162 ws: WebSocketUpgrade,
163) -> Response {
164 if let Err(e) = check_pty_id(&id) {
165 return e.into_response();
166 }
167 if let Some(ticket) = q.ticket.as_deref() {
168 let origin_ok = match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
169 None => true,
170 Some(o) => crate::ui::is_allowed_cors_origin(o, &st.cors_extra),
171 };
172 if !origin_ok || !st.pty.consume_ticket(&id, ticket) {
173 return StatusCode::FORBIDDEN.into_response();
174 }
175 } else {
176 if let Some(o) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
178 && !crate::ui::is_allowed_cors_origin(o, &st.cors_extra)
179 {
180 return StatusCode::FORBIDDEN.into_response();
181 }
182 }
183 let cursor = q.cursor.and_then(|c| c.parse::<i64>().ok());
184 match st.pty.attach(&id, cursor) {
185 Err(_) => {
186 ws.on_upgrade(move |mut socket| async move {
188 let _ = socket
189 .send(Message::Close(Some(axum::extract::ws::CloseFrame {
190 code: 4404,
191 reason: "session not found".into(),
192 })))
193 .await;
194 })
195 }
196 Ok(attachment) => {
197 let st2 = st.clone();
198 let id2 = id.clone();
199 ws.on_upgrade(move |socket| pty_ws_loop(socket, st2, id2, attachment))
200 }
201 }
202}
203
204async fn pty_ws_loop(
205 mut socket: WebSocket,
206 st: Arc<AppState>,
207 id: String,
208 mut attachment: ocserve_pty::Attachment,
209) {
210 const REPLAY_CHUNK: usize = 64 * 1024;
212 let replay = attachment.replay.clone();
213 let mut rest = replay.as_str();
214 while !rest.is_empty() {
215 let cut = floor_char_boundary(rest, REPLAY_CHUNK);
216 if socket
217 .send(Message::Text(rest[..cut].to_string().into()))
218 .await
219 .is_err()
220 {
221 st.pty.detach(&id, attachment.sub_id);
222 return;
223 }
224 rest = &rest[cut..];
225 }
226 let meta = format!("\u{0}{}", json!({"cursor": attachment.cursor}));
228 if socket.send(Message::Text(meta.into())).await.is_err() {
229 st.pty.detach(&id, attachment.sub_id);
230 return;
231 }
232 loop {
234 tokio::select! {
235 msg = attachment.rx.recv() => match msg {
236 Some(ocserve_pty::Msg::Data(chunk)) => {
237 if socket.send(Message::Text(chunk.into())).await.is_err() { break; }
238 }
239 Some(ocserve_pty::Msg::End { .. }) | None => {
240 let _ = socket.send(Message::Close(Some(axum::extract::ws::CloseFrame {
241 code: 1000, reason: "".into(),
242 }))).await;
243 break;
244 }
245 },
246 inbound = socket.recv() => match inbound {
247 Some(Ok(Message::Text(t))) => attachment_write(&st, &id, t.as_str()),
248 Some(Ok(Message::Binary(b))) => {
249 attachment_write(&st, &id, &String::from_utf8_lossy(&b));
250 }
251 Some(Ok(Message::Close(_))) | None => break,
252 Some(Ok(_)) => {}
253 Some(Err(_)) => break,
254 },
255 }
256 }
257 st.pty.detach(&id, attachment.sub_id);
258}
259
260fn attachment_write(st: &Arc<AppState>, id: &str, data: &str) {
261 st.pty.write_session(id, data);
262}
263
264fn floor_char_boundary(s: &str, want: usize) -> usize {
269 if want >= s.len() {
270 return s.len();
271 }
272 let mut end = want;
273 while end > 0 && !s.is_char_boundary(end) {
274 end -= 1;
275 }
276 end
277}