Add websocket-based device sync and connected devices UI
Replaces polling-only desktop sync with a live push channel: desktop apps hold a websocket to the server and get notified on project, document, and folder changes from either the app or the web editor. Settings now lists connected devices with revoke support. Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
@@ -14,6 +14,7 @@ use argon2::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
devices::{notify_devices, DeviceEvent},
|
||||||
models::{Project, User},
|
models::{Project, User},
|
||||||
projects::{decode_text_blob, encode_text_blob},
|
projects::{decode_text_blob, encode_text_blob},
|
||||||
AppState,
|
AppState,
|
||||||
@@ -366,6 +367,8 @@ pub async fn create_project(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(ProjectSummary {
|
Ok(Json(ProjectSummary {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -399,6 +402,8 @@ pub async fn delete_project(
|
|||||||
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +678,8 @@ pub async fn push_file(
|
|||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::project(&project_id)).await;
|
||||||
|
|
||||||
Ok(PushOutcome::Applied(Json(PushFileResponse {
|
Ok(PushOutcome::Applied(Json(PushFileResponse {
|
||||||
path: payload.path,
|
path: payload.path,
|
||||||
hash: content_hash(&incoming),
|
hash: content_hash(&incoming),
|
||||||
@@ -700,6 +707,8 @@ pub async fn delete_file(
|
|||||||
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::project(&project_id)).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +847,8 @@ pub async fn create_folder(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFolder {
|
Ok(Json(CloudFolder {
|
||||||
id: folder_id,
|
id: folder_id,
|
||||||
name,
|
name,
|
||||||
@@ -873,6 +884,8 @@ pub async fn rename_folder(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFolder {
|
Ok(Json(CloudFolder {
|
||||||
id,
|
id,
|
||||||
name: row.0,
|
name: row.0,
|
||||||
@@ -955,6 +968,8 @@ pub async fn move_folder(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFolder {
|
Ok(Json(CloudFolder {
|
||||||
id,
|
id,
|
||||||
name: row.0,
|
name: row.0,
|
||||||
@@ -1012,6 +1027,8 @@ pub async fn delete_folder(
|
|||||||
return Err((StatusCode::NOT_FOUND, "Folder not found or unauthorized".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Folder not found or unauthorized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +1061,8 @@ pub async fn move_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(ProjectSummary {
|
Ok(Json(ProjectSummary {
|
||||||
id: row.0,
|
id: row.0,
|
||||||
name: row.1,
|
name: row.1,
|
||||||
@@ -1289,6 +1308,8 @@ pub async fn push_document(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::document(&id)).await;
|
||||||
|
|
||||||
Ok(PushOutcome::Applied(Json(PushFileResponse {
|
Ok(PushOutcome::Applied(Json(PushFileResponse {
|
||||||
path: id,
|
path: id,
|
||||||
hash: incoming_hash,
|
hash: incoming_hash,
|
||||||
@@ -1323,6 +1344,8 @@ pub async fn create_document(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(DocumentContent {
|
Ok(Json(DocumentContent {
|
||||||
id: document_id,
|
id: document_id,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
@@ -1361,6 +1384,8 @@ pub async fn move_document(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudDocument {
|
Ok(Json(CloudDocument {
|
||||||
id: row.0,
|
id: row.0,
|
||||||
title: row.1,
|
title: row.1,
|
||||||
@@ -1388,6 +1413,8 @@ pub async fn delete_document(
|
|||||||
return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,6 +1514,8 @@ pub async fn upload_account_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFile {
|
Ok(Json(CloudFile {
|
||||||
id: file_id,
|
id: file_id,
|
||||||
name,
|
name,
|
||||||
@@ -1525,6 +1554,8 @@ pub async fn rename_account_file(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFile {
|
Ok(Json(CloudFile {
|
||||||
id,
|
id,
|
||||||
name: row.0,
|
name: row.0,
|
||||||
@@ -1563,6 +1594,8 @@ pub async fn move_account_file(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(CloudFile {
|
Ok(Json(CloudFile {
|
||||||
id,
|
id,
|
||||||
name: row.0,
|
name: row.0,
|
||||||
@@ -1625,5 +1658,7 @@ pub async fn delete_account_file(
|
|||||||
return Err((StatusCode::NOT_FOUND, "File not found or unauthorized".to_string()));
|
return Err((StatusCode::NOT_FOUND, "File not found or unauthorized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
Path, State,
|
||||||
|
},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::SignedCookieJar;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{broadcast, watch};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(token.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.filter(|value| value.starts_with("Bearer "))
|
||||||
|
.map(|value| value[7..].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_user_id(jar: &SignedCookieJar) -> Option<String> {
|
||||||
|
jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate_device(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<(String, String), (StatusCode, String)> {
|
||||||
|
let token = bearer_token(headers).ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Missing Authorization header".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT id, user_id FROM device_tokens WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(hash_token(&token))
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let (device_id, user_id) = row.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid device token".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok((user_id, device_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DeviceEvent {
|
||||||
|
pub kind: String,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceEvent {
|
||||||
|
pub fn project(project_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "project".to_string(),
|
||||||
|
project_id: Some(project_id.to_string()),
|
||||||
|
document_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document(document_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "document".to_string(),
|
||||||
|
project_id: None,
|
||||||
|
document_id: Some(document_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn structure() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "structure".to_string(),
|
||||||
|
project_id: None,
|
||||||
|
document_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify_devices(state: &AppState, user_id: &str, event: DeviceEvent) {
|
||||||
|
let events = state.device_events.lock().await;
|
||||||
|
if let Some(sender) = events.get(user_id) {
|
||||||
|
let _ = sender.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DevicePresence {
|
||||||
|
pub connected_since: String,
|
||||||
|
pub connection_id: String,
|
||||||
|
pub stop: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||||
|
let (user_id, device_id) = authenticate_device(&state, &headers).await?;
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| handle_socket(state, socket, user_id, device_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(state: AppState, socket: WebSocket, user_id: String, device_id: String) {
|
||||||
|
let mut receiver = {
|
||||||
|
let mut events = state.device_events.lock().await;
|
||||||
|
let sender = events
|
||||||
|
.entry(user_id)
|
||||||
|
.or_insert_with(|| broadcast::channel(16).0)
|
||||||
|
.clone();
|
||||||
|
sender.subscribe()
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection_id = Uuid::new_v4().to_string();
|
||||||
|
let (stop_tx, mut stop_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut presence = state.device_presence.lock().await;
|
||||||
|
presence.insert(
|
||||||
|
device_id.clone(),
|
||||||
|
DevicePresence {
|
||||||
|
connected_since: chrono::Utc::now().to_rfc3339(),
|
||||||
|
connection_id: connection_id.clone(),
|
||||||
|
stop: stop_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
|
||||||
|
heartbeat.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
event = receiver.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
let Ok(payload) = serde_json::to_string(&event) else { continue };
|
||||||
|
if sink.send(Message::Text(payload.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = heartbeat.tick() => {
|
||||||
|
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming = stream.next() => {
|
||||||
|
match incoming {
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Err(_)) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = stop_rx.changed() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut presence = state.device_presence.lock().await;
|
||||||
|
if presence
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|entry| entry.connection_id == connection_id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
presence.remove(&device_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DeviceView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub connected: bool,
|
||||||
|
pub connected_since: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<DeviceView>>, (StatusCode, String)> {
|
||||||
|
let user_id =
|
||||||
|
get_user_id(&jar).ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
||||||
|
"SELECT id, name, created_at, last_used_at FROM device_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let presence = state.device_presence.lock().await;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(id, name, created_at, last_used_at)| {
|
||||||
|
let entry = presence.get(&id);
|
||||||
|
DeviceView {
|
||||||
|
connected: entry.is_some(),
|
||||||
|
connected_since: entry.map(|e| e.connected_since.clone()),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
last_used_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let user_id =
|
||||||
|
get_user_id(&jar).ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM device_tokens WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Device not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let presence = state.device_presence.lock().await;
|
||||||
|
if let Some(entry) = presence.get(&id) {
|
||||||
|
let _ = entry.stop.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
+13
-3
@@ -6,8 +6,10 @@ use axum_extra::extract::cookie::Key;
|
|||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use yrs_axum::broadcast::BroadcastGroup;
|
use yrs_axum::broadcast::BroadcastGroup;
|
||||||
|
|
||||||
|
use devices::{DeviceEvent, DevicePresence};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -18,6 +20,7 @@ mod auth;
|
|||||||
mod compiler;
|
mod compiler;
|
||||||
mod db;
|
mod db;
|
||||||
mod desktop;
|
mod desktop;
|
||||||
|
mod devices;
|
||||||
mod docs;
|
mod docs;
|
||||||
mod folders;
|
mod folders;
|
||||||
mod files;
|
mod files;
|
||||||
@@ -43,6 +46,8 @@ pub struct AppState {
|
|||||||
pub key: Key,
|
pub key: Key,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
pub rate_limiter: RateLimiterMap,
|
pub rate_limiter: RateLimiterMap,
|
||||||
|
pub device_presence: Arc<Mutex<HashMap<String, DevicePresence>>>,
|
||||||
|
pub device_events: Arc<Mutex<HashMap<String, broadcast::Sender<DeviceEvent>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl axum::extract::FromRef<AppState> for Key {
|
impl axum::extract::FromRef<AppState> for Key {
|
||||||
@@ -93,6 +98,8 @@ async fn main() {
|
|||||||
key,
|
key,
|
||||||
registration_enabled,
|
registration_enabled,
|
||||||
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
|
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
device_presence: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
device_events: Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
@@ -139,7 +146,9 @@ async fn main() {
|
|||||||
.route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_file))
|
.route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_file))
|
||||||
.route("/packages", get(packages::list_packages))
|
.route("/packages", get(packages::list_packages))
|
||||||
.route("/packages/publish", post(packages::publish_package))
|
.route("/packages/publish", post(packages::publish_package))
|
||||||
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
|
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package))
|
||||||
|
.route("/devices", get(devices::list_devices))
|
||||||
|
.route("/devices/{id}", delete(devices::revoke_device));
|
||||||
|
|
||||||
let desktop_routes = Router::new()
|
let desktop_routes = Router::new()
|
||||||
.route("/version", get(desktop::version_info))
|
.route("/version", get(desktop::version_info))
|
||||||
@@ -158,7 +167,8 @@ async fn main() {
|
|||||||
.route("/files", get(desktop::list_account_files).post(desktop::upload_account_file))
|
.route("/files", get(desktop::list_account_files).post(desktop::upload_account_file))
|
||||||
.route("/files/{id}", get(desktop::pull_account_file).patch(desktop::rename_account_file).delete(desktop::delete_account_file))
|
.route("/files/{id}", get(desktop::pull_account_file).patch(desktop::rename_account_file).delete(desktop::delete_account_file))
|
||||||
.route("/files/{id}/move", patch(desktop::move_account_file))
|
.route("/files/{id}/move", patch(desktop::move_account_file))
|
||||||
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
|
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file))
|
||||||
|
.route("/ws", get(devices::ws_handler));
|
||||||
|
|
||||||
let v1_routes = Router::new()
|
let v1_routes = Router::new()
|
||||||
.route("/render", post(public_api::render_handler));
|
.route("/render", post(public_api::render_handler));
|
||||||
|
|||||||
+17
-4
@@ -13,6 +13,7 @@ use yrs::Update;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
compiler::ProjectInput,
|
compiler::ProjectInput,
|
||||||
|
devices::{notify_devices, DeviceEvent},
|
||||||
models::{
|
models::{
|
||||||
CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
|
CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
|
||||||
UpdateProjectRequest,
|
UpdateProjectRequest,
|
||||||
@@ -336,6 +337,8 @@ pub async fn update_project(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(Json(project))
|
Ok(Json(project))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +366,8 @@ pub async fn delete_project(
|
|||||||
return Err((StatusCode::NOT_FOUND, "Project not found or unauthorized".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Project not found or unauthorized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +399,7 @@ pub async fn create_project_file(
|
|||||||
Json(payload): Json<CreateProjectFileRequest>,
|
Json(payload): Json<CreateProjectFileRequest>,
|
||||||
) -> Result<Json<ProjectFile>, (StatusCode, String)> {
|
) -> Result<Json<ProjectFile>, (StatusCode, String)> {
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
let (project, role) = project_role(&state, &id, &user_id_opt)
|
||||||
.await
|
.await
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
if role == "viewer" {
|
if role == "viewer" {
|
||||||
@@ -417,6 +422,8 @@ pub async fn create_project_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
Ok(Json(file))
|
Ok(Json(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +434,7 @@ pub async fn upload_project_file(
|
|||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
let (project, role) = project_role(&state, &id, &user_id_opt)
|
||||||
.await
|
.await
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
if role == "viewer" {
|
if role == "viewer" {
|
||||||
@@ -465,6 +472,8 @@ pub async fn upload_project_file(
|
|||||||
uploaded.push(path);
|
uploaded.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "files": uploaded })))
|
Ok(Json(serde_json::json!({ "files": uploaded })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +514,7 @@ pub async fn update_project_file(
|
|||||||
Json(payload): Json<UpdateProjectFileRequest>,
|
Json(payload): Json<UpdateProjectFileRequest>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
let (project, role) = project_role(&state, &id, &user_id_opt)
|
||||||
.await
|
.await
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
if role == "viewer" {
|
if role == "viewer" {
|
||||||
@@ -524,6 +533,8 @@ pub async fn update_project_file(
|
|||||||
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +544,7 @@ pub async fn delete_project_file(
|
|||||||
jar: SignedCookieJar,
|
jar: SignedCookieJar,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
let (project, role) = project_role(&state, &id, &user_id_opt)
|
||||||
.await
|
.await
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
if role == "viewer" {
|
if role == "viewer" {
|
||||||
@@ -551,5 +562,7 @@ pub async fn delete_project_file(
|
|||||||
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
rate_limit: number;
|
rate_limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
connected_since: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
let activeSection = $state('account');
|
let activeSection = $state('account');
|
||||||
|
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
@@ -62,6 +71,12 @@
|
|||||||
let confirmRegenerateId = $state<string | null>(null);
|
let confirmRegenerateId = $state<string | null>(null);
|
||||||
let regeneratingKeyId = $state<string | null>(null);
|
let regeneratingKeyId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let devices = $state<Device[]>([]);
|
||||||
|
let devicesLoading = $state(false);
|
||||||
|
let devicesError = $state('');
|
||||||
|
let confirmRevokeDeviceId = $state<string | null>(null);
|
||||||
|
let revokingDeviceId = $state<string | null>(null);
|
||||||
|
|
||||||
type UsagePoint = { date: string; count: number };
|
type UsagePoint = { date: string; count: number };
|
||||||
type UsagePeriod = '1hr' | '1day' | '1week';
|
type UsagePeriod = '1hr' | '1day' | '1week';
|
||||||
let usageData = $state<UsagePoint[]>([]);
|
let usageData = $state<UsagePoint[]>([]);
|
||||||
@@ -161,6 +176,34 @@
|
|||||||
confirmDeleteKeyId = null;
|
confirmDeleteKeyId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
devicesLoading = true;
|
||||||
|
devicesError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/devices');
|
||||||
|
if (res.ok) {
|
||||||
|
devices = await res.json();
|
||||||
|
} else {
|
||||||
|
devicesError = 'Failed to load devices.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
devicesError = 'Network error.';
|
||||||
|
}
|
||||||
|
devicesLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(id: string) {
|
||||||
|
revokingDeviceId = id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/devices/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
devices = devices.filter(d => d.id !== id);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
revokingDeviceId = null;
|
||||||
|
confirmRevokeDeviceId = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function copyKey(key: string) {
|
async function copyKey(key: string) {
|
||||||
await navigator.clipboard.writeText(key);
|
await navigator.clipboard.writeText(key);
|
||||||
copiedKey = true;
|
copiedKey = true;
|
||||||
@@ -300,6 +343,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'devices') {
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function toggleAdmin(user: AdminUser) {
|
async function toggleAdmin(user: AdminUser) {
|
||||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -408,6 +457,7 @@
|
|||||||
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
||||||
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: 'mdi:key-outline' },
|
{ id: 'api-keys', label: 'API Keys', icon: 'mdi:key-outline' },
|
||||||
|
{ id: 'devices', label: 'Devices', icon: 'mdi:devices' },
|
||||||
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
@@ -811,6 +861,89 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'devices'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] flex items-center gap-2 mb-2">
|
||||||
|
<Icon icon="mdi:devices" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
Devices
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mb-6">
|
||||||
|
Typst Desktop apps signed in to your account. A device stays connected while it is running with live sync; revoke a device to sign it out immediately.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if devicesError}
|
||||||
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{devicesError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if devicesLoading}
|
||||||
|
<div class="flex items-center justify-center py-12 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
||||||
|
Loading devices...
|
||||||
|
</div>
|
||||||
|
{:else if devices.length === 0}
|
||||||
|
<div class="text-center py-12 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:devices" class="text-4xl mb-2 opacity-40" />
|
||||||
|
<p class="text-sm">No devices signed in yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each devices as device (device.id)}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-3 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)]">
|
||||||
|
<div class="h-9 w-9 rounded-full bg-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] flex-shrink-0">
|
||||||
|
<Icon icon="mdi:laptop" class="text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink)] truncate flex items-center gap-2">
|
||||||
|
{device.name}
|
||||||
|
{#if device.connected}
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-success)]">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-success)]"></span>
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">{device.last_used_at ? `Last used ${formatDate(device.last_used_at)}` : 'Never used'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0 hidden sm:block">
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">Added {formatDate(device.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{#if confirmRevokeDeviceId === device.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-[var(--color-ink-muted)]">Revoke?</span>
|
||||||
|
<button
|
||||||
|
onclick={() => revokeDevice(device.id)}
|
||||||
|
disabled={revokingDeviceId === device.id}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-danger)] hover:opacity-90 text-white font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{revokingDeviceId === device.id ? '...' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => confirmRevokeDeviceId = null}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)] font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => confirmRevokeDeviceId = device.id}
|
||||||
|
title="Revoke device"
|
||||||
|
class="p-1.5 rounded-md text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete-outline" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if activeSection === 'admin' && $userStore?.is_admin}
|
{#if activeSection === 'admin' && $userStore?.is_admin}
|
||||||
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user