Support device-token auth and file ids for realtime sync

Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
2026-07-21 15:55:56 -04:00
parent 7586e5a8e4
commit b50e0c0224
2 changed files with 80 additions and 20 deletions
+30 -8
View File
@@ -61,7 +61,7 @@ fn content_hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes)) format!("{:x}", Sha256::digest(bytes))
} }
fn hash_token(token: &str) -> String { pub(crate) fn hash_token(token: &str) -> String {
format!("{:x}", Sha256::digest(token.as_bytes())) format!("{:x}", Sha256::digest(token.as_bytes()))
} }
@@ -110,6 +110,26 @@ pub async fn authenticate(
Ok(user_id) Ok(user_id)
} }
pub(crate) async fn user_id_for_token(state: &AppState, token: &str) -> Option<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
.ok()??;
let (token_id, user_id) = row;
let _ = sqlx::query("UPDATE device_tokens SET last_used_at = ? WHERE id = ?")
.bind(chrono::Utc::now().to_rfc3339())
.bind(&token_id)
.execute(&state.db)
.await;
Some(user_id)
}
async fn owned_project( async fn owned_project(
state: &AppState, state: &AppState,
project_id: &str, project_id: &str,
@@ -409,6 +429,7 @@ pub async fn delete_project(
#[derive(Serialize)] #[derive(Serialize)]
pub struct ManifestEntry { pub struct ManifestEntry {
pub id: String,
pub path: String, pub path: String,
pub kind: String, pub kind: String,
pub hash: String, pub hash: String,
@@ -428,9 +449,9 @@ pub struct ProjectManifest {
async fn plain_contents( async fn plain_contents(
state: &AppState, state: &AppState,
project_id: &str, project_id: &str,
) -> Result<Vec<(String, String, Vec<u8>, String)>, (StatusCode, String)> { ) -> Result<Vec<(String, String, String, Vec<u8>, String)>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>, Option<String>)>( let rows = sqlx::query_as::<_, (String, String, String, Option<Vec<u8>>, Option<String>)>(
"SELECT path, kind, content, updated_at FROM project_files WHERE project_id = ? ORDER BY path ASC", "SELECT id, path, kind, content, updated_at FROM project_files WHERE project_id = ? ORDER BY path ASC",
) )
.bind(project_id) .bind(project_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -439,14 +460,14 @@ async fn plain_contents(
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(path, kind, content, updated_at)| { .map(|(id, path, kind, content, updated_at)| {
let raw = content.unwrap_or_default(); let raw = content.unwrap_or_default();
let plain = if kind == "binary" { let plain = if kind == "binary" {
raw raw
} else { } else {
decode_text_blob(&raw).into_bytes() decode_text_blob(&raw).into_bytes()
}; };
(path, kind, plain, updated_at.unwrap_or_default()) (id, path, kind, plain, updated_at.unwrap_or_default())
}) })
.collect()) .collect())
} }
@@ -462,7 +483,8 @@ pub async fn get_manifest(
let files = plain_contents(&state, &project_id) let files = plain_contents(&state, &project_id)
.await? .await?
.into_iter() .into_iter()
.map(|(path, kind, plain, updated_at)| ManifestEntry { .map(|(id, path, kind, plain, updated_at)| ManifestEntry {
id,
path, path,
kind, kind,
hash: content_hash(&plain), hash: content_hash(&plain),
@@ -740,7 +762,7 @@ pub async fn pull_project(
let files = plain_contents(&state, &project_id) let files = plain_contents(&state, &project_id)
.await? .await?
.into_iter() .into_iter()
.map(|(path, kind, plain, _)| { .map(|(_, path, kind, plain, _)| {
let hash = content_hash(&plain); let hash = content_hash(&plain);
let (encoding, content) = encode_for_transport(&kind, plain); let (encoding, content) = encode_for_transport(&kind, plain);
BundleFile { BundleFile {
+50 -12
View File
@@ -1,10 +1,11 @@
use axum::{ use axum::{
extract::{Path, State, Multipart}, extract::{Path, Query, State, Multipart},
http::{header, StatusCode}, http::{header, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use yrs_axum::ws::AxumSink; use yrs_axum::ws::AxumSink;
@@ -14,6 +15,7 @@ use yrs::{Doc, ReadTxn, Transact, Update};
use yrs::updates::decoder::Decode; use yrs::updates::decoder::Decode;
use futures_util::stream::{StreamExt, Stream}; use futures_util::stream::{StreamExt, Stream};
use crate::AppState; use crate::AppState;
use crate::devices::{notify_devices, DeviceEvent};
use crate::models::Document; use crate::models::Document;
pub struct ViewerFilterStream { pub struct ViewerFilterStream {
@@ -89,22 +91,35 @@ pub struct Diagnostic {
pub to: Option<usize>, pub to: Option<usize>,
} }
struct YjsSaveTarget {
table: &'static str,
row_id: String,
owner_id: String,
event: DeviceEvent,
}
pub async fn yjs_handler( pub async fn yjs_handler(
ws: axum::extract::ws::WebSocketUpgrade, ws: axum::extract::ws::WebSocketUpgrade,
Path(id): Path<String>, Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<AppState>, State(state): State<AppState>,
jar: axum_extra::extract::cookie::SignedCookieJar, jar: axum_extra::extract::cookie::SignedCookieJar,
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = match jar.get("session_user_id").map(|c| c.value().to_string()) {
Some(uid) => Some(uid),
None => match params.get("token") {
Some(token) => crate::desktop::user_id_for_token(&state, token).await,
None => None,
},
};
let mut is_viewer = true; let mut is_viewer = true;
let mut initial_content: Option<Vec<u8>> = None; let mut initial_content: Option<Vec<u8>> = None;
// (table, row_id) the autosave task persists into; None means no persistence. let mut save_target: Option<YjsSaveTarget> = None;
let mut save_target: Option<(&'static str, String)> = None;
if let Some(rest) = id.strip_prefix("project:") { if let Some(rest) = id.strip_prefix("project:") {
if let Some((project_id, file_id)) = rest.split_once(':') { if let Some((project_id, file_id)) = rest.split_once(':') {
if let Some((_project, role)) = crate::projects::project_role(&state, project_id, &user_id_opt).await { if let Some((project, role)) = crate::projects::project_role(&state, project_id, &user_id_opt).await {
is_viewer = role == "viewer"; is_viewer = role == "viewer";
if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>( if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>(
"SELECT content FROM project_files WHERE id = ? AND project_id = ?" "SELECT content FROM project_files WHERE id = ? AND project_id = ?"
@@ -116,7 +131,12 @@ pub async fn yjs_handler(
{ {
initial_content = content; initial_content = content;
} }
save_target = Some(("project_files", file_id.to_string())); save_target = Some(YjsSaveTarget {
table: "project_files",
row_id: file_id.to_string(),
owner_id: project.owner_id,
event: DeviceEvent::project(project_id),
});
} }
} }
} else { } else {
@@ -148,8 +168,13 @@ pub async fn yjs_handler(
} }
} }
initial_content = d.content.clone(); initial_content = d.content.clone();
save_target = Some(YjsSaveTarget {
table: "documents",
row_id: id.clone(),
owner_id: d.owner_id.clone(),
event: DeviceEvent::document(&id),
});
} }
save_target = Some(("documents", id.clone()));
} }
let mut bcast_map = state.bcast_map.lock().await; let mut bcast_map = state.bcast_map.lock().await;
@@ -168,25 +193,38 @@ pub async fn yjs_handler(
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await); let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
bcast_map.insert(id.clone(), new_bcast.clone()); bcast_map.insert(id.clone(), new_bcast.clone());
if let Some((table, row_id)) = save_target { if let Some(target) = save_target {
let save_db = state.db.clone(); let save_db = state.db.clone();
let save_awareness = awareness.clone(); let save_awareness = awareness.clone();
let save_state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
let mut last_content: Option<Vec<u8>> = None;
loop { loop {
interval.tick().await; interval.tick().await;
let doc = save_awareness.read().await; let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default()); let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let query = if table == "project_files" { drop(doc);
if last_content.as_ref() == Some(&content) {
continue;
}
let query = if target.table == "project_files" {
"UPDATE project_files SET content = ? WHERE id = ?" "UPDATE project_files SET content = ? WHERE id = ?"
} else { } else {
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" "UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
}; };
let _ = sqlx::query(query) let result = sqlx::query(query)
.bind(content) .bind(&content)
.bind(&row_id) .bind(&target.row_id)
.execute(&save_db) .execute(&save_db)
.await; .await;
if result.is_ok() {
last_content = Some(content);
notify_devices(&save_state, &target.owner_id, target.event.clone()).await;
}
} }
}); });
} }