Update 1.2.0

This commit is contained in:
2026-04-05 17:59:52 -04:00
parent 88df97f712
commit 37dc7d5610
30 changed files with 2057 additions and 245 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "server"
version = "1.0.0"
version = "1.2.0"
edition = "2021"
[dependencies]
@@ -9,7 +9,7 @@ axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
@@ -30,3 +30,4 @@ yrs = "0.18.8"
yrs-axum = "0.8"
typst-assets = "0.14.2"
tokio-stream = "0.1.18"
+20 -18
View File
@@ -20,8 +20,8 @@ pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
if payload.username.is_empty() || payload.password.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username and password cannot be empty".to_string()));
if payload.username.is_empty() || payload.password.is_empty() || payload.email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username, email, and password cannot be empty".to_string()));
}
let salt = SaltString::generate(&mut OsRng);
@@ -34,10 +34,11 @@ pub async fn register(
let user_id = Uuid::new_v4().to_string();
let result = sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?) RETURNING id, username, password_hash"
"INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, username, email, password_hash"
)
.bind(&user_id)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.fetch_one(&state.db)
.await;
@@ -45,7 +46,7 @@ pub async fn register(
match result {
Ok(user) => Ok(Json(user)),
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
Err((StatusCode::CONFLICT, "Username or email already exists".to_string()))
}
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
}
@@ -56,22 +57,22 @@ pub async fn login(
jar: SignedCookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE username = ?")
.bind(&payload.username)
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = $1")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let user = match user {
Some(u) => u,
None => return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string())),
None => return Err((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string())),
};
let parsed_hash = PasswordHash::new(&user.password_hash)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string()));
return Err((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string()));
}
let mut cookie = Cookie::new("session_user_id", user.id.clone());
@@ -92,19 +93,20 @@ pub async fn update_profile(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
if payload.username.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username cannot be empty".to_string()));
if payload.username.is_empty() || payload.email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username and email cannot be empty".to_string()));
}
let result = sqlx::query("UPDATE users SET username = ? WHERE id = ?")
let result = sqlx::query("UPDATE users SET username = $1, email = $2 WHERE id = $3")
.bind(&payload.username)
.bind(&payload.email)
.bind(&user_id)
.execute(&state.db)
.await;
match result {
Ok(_) => {
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -113,7 +115,7 @@ pub async fn update_profile(
Ok(Json(user))
}
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
Err((StatusCode::CONFLICT, "Username or email already exists".to_string()))
}
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
}
@@ -135,7 +137,7 @@ pub async fn me(
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
};
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -159,7 +161,7 @@ pub async fn change_password(
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
}
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -179,7 +181,7 @@ pub async fn change_password(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_string();
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&new_password_hash)
.bind(&user_id)
.execute(&state.db)
@@ -197,7 +199,7 @@ pub async fn storage_stats(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let docs_size: (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(LENGTH(content)), 0) FROM documents WHERE owner_id = ?"
"SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0) FROM documents WHERE owner_id = $1"
)
.bind(&user_id)
.fetch_one(&state.db)
@@ -205,7 +207,7 @@ pub async fn storage_stats(
.unwrap_or((0,));
let files_size: (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM files WHERE owner_id = ?"
"SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0) FROM files WHERE owner_id = $1"
)
.bind(&user_id)
.fetch_one(&state.db)
+302
View File
@@ -0,0 +1,302 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use axum_extra::extract::cookie::SignedCookieJar;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
models::{Collaborator, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
AppState,
};
pub async fn invite_collaborator(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<InviteRequest>,
) -> Result<Json<Invitation>, (StatusCode, String)> {
let inviter_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Check if the user is the owner
let doc_exists = sqlx::query_as::<_, (String,)>("SELECT id FROM documents WHERE id = $1 AND owner_id = $2")
.bind(&doc_id)
.bind(&inviter_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if doc_exists.is_none() {
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string()));
}
// Find the user by email
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = $1")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(user) = invited_user {
let collab_id = Uuid::new_v4().to_string();
let _collab = sqlx::query_as::<_, Collaborator>(
"INSERT INTO collaborators (id, document_id, user_id, role) VALUES ($1, $2, $3, $4) ON CONFLICT (document_id, user_id) DO UPDATE SET role = EXCLUDED.role RETURNING id, document_id, user_id, role, created_at"
)
.bind(&collab_id)
.bind(&doc_id)
.bind(&user.id)
.bind(&payload.role)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Mock returning an invitation so frontend knows it succeeded
let inv = Invitation {
id: Uuid::new_v4().to_string(),
document_id: doc_id.to_string(),
role: payload.role.clone(),
token: "direct-added".to_string(),
created_at: chrono::Utc::now().naive_utc(),
expires_at: None,
};
Ok(Json(inv))
} else {
Err((StatusCode::NOT_FOUND, "User with that email not found".to_string()))
}
}
#[derive(Deserialize)]
pub struct AcceptInviteQuery {
pub token: String,
}
pub async fn accept_invite(
State(state): State<AppState>,
jar: SignedCookieJar,
Query(query): Query<AcceptInviteQuery>,
) -> Result<Json<Collaborator>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let invitation = sqlx::query_as::<_, Invitation>(
"SELECT id, document_id, role, token, created_at, expires_at FROM invitations WHERE token = $1"
)
.bind(&query.token)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Invalid or expired invitation".to_string()))?;
let collab_id = Uuid::new_v4().to_string();
let collab = sqlx::query_as::<_, Collaborator>(
"INSERT INTO collaborators (id, document_id, user_id, role) VALUES ($1, $2, $3, $4) ON CONFLICT (document_id, user_id) DO UPDATE SET role = EXCLUDED.role RETURNING id, document_id, user_id, role, created_at"
)
.bind(&collab_id)
.bind(&invitation.document_id)
.bind(&user_id)
.bind(&invitation.role)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(collab))
}
pub async fn get_comments(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Comment>>, (StatusCode, String)> {
let _user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Basic access control omitted for brevity
let comments = sqlx::query_as::<_, Comment>(
"SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM comments c \
LEFT JOIN users u ON c.user_id = u.id \
WHERE c.document_id = $1 \
ORDER BY c.created_at ASC"
)
.bind(&doc_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(comments))
}
pub async fn add_comment(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<CreateCommentRequest>,
) -> Result<Json<Comment>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let comment_id = Uuid::new_v4().to_string();
let comment = sqlx::query_as::<_, Comment>(
"WITH new_comment AS ( \
INSERT INTO comments (id, document_id, user_id, content) \
VALUES ($1, $2, $3, $4) \
RETURNING id, document_id, user_id, content, resolved, created_at \
) \
SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM new_comment c \
LEFT JOIN users u ON c.user_id = u.id"
)
.bind(&comment_id)
.bind(&doc_id)
.bind(&user_id)
.bind(&payload.content)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(comment))
}
pub async fn create_version(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<crate::models::CreateVersionRequest>,
) -> Result<Json<crate::models::DocumentVersion>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Check access
let doc = sqlx::query_as::<_, crate::models::Document>("SELECT * FROM documents WHERE id = $1")
.bind(&doc_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
let is_owner = doc.owner_id == user_id;
let role = sqlx::query_scalar::<_, String>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(&doc_id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !is_owner && role != Some("editor".to_string()) {
return Err((StatusCode::FORBIDDEN, "Not authorized to create versions".to_string()));
}
let version_id = uuid::Uuid::new_v4().to_string();
let version = sqlx::query_as::<_, crate::models::DocumentVersion>(
"INSERT INTO document_versions (id, document_id, user_id, content) VALUES ($1, $2, $3, $4) RETURNING *, (SELECT username FROM users WHERE id = $3) as author_name"
)
.bind(&version_id)
.bind(&doc_id)
.bind(&user_id)
.bind(&payload.content)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(version))
}
pub async fn get_versions(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<crate::models::DocumentVersion>>, (StatusCode, String)> {
let _user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Basic access check
let versions = sqlx::query_as::<_, crate::models::DocumentVersion>(
"SELECT v.id, v.document_id, v.user_id, v.content, v.created_at, u.username as author_name \
FROM document_versions v \
LEFT JOIN users u ON v.user_id = u.id \
WHERE v.document_id = $1 \
ORDER BY v.created_at DESC"
)
.bind(&doc_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(versions))
}
pub async fn update_comment(
State(state): State<AppState>,
Path(comment_id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<UpdateCommentRequest>,
) -> Result<Json<Comment>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut comment = sqlx::query_as::<_, Comment>(
"SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM comments c \
LEFT JOIN users u ON c.user_id = u.id \
WHERE c.id = $1 AND c.user_id = $2"
)
.bind(&comment_id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Comment not found or unauthorized".to_string()))?;
if let Some(c) = payload.content {
comment.content = c;
}
if let Some(r) = payload.resolved {
comment.resolved = r;
}
let updated_comment = sqlx::query_as::<_, Comment>(
"WITH updated_comment AS ( \
UPDATE comments SET content = $1, resolved = $2 WHERE id = $3 \
RETURNING id, document_id, user_id, content, resolved, created_at \
) \
SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM updated_comment c \
LEFT JOIN users u ON c.user_id = u.id"
)
.bind(&comment.content)
.bind(comment.resolved)
.bind(&comment.id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(updated_comment))
}
pub async fn delete_comment(
State(state): State<AppState>,
Path(comment_id): Path<String>,
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let result = sqlx::query("DELETE FROM comments WHERE id = $1 AND user_id = $2")
.bind(&comment_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, "Comment not found or unauthorized".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
+1 -1
View File
@@ -23,7 +23,7 @@ impl TypstCompiler {
output: Ok(doc),
warnings: _,
} => {
let svgs = doc.pages.iter().map(|page| typst_svg::svg(page)).collect();
let svgs = doc.pages.iter().map(typst_svg::svg).collect();
let thumbnail = if let Some(page) = doc.pages.first() {
typst_svg::svg(page)
} else {
+75 -55
View File
@@ -1,86 +1,106 @@
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
pub async fn init_db() -> Pool<Sqlite> {
let db_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:typstdrive.db?mode=rwc".to_string());
// Ensure parent directory exists if there is one
if let Some(path) = db_url.strip_prefix("sqlite:") {
if let Some(path) = path.split('?').next() {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
}
}
pub async fn init_db() -> Pool<Postgres> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/typstdrive".to_string());
let pool = SqlitePoolOptions::new()
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to create pool.");
.expect("Failed to create Postgres pool. Make sure your database is running.");
sqlx::query(
r#"
let schema = r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
parent_id TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(owner_id) REFERENCES users(id),
FOREIGN KEY(parent_id) REFERENCES folders(id)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
folder_id TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
title TEXT NOT NULL,
content BLOB,
content BYTEA,
thumbnail_svg TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(owner_id) REFERENCES users(id),
FOREIGN KEY(folder_id) REFERENCES folders(id)
public_role TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
document_id TEXT,
folder_id TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
document_id TEXT REFERENCES documents(id),
folder_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
data BLOB NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(owner_id) REFERENCES users(id),
FOREIGN KEY(document_id) REFERENCES documents(id),
FOREIGN KEY(folder_id) REFERENCES folders(id)
data BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"#,
)
.execute(&pool)
.await
.expect("Failed to initialize database schema");
CREATE TABLE IF NOT EXISTS collaborators (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(document_id, user_id)
);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
role TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
resolved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS document_history (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content BYTEA NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS document_versions (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"#;
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN folder_id TEXT REFERENCES folders(id)")
.execute(&pool)
.await;
for query in schema.split(';') {
let q = query.trim();
if !q.is_empty() {
sqlx::query(q).execute(&pool).await.expect("Failed to execute schema query");
}
}
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN thumbnail_svg TEXT")
// Add public_role column if it doesn't exist
sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT")
.execute(&pool)
.await;
let _ = sqlx::query("ALTER TABLE files ADD COLUMN folder_id TEXT REFERENCES folders(id)")
.execute(&pool)
.await;
.await
.unwrap_or_else(|e| {
eprintln!("Warning: Failed to add public_role column (might already exist): {}", e);
Default::default()
});
pool
}
+52 -19
View File
@@ -27,7 +27,7 @@ pub async fn list_documents(
let docs = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = $1 AND folder_id = $2 ORDER BY updated_at DESC"
)
.bind(&user_id)
.bind(&folder_id)
@@ -36,7 +36,7 @@ pub async fn list_documents(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else {
sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = $1 AND folder_id IS NULL ORDER BY updated_at DESC"
)
.bind(&user_id)
.fetch_all(&state.db)
@@ -70,7 +70,7 @@ pub async fn create_document(
};
let doc = sqlx::query_as::<_, Document>(
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES ($1, $2, $3, $4, $5) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at"
)
.bind(&doc_id)
.bind(&user_id)
@@ -89,22 +89,48 @@ pub async fn get_document(
Path(id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Document>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?"
let mut doc = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1"
)
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.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()))?;
match doc {
Some(d) => Ok(Json(d)),
None => Err((StatusCode::NOT_FOUND, "Document not found".to_string())),
let mut effective_role = "none".to_string();
if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid {
effective_role = "owner".to_string();
} else {
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(&id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
effective_role = role;
}
}
}
if effective_role == "none" {
if let Some(pr) = &doc.public_role {
if pr == "viewer" || pr == "editor" {
effective_role = pr.clone();
}
}
}
if effective_role == "none" {
return Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()));
}
doc.effective_role = Some(effective_role);
Ok(Json(doc))
}
pub async fn update_document(
@@ -118,7 +144,7 @@ pub async fn update_document(
let mut doc = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?"
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1 AND owner_id = $2"
)
.bind(&id)
.bind(&user_id)
@@ -137,13 +163,21 @@ pub async fn update_document(
doc.folder_id = Some(new_folder_id);
}
}
if let Some(new_public_role) = payload.public_role {
if new_public_role == "none" || new_public_role.is_empty() {
doc.public_role = None;
} else {
doc.public_role = Some(new_public_role);
}
}
let doc = sqlx::query_as::<_, Document>(
"UPDATE documents SET title = ?, folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
"UPDATE documents SET title = $1, folder_id = $2, public_role = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND owner_id = $5 RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at"
)
.bind(&doc.title)
.bind(&doc.folder_id)
.bind(&doc.public_role)
.bind(&id)
.bind(&user_id)
.fetch_one(&state.db)
@@ -161,7 +195,7 @@ pub async fn delete_document(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let result = sqlx::query("DELETE FROM documents WHERE id = ? AND owner_id = ?")
let result = sqlx::query("DELETE FROM documents WHERE id = $1 AND owner_id = $2")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
@@ -185,7 +219,7 @@ pub async fn upload_file(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let doc_exists = sqlx::query_as::<_, (String, Option<String>)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?")
let doc_exists = sqlx::query_as::<_, (String, Option<String>)>("SELECT id, folder_id FROM documents WHERE id = $1 AND owner_id = $2")
.bind(&doc_id)
.bind(&user_id)
.fetch_optional(&state.db)
@@ -200,14 +234,14 @@ pub async fn upload_file(
let mut uploaded_filename = String::new();
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
if let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let file_name = field.file_name().unwrap_or("unnamed").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?.to_vec();
let file_id = Uuid::new_v4().to_string();
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)")
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)")
.bind(&file_id)
.bind(&user_id)
.bind(&doc_id)
@@ -220,7 +254,6 @@ pub async fn upload_file(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
uploaded_filename = file_name;
break;
}
Ok(Json(serde_json::json!({"filename": uploaded_filename})))
+7 -7
View File
@@ -28,7 +28,7 @@ pub async fn list_files(
let files = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, File>(
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id = ? ORDER BY name ASC"
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = $1 AND folder_id = $2 ORDER BY name ASC"
)
.bind(&user_id)
.bind(&folder_id)
@@ -37,7 +37,7 @@ pub async fn list_files(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else {
sqlx::query_as::<_, File>(
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id IS NULL ORDER BY name ASC"
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = $1 AND folder_id IS NULL ORDER BY name ASC"
)
.bind(&user_id)
.fetch_all(&state.db)
@@ -71,7 +71,7 @@ pub async fn upload_file_global(
let file_id = Uuid::new_v4().to_string();
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?)")
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)")
.bind(&file_id)
.bind(&user_id)
.bind(&query.folder_id)
@@ -96,7 +96,7 @@ pub async fn get_file_data(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let file = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT mime_type, data FROM files WHERE id = ? AND owner_id = ?")
let file = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT mime_type, data FROM files WHERE id = $1 AND owner_id = $2")
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
@@ -121,7 +121,7 @@ pub async fn delete_file(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let result = sqlx::query("DELETE FROM files WHERE id = ? AND owner_id = ?")
let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
@@ -151,7 +151,7 @@ pub async fn update_file(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut file = sqlx::query_as::<_, File>(
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE id = ? AND owner_id = ?"
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE id = $1 AND owner_id = $2"
)
.bind(&id)
.bind(&user_id)
@@ -172,7 +172,7 @@ pub async fn update_file(
}
let file = sqlx::query_as::<_, File>(
"UPDATE files SET name = ?, folder_id = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at"
"UPDATE files SET name = $1, folder_id = $2 WHERE id = $3 AND owner_id = $4 RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at"
)
.bind(&file.name)
.bind(&file.folder_id)
+5 -5
View File
@@ -27,7 +27,7 @@ pub async fn list_folders(
let folders = if let Some(parent_id) = query.parent_id {
sqlx::query_as::<_, Folder>(
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id = ? ORDER BY name ASC"
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = $1 AND parent_id = $2 ORDER BY name ASC"
)
.bind(&user_id)
.bind(&parent_id)
@@ -36,7 +36,7 @@ pub async fn list_folders(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else {
sqlx::query_as::<_, Folder>(
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id IS NULL ORDER BY name ASC"
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = $1 AND parent_id IS NULL ORDER BY name ASC"
)
.bind(&user_id)
.fetch_all(&state.db)
@@ -58,7 +58,7 @@ pub async fn create_folder(
let folder_id = Uuid::new_v4().to_string();
let folder = sqlx::query_as::<_, Folder>(
"INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?) RETURNING id, owner_id, parent_id, name, created_at"
"INSERT INTO folders (id, owner_id, parent_id, name) VALUES ($1, $2, $3, $4) RETURNING id, owner_id, parent_id, name, created_at"
)
.bind(&folder_id)
.bind(&user_id)
@@ -81,7 +81,7 @@ pub async fn delete_folder(
let result = sqlx::query("DELETE FROM folders WHERE id = ? AND owner_id = ?")
let result = sqlx::query("DELETE FROM folders WHERE id = $1 AND owner_id = $2")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
@@ -110,7 +110,7 @@ pub async fn update_folder(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let folder = sqlx::query_as::<_, Folder>(
"UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at"
"UPDATE folders SET name = $1 WHERE id = $2 AND owner_id = $3 RETURNING id, owner_id, parent_id, name, created_at"
)
.bind(&payload.name)
.bind(&id)
+289 -36
View File
@@ -1,5 +1,5 @@
use axum::{
extract::{Path, State},
extract::{Path, State, Multipart},
http::{header, StatusCode},
response::IntoResponse,
Json,
@@ -7,15 +7,47 @@ use axum::{
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use yrs_axum::ws::{AxumSink, AxumStream};
use yrs_axum::ws::AxumSink;
use yrs_axum::broadcast::BroadcastGroup;
use yrs::sync::Awareness;
use yrs::{Doc, ReadTxn, Transact, Update};
use yrs::updates::decoder::Decode;
use futures_util::stream::StreamExt;
use futures_util::stream::{StreamExt, Stream};
use crate::AppState;
use crate::models::Document;
pub struct ViewerFilterStream {
inner: futures_util::stream::SplitStream<axum::extract::ws::WebSocket>,
is_viewer: bool,
}
impl Stream for ViewerFilterStream {
type Item = Result<Vec<u8>, yrs::sync::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
loop {
match futures_util::ready!(std::pin::Pin::new(&mut self.inner).poll_next(cx)) {
Some(Ok(msg)) => {
if let axum::extract::ws::Message::Binary(bytes) = msg {
if self.is_viewer && !bytes.is_empty() && bytes[0] == 0 && bytes.len() > 1 && bytes[1] == 2 {
continue; // Skip updates
}
return std::task::Poll::Ready(Some(Ok(bytes.to_vec())));
} else if let axum::extract::ws::Message::Close(_) = msg {
return std::task::Poll::Ready(None);
}
continue;
}
Some(Err(e)) => return std::task::Poll::Ready(Some(Err(yrs::sync::Error::Other(Box::new(e))))),
None => return std::task::Poll::Ready(None),
}
}
}
}
#[derive(Deserialize)]
pub struct CompileRequest {
pub text: String,
@@ -38,21 +70,47 @@ pub async fn yjs_handler(
ws: axum::extract::ws::WebSocketUpgrade,
Path(id): Path<String>,
State(state): State<AppState>,
jar: axum_extra::extract::cookie::SignedCookieJar,
) -> impl IntoResponse {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc_info = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1"
)
.bind(&id)
.fetch_optional(&state.db)
.await;
let mut is_viewer = true;
if let Ok(Some(ref d)) = doc_info {
if let Some(uid) = &user_id_opt {
if &d.owner_id == uid {
is_viewer = false;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2 AND role = 'editor'")
.bind(&id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
is_viewer = false;
}
}
if is_viewer {
if let Some(pr) = &d.public_role {
if pr == "editor" {
is_viewer = false;
}
}
}
}
let mut bcast_map = state.bcast_map.lock().await;
let bcast = if let Some(bcast) = bcast_map.get(&id) {
bcast.clone()
} else {
let doc = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?"
)
.bind(&id)
.fetch_optional(&state.db)
.await;
let ydoc = Doc::new();
if let Ok(Some(db_doc)) = doc {
if let Ok(Some(db_doc)) = doc_info {
if let Some(content) = db_doc.content {
if let Ok(update) = Update::decode_v1(&content) {
ydoc.transact_mut().apply_update(update);
@@ -73,7 +131,7 @@ pub async fn yjs_handler(
interval.tick().await;
let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
let _ = sqlx::query("UPDATE documents SET content = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2")
.bind(content)
.bind(&save_id)
.execute(&save_db)
@@ -89,8 +147,13 @@ pub async fn yjs_handler(
ws.on_upgrade(move |socket| async move {
let (sink, stream) = socket.split();
let sink = Arc::new(Mutex::new(AxumSink(sink)));
let stream = AxumStream(stream);
let sub = bcast.subscribe(sink, stream);
let filtered_stream = ViewerFilterStream {
inner: stream,
is_viewer,
};
let sub = bcast.subscribe(sink, filtered_stream);
match sub.completed().await {
Ok(_) => println!("broadcasting for channel finished successfully"),
Err(e) => eprintln!("broadcasting for channel finished abruptly: {}", e),
@@ -100,18 +163,49 @@ pub async fn yjs_handler(
pub async fn compile_handler(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::SignedCookieJar,
Json(payload): Json<CompileRequest>,
) -> impl IntoResponse {
let mut files_map = std::collections::HashMap::new();
let mut can_save_thumbnail = false;
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(doc_id) = &payload.document_id {
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(doc_id).fetch_one(&state.db).await {
// Allow compilation if owner or if it has a public role or if they are a collaborator
let mut has_access = false;
if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid {
has_access = true;
can_save_thumbnail = true;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(doc_id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
has_access = true;
}
}
if !has_access {
if let Some(pr) = &doc.public_role {
if pr == "viewer" || pr == "editor" {
has_access = true;
}
}
}
if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1")
.bind(doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
}
}
}
}
@@ -121,11 +215,13 @@ pub async fn compile_handler(
match compiler.compile_svg(payload.text, files_map) {
Ok((svgs, thumbnail)) => {
if let Some(doc_id) = &payload.document_id {
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = ? WHERE id = ?")
.bind(&thumbnail)
.bind(doc_id)
.execute(&state.db)
.await;
if can_save_thumbnail {
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = $1 WHERE id = $2")
.bind(&thumbnail)
.bind(doc_id)
.execute(&state.db)
.await;
}
}
Json(CompileResponse {
@@ -151,19 +247,46 @@ pub async fn compile_handler(
pub async fn export_handler(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::SignedCookieJar,
Path(format): Path<String>,
Json(payload): Json<CompileRequest>,
) -> impl IntoResponse {
let mut files_map = std::collections::HashMap::new();
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(doc_id) = &payload.document_id {
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(doc_id).fetch_one(&state.db).await {
let mut has_access = false;
if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid {
has_access = true;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(doc_id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
has_access = true;
}
}
if !has_access {
if let Some(pr) = &doc.public_role {
if pr == "viewer" || pr == "editor" {
has_access = true;
}
}
}
if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1")
.bind(doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
}
}
}
}
@@ -197,7 +320,7 @@ pub async fn export_handler(
let mut combined = String::new();
for svg in svgs {
combined.push_str(&svg);
combined.push_str("\n");
combined.push('\n');
}
(
StatusCode::OK,
@@ -211,3 +334,133 @@ pub async fn export_handler(
_ => (StatusCode::NOT_FOUND, "Format not supported").into_response(),
}
}
use std::process::Stdio;
use tokio::process::Command;
pub async fn pandoc_export_handler(
Path(format): Path<String>,
Json(payload): Json<CompileRequest>,
) -> impl IntoResponse {
let supported_formats = ["docx", "latex", "markdown", "html"];
if !supported_formats.contains(&format.as_str()) {
return (StatusCode::BAD_REQUEST, "Unsupported format").into_response();
}
let _ext = match format.as_str() {
"latex" => "tex",
"markdown" => "md",
f => f,
};
let mut child = match Command::new("pandoc")
.arg("-f")
.arg("typst")
.arg("-t")
.arg(&format)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start pandoc: {}", e)).into_response(),
};
let mut stdin = child.stdin.take().unwrap();
let text = payload.text.clone();
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(text.as_bytes()).await;
});
let output = match child.wait_with_output().await {
Ok(o) => o,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Pandoc failed: {}", e)).into_response(),
};
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return (StatusCode::BAD_REQUEST, format!("Pandoc error: {}", err)).into_response();
}
let content_type = match format.as_str() {
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"html" => "text/html",
"latex" => "application/x-latex",
"markdown" => "text/markdown",
_ => "application/octet-stream",
};
(
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
output.stdout,
)
.into_response()
}
pub async fn pandoc_import_handler(
mut multipart: Multipart,
) -> impl IntoResponse {
let mut file_data = Vec::new();
let mut file_ext = String::new();
if let Some(field) = multipart.next_field().await.unwrap_or(None) {
if let Some(file_name) = field.file_name() {
if file_name.ends_with(".docx") {
file_ext = "docx".to_string();
} else if file_name.ends_with(".tex") {
file_ext = "latex".to_string();
} else if file_name.ends_with(".md") {
file_ext = "markdown".to_string();
} else if file_name.ends_with(".html") {
file_ext = "html".to_string();
} else {
file_ext = "markdown".to_string(); // fallback
}
}
if let Ok(bytes) = field.bytes().await {
file_data = bytes.to_vec();
}
}
if file_data.is_empty() {
return (StatusCode::BAD_REQUEST, "No file uploaded").into_response();
}
let mut child = match Command::new("pandoc")
.arg("-f")
.arg(&file_ext)
.arg("-t")
.arg("typst")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start pandoc: {}", e)).into_response(),
};
let mut stdin = child.stdin.take().unwrap();
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(&file_data).await;
});
let output = match child.wait_with_output().await {
Ok(o) => o,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Pandoc failed: {}", e)).into_response(),
};
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return (StatusCode::BAD_REQUEST, format!("Pandoc error: {}", err)).into_response();
}
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
output.stdout,
)
.into_response()
}
+12 -4
View File
@@ -1,9 +1,9 @@
use axum::{
routing::{get, post, put, delete},
routing::{get, post, put, delete, patch},
Router,
};
use axum_extra::extract::cookie::Key;
use sqlx::{Pool, Sqlite};
use sqlx::{Pool, Postgres};
use std::sync::Arc;
use std::collections::HashMap;
use tokio::sync::Mutex;
@@ -21,6 +21,7 @@ mod files;
mod handlers;
mod models;
mod world;
mod collab;
use compiler::TypstCompiler;
use handlers::{compile_handler, export_handler, yjs_handler};
@@ -29,7 +30,7 @@ use handlers::{compile_handler, export_handler, yjs_handler};
pub struct AppState {
pub compiler: Arc<Mutex<TypstCompiler>>,
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
pub db: Pool<Sqlite>,
pub db: Pool<Postgres>,
pub key: Key,
}
@@ -66,6 +67,8 @@ async fn main() {
let api_routes = Router::new()
.route("/compile", post(compile_handler))
.route("/export/{format}", post(export_handler))
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
.route("/import/pandoc", post(handlers::pandoc_import_handler))
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
@@ -78,8 +81,13 @@ async fn main() {
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
.route("/files/{id}/data", get(files::get_file_data))
.route("/docs", get(docs::list_documents).post(docs::create_document))
.route("/docs/accept-invite", get(collab::accept_invite))
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
.route("/docs/{id}/files", post(docs::upload_file));
.route("/docs/{id}/files", post(docs::upload_file))
.route("/docs/{id}/invite", post(collab::invite_collaborator))
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
.route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment));
let yjs_routes = Router::new()
.route("/{id}", get(yjs_handler));
+72 -1
View File
@@ -5,6 +5,7 @@ use sqlx::FromRow;
pub struct User {
pub id: String,
pub username: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
}
@@ -38,6 +39,10 @@ pub struct Document {
#[serde(skip_serializing)]
pub content: Option<Vec<u8>>,
pub thumbnail_svg: Option<String>,
pub public_role: Option<String>,
#[serde(default)]
#[sqlx(default)]
pub effective_role: Option<String>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
@@ -45,18 +50,20 @@ pub struct Document {
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateProfileRequest {
pub username: String,
pub email: String,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -82,6 +89,7 @@ pub struct CreateDocumentRequest {
pub struct UpdateDocumentRequest {
pub title: Option<String>,
pub folder_id: Option<String>,
pub public_role: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -90,3 +98,66 @@ pub struct StorageStats {
pub files_size_bytes: i64,
pub total_size_bytes: i64,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Collaborator {
pub id: String,
pub document_id: String,
pub user_id: String,
pub role: String,
pub created_at: chrono::NaiveDateTime,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Invitation {
pub id: String,
pub document_id: String,
pub role: String,
pub token: String,
pub created_at: chrono::NaiveDateTime,
pub expires_at: Option<chrono::NaiveDateTime>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Comment {
pub id: String,
pub document_id: String,
pub user_id: String,
pub content: String,
pub resolved: bool,
pub created_at: chrono::NaiveDateTime,
pub author_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateCommentRequest {
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateCommentRequest {
pub content: Option<String>,
pub resolved: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct DocumentVersion {
pub id: String,
pub document_id: String,
pub user_id: String,
pub content: String,
pub created_at: chrono::NaiveDateTime,
#[sqlx(default)]
pub author_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateVersionRequest {
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteRequest {
pub email: String,
pub role: String,
}