diff --git a/Dockerfile b/Dockerfile index 75276db..d5c7b1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN npm run build # Build Backend FROM rust:alpine AS backend-builder WORKDIR /app -RUN apk add --no-cache musl-dev sqlite-dev openssl-dev openssl-libs-static pkgconfig git +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git RUN git clone -b v0.14.2 --single-branch https://github.com/typst/typst.git typst COPY server/Cargo.* server/ COPY server/src server/src @@ -19,7 +19,7 @@ RUN cargo build --release # Final Runtime Image FROM alpine:3.19 WORKDIR /app -RUN apk add --no-cache libgcc sqlite-libs openssl +RUN apk add --no-cache libgcc openssl pandoc COPY --from=frontend-builder /app/build /app/build COPY --from=backend-builder /app/server/target/release/server /app/server ENV PORT=3000 diff --git a/README.md b/README.md index fcf234b..140284e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # TypstDrive -[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/your-username/typstdrive) +[![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/your-username/typstdrive) [![Typst Version](https://img.shields.io/badge/Typst-0.14.2-239dad?logo=typst&logoColor=white)](https://typst.app/) [![Rust](https://img.shields.io/badge/Rust-1.82+-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/) [![SvelteKit](https://img.shields.io/badge/SvelteKit-5-ff3e00?logo=svelte)](https://kit.svelte.dev/) [![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/) [![Bun](https://img.shields.io/badge/Bun-latest-black?logo=bun)](https://bun.sh/) -[![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) TypstDrive is a collaborative web editor for Typst. With built-in dark mode, multiple themes, and a clean Google Docs-like interface, it makes creating and sharing documents effortless. @@ -16,9 +16,9 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul - **Real-Time Collaboration**: Powered by Yjs and CodeMirror 6, see changes and cursors from other users instantly. - **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls. - **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode. -- **Export Options**: Export your compiled documents directly to PDF, PNG, or SVG. -- **User Authentication**: Secure accounts and workspaces for all your documents. -- **Link Sharing**: Share documents with configurable permissions (Viewer / Editor). +- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations. +- **User Authentication & Document Access**: Secure accounts, workspaces, and sharing features via email-based collaborator invitations (Editor or Viewer roles) for all your documents. +- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay. - **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents. ## Fonts & Images @@ -75,7 +75,7 @@ TypstDrive is completely self-hostable. We provide a Docker image that packages ### Data Storage -The SQLite database containing users and documents is stored in the `./data` directory relative to your `docker-compose.yml` file. This is automatically mounted by Docker Compose to ensure your data persists across container restarts. +The PostgreSQL database containing users and documents is persisted via the Docker volume `pgdata`. This is automatically configured in `docker-compose.yml` to ensure your data persists across container restarts. ## Local Development @@ -86,8 +86,9 @@ If you'd like to contribute or run TypstDrive without Docker: 2. Run the dev server: `npm run dev` ### Backend -1. Navigate to the `server/` directory. -2. Build and run: `cargo run` +1. Start the local database: `docker-compose up -d db` +2. Navigate to the `server/` directory. +3. Build and run: `cargo run` Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically. diff --git a/docker-compose.yml b/docker-compose.yml index 81f8d14..f839cb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,25 @@ +version: '3.8' + services: - typstdrive: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: typstdrive + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + app: build: . - container_name: typstdrive ports: - "3000:3000" environment: - - DATABASE_URL=sqlite:/app/data/typstdrive.db?mode=rwc - volumes: - - ./data:/app/data - restart: unless-stopped + - DATABASE_URL=postgres://postgres:password@db:5432/typstdrive + depends_on: + - db + +volumes: + pgdata: diff --git a/package.json b/package.json index f1f539f..e579af1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "typstdrive", "private": true, - "version": "1.0.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite dev --host", diff --git a/server/Cargo.toml b/server/Cargo.toml index ee405a2..45bb435 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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" diff --git a/server/src/auth.rs b/server/src/auth.rs index a4643ee..6833006 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -20,8 +20,8 @@ pub async fn register( State(state): State, Json(payload): Json, ) -> Result, (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, ) -> Result<(SignedCookieJar, Json), (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) diff --git a/server/src/collab.rs b/server/src/collab.rs new file mode 100644 index 0000000..73677e5 --- /dev/null +++ b/server/src/collab.rs @@ -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, + Path(doc_id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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, + jar: SignedCookieJar, + Query(query): Query, +) -> Result, (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, + Path(doc_id): Path, + jar: SignedCookieJar, +) -> Result>, (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, + Path(doc_id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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, + Path(doc_id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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, + Path(doc_id): Path, + jar: SignedCookieJar, +) -> Result>, (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, + Path(comment_id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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, + Path(comment_id): Path, + jar: SignedCookieJar, +) -> Result { + 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) +} diff --git a/server/src/compiler.rs b/server/src/compiler.rs index 2667b59..116ec74 100644 --- a/server/src/compiler.rs +++ b/server/src/compiler.rs @@ -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 { diff --git a/server/src/db.rs b/server/src/db.rs index bf5b26f..d325a28 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -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 { - 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 { + 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 } diff --git a/server/src/docs.rs b/server/src/docs.rs index ca8d79e..f08bd77 100644 --- a/server/src/docs.rs +++ b/server/src/docs.rs @@ -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, jar: SignedCookieJar, ) -> Result, (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)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?") + let doc_exists = sqlx::query_as::<_, (String, Option)>("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}))) diff --git a/server/src/files.rs b/server/src/files.rs index e092395..6157399 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -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)>("SELECT mime_type, data FROM files WHERE id = ? AND owner_id = ?") + let file = sqlx::query_as::<_, (String, Vec)>("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) diff --git a/server/src/folders.rs b/server/src/folders.rs index a62ea2d..98a6841 100644 --- a/server/src/folders.rs +++ b/server/src/folders.rs @@ -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) diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 6d43686..38094eb 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -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, + is_viewer: bool, +} + +impl Stream for ViewerFilterStream { + type Item = Result, yrs::sync::Error>; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + 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, State(state): State, + 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, + jar: axum_extra::extract::cookie::SignedCookieJar, Json(payload): Json, ) -> 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)>("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)>("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, + jar: axum_extra::extract::cookie::SignedCookieJar, Path(format): Path, Json(payload): Json, ) -> 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)>("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)>("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, + Json(payload): Json, +) -> 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() +} diff --git a/server/src/main.rs b/server/src/main.rs index ec04a42..bd1e5cb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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>, pub bcast_map: Arc>>>, - pub db: Pool, + pub db: Pool, 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)); diff --git a/server/src/models.rs b/server/src/models.rs index 4eab573..6d8a1f4 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -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>, pub thumbnail_svg: Option, + pub public_role: Option, + #[serde(default)] + #[sqlx(default)] + pub effective_role: Option, 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, pub folder_id: Option, + pub public_role: Option, } #[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, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateCommentRequest { + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateCommentRequest { + pub content: Option, + pub resolved: Option, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateVersionRequest { + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InviteRequest { + pub email: String, + pub role: String, +} diff --git a/src/lib/components/CommentsSidebar.svelte b/src/lib/components/CommentsSidebar.svelte new file mode 100644 index 0000000..a7ad6ac --- /dev/null +++ b/src/lib/components/CommentsSidebar.svelte @@ -0,0 +1,183 @@ + + +
+ +
+
+ +

Comments

+ {comments.length} +
+ +
+ + +
+ {#if loading} +
+ +
+ {:else if error} +
+ {error} +
+ {:else if comments.length === 0} +
+ +

No comments yet

+
+ {:else} + {#each comments as comment} +
+
+
+
+ {(comment.author_name || 'A').substring(0, 1).toUpperCase()} +
+
+

{comment.author_name || 'Anonymous'}

+

{formatDate(comment.created_at)}

+
+
+ + +
+ {#if $userStore?.id === comment.user_id} + + {/if} + +
+
+

{comment.content}

+
+ {/each} + {/if} +
+ + +
+
+ + +
+

Press Enter to post, Shift+Enter for newline

+
+
\ No newline at end of file diff --git a/src/lib/components/PresentationMode.svelte b/src/lib/components/PresentationMode.svelte new file mode 100644 index 0000000..5f02da6 --- /dev/null +++ b/src/lib/components/PresentationMode.svelte @@ -0,0 +1,456 @@ + + +
+ + + {#if tool === 'laser' && laserPos.visible && !showGrid} +
+ {/if} + + + {#if showGrid} +
+
+ {#each svgs as svg, i} + + {/each} +
+
+ {/if} + + +
+
+ + + + + + {#if tool === 'pen' || tool === 'highlighter'} +
+
+ {#each colors as color} + + {/each} +
+ {/if} + +
+ + + +
+ +
+ + +
+
+ + +
+ {#if svgs.length > 0 && !showGrid} +
+ {@html svgs[currentSlide]} + + + + + + +
+ {:else if svgs.length === 0} +
No slides available to present.
+ {/if} +
+ + + {#if svgs.length > 0} +
+ {#if svgs.length > 1} + + + + {/if} +
+ {/if} + +
+ + \ No newline at end of file diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte index c7ad5b4..af589c3 100644 --- a/src/lib/components/ShareModal.svelte +++ b/src/lib/components/ShareModal.svelte @@ -9,6 +9,43 @@ let copied = $state(false); let role = $state('editor'); + let inviteEmail = $state(''); + let inviteRole = $state('editor'); + let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle'); + let inviteMessage = $state(''); + + async function inviteUser(e: Event) { + e.preventDefault(); + if (!docId || !inviteEmail.trim()) return; + + inviteStatus = 'loading'; + inviteMessage = ''; + + try { + const res = await fetch(`/api/docs/${docId}/invite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }) + }); + + if (res.ok) { + inviteStatus = 'success'; + inviteMessage = 'User invited successfully!'; + inviteEmail = ''; + } else { + const text = await res.text(); + inviteStatus = 'error'; + inviteMessage = text || 'Failed to invite user'; + } + } catch (err) { + console.error(err); + inviteStatus = 'error'; + inviteMessage = 'Network error occurred'; + } + } + onMount(() => { const baseUrl = window.location.origin; @@ -45,52 +82,62 @@