Updated Typst v0.14.2 and added SQLite database support

This commit is contained in:
2026-05-14 17:02:42 -04:00
parent c588867625
commit 61251b3ea7
18 changed files with 410 additions and 290 deletions
+3 -2
View File
@@ -10,7 +10,7 @@ RUN bun run build
FROM rust:alpine AS backend-builder FROM rust:alpine AS backend-builder
WORKDIR /app WORKDIR /app
RUN apk add --no-cache musl-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 https://github.com/typst/typst.git typst && cd typst && git checkout d6848a802e86a6269300f9768c054a641c2da77f RUN git clone --depth=1 https://github.com/typst/typst.git typst
COPY server/Cargo.* server/ COPY server/Cargo.* server/
COPY server/src server/src COPY server/src server/src
WORKDIR /app/server WORKDIR /app/server
@@ -19,7 +19,8 @@ RUN cargo build --release
# Final Runtime Image # Final Runtime Image
FROM alpine:3.19 FROM alpine:3.19
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libgcc openssl pandoc curl RUN apk add --no-cache libgcc openssl pandoc curl sqlite
RUN mkdir -p /data
RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist
COPY --from=frontend-builder /app/build /app/build COPY --from=frontend-builder /app/build /app/build
COPY --from=backend-builder /app/server/target/release/server /app/server COPY --from=backend-builder /app/server/target/release/server /app/server
+17 -15
View File
@@ -1,23 +1,25 @@
services: services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: typstdrive
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
app: app:
build: . build: .
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- DATABASE_URL=postgres://postgres:password@db:5432/typstdrive - DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
depends_on: - DB_TYPE=sqlite # set to "postgres" with a postgres DATABASE_URL to use PostgreSQL
- db volumes:
- appdata:/data
# Uncomment to use PostgreSQL instead of SQLite
# db:
# image: postgres:16-alpine
# environment:
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: password
# POSTGRES_DB: typstdrive
# ports:
# - "5433:5432"
# volumes:
# - appdata:/var/lib/postgresql/data
volumes: volumes:
pgdata: appdata:
+1 -1
View File
@@ -9,7 +9,7 @@ axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] } tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] } sqlx = { version = "0.8", features = ["postgres", "sqlite", "any", "runtime-tokio-rustls", "chrono", "uuid"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
+11 -12
View File
@@ -34,7 +34,7 @@ pub async fn register(
let user_id = Uuid::new_v4().to_string(); let user_id = Uuid::new_v4().to_string();
let result = sqlx::query_as::<_, User>( let result = sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, username, email, password_hash" "INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash"
) )
.bind(&user_id) .bind(&user_id)
.bind(&payload.username) .bind(&payload.username)
@@ -57,7 +57,7 @@ pub async fn login(
jar: SignedCookieJar, jar: SignedCookieJar,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> { ) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = $1") let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = ?")
.bind(&payload.email) .bind(&payload.email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -97,7 +97,7 @@ pub async fn update_profile(
return Err((StatusCode::BAD_REQUEST, "Username and email cannot be empty".to_string())); return Err((StatusCode::BAD_REQUEST, "Username and email cannot be empty".to_string()));
} }
let result = sqlx::query("UPDATE users SET username = $1, email = $2 WHERE id = $3") let result = sqlx::query("UPDATE users SET username = ?, email = ? WHERE id = ?")
.bind(&payload.username) .bind(&payload.username)
.bind(&payload.email) .bind(&payload.email)
.bind(&user_id) .bind(&user_id)
@@ -106,7 +106,7 @@ pub async fn update_profile(
match result { match result {
Ok(_) => { Ok(_) => {
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -130,14 +130,12 @@ pub async fn me(
State(state): State<AppState>, State(state): State<AppState>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<Json<User>, (StatusCode, String)> { ) -> Result<Json<User>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id = match jar.get("session_user_id").map(|c| c.value().to_string()) {
let user_id = match user_id {
Some(id) => id, Some(id) => id,
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())), None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
}; };
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -161,7 +159,7 @@ pub async fn change_password(
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string())); return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
} }
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -181,7 +179,7 @@ pub async fn change_password(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_string(); .to_string();
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&new_password_hash) .bind(&new_password_hash)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
@@ -198,8 +196,9 @@ pub async fn storage_stats(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// LENGTH() returns byte count for both BYTEA (Postgres) and BLOB (SQLite)
let docs_size: (i64,) = sqlx::query_as( let docs_size: (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0) FROM documents WHERE owner_id = $1" "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM documents WHERE owner_id = ?"
) )
.bind(&user_id) .bind(&user_id)
.fetch_one(&state.db) .fetch_one(&state.db)
@@ -207,7 +206,7 @@ pub async fn storage_stats(
.unwrap_or((0,)); .unwrap_or((0,));
let files_size: (i64,) = sqlx::query_as( let files_size: (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0) FROM files WHERE owner_id = $1" "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM files WHERE owner_id = ?"
) )
.bind(&user_id) .bind(&user_id)
.fetch_one(&state.db) .fetch_one(&state.db)
+50 -40
View File
@@ -21,8 +21,7 @@ pub async fn invite_collaborator(
let inviter_id = jar.get("session_user_id").map(|c| c.value().to_string()) let inviter_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".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 = ? AND owner_id = ?")
let doc_exists = sqlx::query_as::<_, (String,)>("SELECT id FROM documents WHERE id = $1 AND owner_id = $2")
.bind(&doc_id) .bind(&doc_id)
.bind(&inviter_id) .bind(&inviter_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -33,8 +32,7 @@ pub async fn invite_collaborator(
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string())); 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 = ?")
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = $1")
.bind(&payload.email) .bind(&payload.email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -43,7 +41,7 @@ pub async fn invite_collaborator(
if let Some(user) = invited_user { if let Some(user) = invited_user {
let collab_id = Uuid::new_v4().to_string(); let collab_id = Uuid::new_v4().to_string();
let _collab = sqlx::query_as::<_, Collaborator>( 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" "INSERT INTO collaborators (id, document_id, user_id, role) VALUES (?, ?, ?, ?) 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(&collab_id)
.bind(&doc_id) .bind(&doc_id)
@@ -53,13 +51,12 @@ pub async fn invite_collaborator(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Mock returning an invitation so frontend knows it succeeded
let inv = Invitation { let inv = Invitation {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
document_id: doc_id.to_string(), document_id: doc_id.to_string(),
role: payload.role.clone(), role: payload.role.clone(),
token: "direct-added".to_string(), token: "direct-added".to_string(),
created_at: chrono::Utc::now().naive_utc(), created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
expires_at: None, expires_at: None,
}; };
Ok(Json(inv)) Ok(Json(inv))
@@ -82,7 +79,7 @@ pub async fn accept_invite(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let invitation = sqlx::query_as::<_, Invitation>( let invitation = sqlx::query_as::<_, Invitation>(
"SELECT id, document_id, role, token, created_at, expires_at FROM invitations WHERE token = $1" "SELECT id, document_id, role, token, created_at, expires_at FROM invitations WHERE token = ?"
) )
.bind(&query.token) .bind(&query.token)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -93,7 +90,7 @@ pub async fn accept_invite(
let collab_id = Uuid::new_v4().to_string(); let collab_id = Uuid::new_v4().to_string();
let collab = sqlx::query_as::<_, Collaborator>( 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" "INSERT INTO collaborators (id, document_id, user_id, role) VALUES (?, ?, ?, ?) 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(&collab_id)
.bind(&invitation.document_id) .bind(&invitation.document_id)
@@ -114,12 +111,11 @@ pub async fn get_comments(
let _user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let _user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Basic access control omitted for brevity
let comments = sqlx::query_as::<_, Comment>( 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 \ "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM comments c \ FROM comments c \
LEFT JOIN users u ON c.user_id = u.id \ LEFT JOIN users u ON c.user_id = u.id \
WHERE c.document_id = $1 \ WHERE c.document_id = ? \
ORDER BY c.created_at ASC" ORDER BY c.created_at ASC"
) )
.bind(&doc_id) .bind(&doc_id)
@@ -141,20 +137,22 @@ pub async fn add_comment(
let comment_id = Uuid::new_v4().to_string(); let comment_id = Uuid::new_v4().to_string();
let comment = sqlx::query_as::<_, Comment>( sqlx::query("INSERT INTO comments (id, document_id, user_id, content) VALUES (?, ?, ?, ?)")
"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(&comment_id)
.bind(&doc_id) .bind(&doc_id)
.bind(&user_id) .bind(&user_id)
.bind(&payload.content) .bind(&payload.content)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let 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 = ?"
)
.bind(&comment_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -171,8 +169,9 @@ pub async fn create_version(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Check access let doc = sqlx::query_as::<_, crate::models::Document>(
let doc = sqlx::query_as::<_, crate::models::Document>("SELECT * FROM documents WHERE id = $1") "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
)
.bind(&doc_id) .bind(&doc_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -180,7 +179,7 @@ pub async fn create_version(
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
let is_owner = doc.owner_id == user_id; 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") let role = sqlx::query_scalar::<_, String>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?")
.bind(&doc_id) .bind(&doc_id)
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -193,13 +192,22 @@ pub async fn create_version(
let version_id = uuid::Uuid::new_v4().to_string(); let version_id = uuid::Uuid::new_v4().to_string();
let version = sqlx::query_as::<_, crate::models::DocumentVersion>( sqlx::query("INSERT INTO document_versions (id, document_id, user_id, content) VALUES (?, ?, ?, ?)")
"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(&version_id)
.bind(&doc_id) .bind(&doc_id)
.bind(&user_id) .bind(&user_id)
.bind(&payload.content) .bind(&payload.content)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let version = 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.id = ?"
)
.bind(&version_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -215,12 +223,11 @@ pub async fn get_versions(
let _user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let _user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Basic access check
let versions = sqlx::query_as::<_, crate::models::DocumentVersion>( 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 \ "SELECT v.id, v.document_id, v.user_id, v.content, v.created_at, u.username as author_name \
FROM document_versions v \ FROM document_versions v \
LEFT JOIN users u ON v.user_id = u.id \ LEFT JOIN users u ON v.user_id = u.id \
WHERE v.document_id = $1 \ WHERE v.document_id = ? \
ORDER BY v.created_at DESC" ORDER BY v.created_at DESC"
) )
.bind(&doc_id) .bind(&doc_id)
@@ -244,7 +251,7 @@ pub async fn update_comment(
"SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \
FROM comments c \ FROM comments c \
LEFT JOIN users u ON c.user_id = u.id \ LEFT JOIN users u ON c.user_id = u.id \
WHERE c.id = $1 AND c.user_id = $2" WHERE c.id = ? AND c.user_id = ?"
) )
.bind(&comment_id) .bind(&comment_id)
.bind(&user_id) .bind(&user_id)
@@ -260,18 +267,21 @@ pub async fn update_comment(
comment.resolved = r; comment.resolved = r;
} }
let updated_comment = sqlx::query_as::<_, Comment>( sqlx::query("UPDATE comments SET content = ?, resolved = ? WHERE id = ?")
"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.content)
.bind(comment.resolved) .bind(comment.resolved)
.bind(&comment.id) .bind(&comment.id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let updated_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 = ?"
)
.bind(&comment.id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -287,7 +297,7 @@ pub async fn delete_comment(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".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") let result = sqlx::query("DELETE FROM comments WHERE id = ? AND user_id = ?")
.bind(&comment_id) .bind(&comment_id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
-106
View File
@@ -1,106 +0,0 @@
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
pub async fn init_db() -> Pool<Postgres> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:[email protected]:5432/typstdrive".to_string());
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to create Postgres pool. Make sure your database is running.");
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 REFERENCES users(id),
parent_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
title TEXT NOT NULL,
content BYTEA,
thumbnail_svg TEXT,
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 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 BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
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
);
"#;
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");
}
}
// 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
.unwrap_or_else(|e| {
eprintln!("Warning: Failed to add public_role column (might already exist): {}", e);
Default::default()
});
pool
}
+36
View File
@@ -0,0 +1,36 @@
mod postgres;
mod sqlite;
use sqlx::any::AnyPoolOptions;
use sqlx::AnyPool;
pub async fn init_db() -> AnyPool {
sqlx::any::install_default_drivers();
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:[email protected]:5432/typstdrive".to_string());
// DB_TYPE can override URL-based detection: "sqlite" or "postgres"
let db_type = std::env::var("DB_TYPE")
.unwrap_or_else(|_| {
if db_url.starts_with("sqlite") {
"sqlite".to_string()
} else {
"postgres".to_string()
}
});
let pool = AnyPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to connect to database. Check DATABASE_URL.");
match db_type.as_str() {
"sqlite" => sqlite::init_schema(&pool).await,
"postgres" => postgres::init_schema(&pool).await,
other => panic!("Unknown DB_TYPE '{}'. Expected 'sqlite' or 'postgres'.", other),
}
pool
}
+94
View File
@@ -0,0 +1,94 @@
use sqlx::AnyPool;
pub async fn init_schema(pool: &AnyPool) {
let statements = [
"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 REFERENCES users(id),
parent_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
title TEXT NOT NULL,
content BYTEA,
thumbnail_svg TEXT,
public_role TEXT DEFAULT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
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 BYTEA NOT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"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 TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
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 TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
expires_at TEXT
)",
"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 TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"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 TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"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 TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
];
for stmt in &statements {
sqlx::query(stmt)
.execute(pool)
.await
.expect("Failed to execute Postgres schema");
}
// Idempotent migration for existing databases with TIMESTAMP columns
sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT")
.execute(pool)
.await
.unwrap_or_else(|e| {
eprintln!("Warning: public_role migration: {}", e);
Default::default()
});
}
+90
View File
@@ -0,0 +1,90 @@
use sqlx::AnyPool;
pub async fn init_schema(pool: &AnyPool) {
sqlx::query("PRAGMA foreign_keys = ON")
.execute(pool)
.await
.expect("Failed to enable SQLite foreign keys");
let statements = [
"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 REFERENCES users(id),
parent_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
title TEXT NOT NULL,
content BLOB,
thumbnail_svg TEXT,
public_role TEXT DEFAULT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
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 TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"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 TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
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 TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
expires_at TEXT
)",
"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 INTEGER DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS document_history (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content BLOB NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"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 TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
];
for stmt in &statements {
sqlx::query(stmt)
.execute(pool)
.await
.expect("Failed to execute SQLite schema");
}
}
+10 -15
View File
@@ -27,7 +27,7 @@ pub async fn list_documents(
let docs = if let Some(folder_id) = query.folder_id { let docs = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, Document>( sqlx::query_as::<_, Document>(
"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" "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
) )
.bind(&user_id) .bind(&user_id)
.bind(&folder_id) .bind(&folder_id)
@@ -36,7 +36,7 @@ pub async fn list_documents(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else { } else {
sqlx::query_as::<_, Document>( sqlx::query_as::<_, Document>(
"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" "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -70,7 +70,7 @@ pub async fn create_document(
}; };
let doc = sqlx::query_as::<_, Document>( let doc = sqlx::query_as::<_, Document>(
"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" "INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at"
) )
.bind(&doc_id) .bind(&doc_id)
.bind(&user_id) .bind(&user_id)
@@ -92,7 +92,7 @@ pub async fn get_document(
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let mut doc = sqlx::query_as::<_, Document>( 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" "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
) )
.bind(&id) .bind(&id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -105,8 +105,7 @@ pub async fn get_document(
if let Some(uid) = &user_id_opt { if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid { if &doc.owner_id == uid {
effective_role = "owner".to_string(); effective_role = "owner".to_string();
} else { } else if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?")
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(&id) .bind(&id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -115,7 +114,6 @@ pub async fn get_document(
effective_role = role; effective_role = role;
} }
} }
}
if effective_role == "none" { if effective_role == "none" {
if let Some(pr) = &doc.public_role { if let Some(pr) = &doc.public_role {
@@ -142,9 +140,8 @@ pub async fn update_document(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut doc = sqlx::query_as::<_, Document>( 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 AND owner_id = $2" "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?"
) )
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
@@ -171,9 +168,8 @@ pub async fn update_document(
} }
} }
let doc = sqlx::query_as::<_, Document>( let doc = sqlx::query_as::<_, Document>(
"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" "UPDATE documents SET title = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at"
) )
.bind(&doc.title) .bind(&doc.title)
.bind(&doc.folder_id) .bind(&doc.folder_id)
@@ -195,7 +191,7 @@ pub async fn delete_document(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let result = sqlx::query("DELETE FROM documents WHERE id = $1 AND owner_id = $2") let result = sqlx::query("DELETE FROM documents WHERE id = ? AND owner_id = ?")
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
@@ -218,8 +214,7 @@ pub async fn upload_file(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .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(&doc_id)
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -248,7 +243,7 @@ pub async fn upload_file(
} }
} }
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)") sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(&file_id) .bind(&file_id)
.bind(&user_id) .bind(&user_id)
.bind(&doc_id) .bind(&doc_id)
+14 -16
View File
@@ -28,7 +28,7 @@ pub async fn list_files(
let files = if let Some(folder_id) = query.folder_id { let files = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, File>( sqlx::query_as::<_, File>(
"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" "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"
) )
.bind(&user_id) .bind(&user_id)
.bind(&folder_id) .bind(&folder_id)
@@ -37,7 +37,7 @@ pub async fn list_files(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else { } else {
sqlx::query_as::<_, File>( sqlx::query_as::<_, File>(
"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" "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"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -79,7 +79,7 @@ pub async fn upload_file_global(
} }
} }
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)") sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&file_id) .bind(&file_id)
.bind(&user_id) .bind(&user_id)
.bind(&query.folder_id) .bind(&query.folder_id)
@@ -108,7 +108,7 @@ pub async fn get_file_data(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".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 = $1 AND owner_id = $2") let file = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT mime_type, data FROM files WHERE id = ? AND owner_id = ?")
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -133,7 +133,7 @@ pub async fn delete_file(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2") let result = sqlx::query("DELETE FROM files WHERE id = ? AND owner_id = ?")
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
@@ -154,26 +154,24 @@ pub async fn list_fonts(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let files = sqlx::query_as::<_, (String,)>( let files = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name FROM files WHERE owner_id = $1" "SELECT name, data FROM files WHERE owner_id = ?"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut fonts = Vec::new(); let mut families = std::collections::BTreeSet::new();
for (name,) in files { for (name, data) in files {
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
if let Some(stem) = std::path::Path::new(&name).file_stem() { for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) {
if let Some(stem_str) = stem.to_str() { families.insert(font.info().family.clone());
fonts.push(stem_str.to_string());
}
} }
} }
} }
Ok(Json(fonts)) Ok(Json(families.into_iter().collect()))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -192,7 +190,7 @@ pub async fn update_file(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut file = sqlx::query_as::<_, File>( let mut file = sqlx::query_as::<_, File>(
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE id = $1 AND owner_id = $2" "SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE id = ? AND owner_id = ?"
) )
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
@@ -213,7 +211,7 @@ pub async fn update_file(
} }
let file = sqlx::query_as::<_, File>( let file = sqlx::query_as::<_, File>(
"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" "UPDATE files SET name = ?, folder_id = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at"
) )
.bind(&file.name) .bind(&file.name)
.bind(&file.folder_id) .bind(&file.folder_id)
+5 -8
View File
@@ -27,7 +27,7 @@ pub async fn list_folders(
let folders = if let Some(parent_id) = query.parent_id { let folders = if let Some(parent_id) = query.parent_id {
sqlx::query_as::<_, Folder>( sqlx::query_as::<_, Folder>(
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = $1 AND parent_id = $2 ORDER BY name ASC" "SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id = ? ORDER BY name ASC"
) )
.bind(&user_id) .bind(&user_id)
.bind(&parent_id) .bind(&parent_id)
@@ -36,7 +36,7 @@ pub async fn list_folders(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
} else { } else {
sqlx::query_as::<_, Folder>( sqlx::query_as::<_, Folder>(
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = $1 AND parent_id IS NULL ORDER BY name ASC" "SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id IS NULL ORDER BY name ASC"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -58,7 +58,7 @@ pub async fn create_folder(
let folder_id = Uuid::new_v4().to_string(); let folder_id = Uuid::new_v4().to_string();
let folder = sqlx::query_as::<_, Folder>( let folder = sqlx::query_as::<_, Folder>(
"INSERT INTO folders (id, owner_id, parent_id, name) VALUES ($1, $2, $3, $4) RETURNING id, owner_id, parent_id, name, created_at" "INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?) RETURNING id, owner_id, parent_id, name, created_at"
) )
.bind(&folder_id) .bind(&folder_id)
.bind(&user_id) .bind(&user_id)
@@ -79,9 +79,7 @@ pub async fn delete_folder(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
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(&id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
@@ -110,7 +108,7 @@ pub async fn update_folder(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let folder = sqlx::query_as::<_, Folder>( let folder = sqlx::query_as::<_, Folder>(
"UPDATE folders SET name = $1 WHERE id = $2 AND owner_id = $3 RETURNING id, owner_id, parent_id, name, created_at" "UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at"
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&id) .bind(&id)
@@ -124,4 +122,3 @@ pub async fn update_folder(
None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())), None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())),
} }
} }
+32 -19
View File
@@ -80,7 +80,7 @@ pub async fn yjs_handler(
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc_info = sqlx::query_as::<_, Document>( 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" "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
) )
.bind(&id) .bind(&id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -91,7 +91,7 @@ pub async fn yjs_handler(
if let Some(uid) = &user_id_opt { if let Some(uid) = &user_id_opt {
if &d.owner_id == uid { if &d.owner_id == uid {
is_viewer = false; 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'") } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'")
.bind(&id) .bind(&id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -136,7 +136,7 @@ pub async fn yjs_handler(
interval.tick().await; interval.tick().await;
let doc = save_awareness.read().await; let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default()); let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let _ = sqlx::query("UPDATE documents SET content = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2") let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(content) .bind(content)
.bind(&save_id) .bind(&save_id)
.execute(&save_db) .execute(&save_db)
@@ -176,15 +176,19 @@ pub async fn compile_handler(
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(doc_id) = &payload.document_id { 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, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(doc_id).fetch_one(&state.db).await { 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 = ?"
// Allow compilation if owner or if it has a public role or if they are a collaborator )
.bind(doc_id)
.fetch_one(&state.db)
.await
{
let mut has_access = false; let mut has_access = false;
if let Some(uid) = &user_id_opt { if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid { if &doc.owner_id == uid {
has_access = true; has_access = true;
can_save_thumbnail = 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") } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?")
.bind(doc_id) .bind(doc_id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -203,7 +207,7 @@ pub async fn compile_handler(
} }
if has_access { if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1") if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id) .bind(doc.owner_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
@@ -221,7 +225,7 @@ pub async fn compile_handler(
Ok((svgs, thumbnail, stats)) => { Ok((svgs, thumbnail, stats)) => {
if let Some(doc_id) = &payload.document_id { if let Some(doc_id) = &payload.document_id {
if can_save_thumbnail { if can_save_thumbnail {
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = $1 WHERE id = $2") let _ = sqlx::query("UPDATE documents SET thumbnail_svg = ? WHERE id = ?")
.bind(&thumbnail) .bind(&thumbnail)
.bind(doc_id) .bind(doc_id)
.execute(&state.db) .execute(&state.db)
@@ -264,13 +268,18 @@ pub async fn export_handler(
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(doc_id) = &payload.document_id { 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, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(doc_id).fetch_one(&state.db).await { 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 = ?"
)
.bind(doc_id)
.fetch_one(&state.db)
.await
{
let mut has_access = false; let mut has_access = false;
if let Some(uid) = &user_id_opt { if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid { if &doc.owner_id == uid {
has_access = true; has_access = true;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?")
.bind(doc_id) .bind(doc_id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -288,7 +297,7 @@ pub async fn export_handler(
} }
if has_access { if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1") if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id) .bind(doc.owner_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
@@ -324,8 +333,6 @@ pub async fn export_handler(
}, },
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) { "svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
Ok((svgs, _, _)) => { Ok((svgs, _, _)) => {
let mut combined = String::new(); let mut combined = String::new();
for svg in svgs { for svg in svgs {
combined.push_str(&svg); combined.push_str(&svg);
@@ -425,7 +432,7 @@ pub async fn pandoc_import_handler(
} else if file_name.ends_with(".html") { } else if file_name.ends_with(".html") {
file_ext = "html".to_string(); file_ext = "html".to_string();
} else { } else {
file_ext = "markdown".to_string(); // fallback file_ext = "markdown".to_string();
} }
} }
if let Ok(bytes) = field.bytes().await { if let Ok(bytes) = field.bytes().await {
@@ -482,7 +489,13 @@ pub async fn lsp_handler(
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc = match 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(&id).fetch_optional(&state.db).await { let doc = match 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 = ?"
)
.bind(&id)
.fetch_optional(&state.db)
.await
{
Ok(Some(d)) => d, Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND, "Document not found").into_response(), _ => return (StatusCode::NOT_FOUND, "Document not found").into_response(),
}; };
@@ -491,7 +504,7 @@ pub async fn lsp_handler(
if let Some(uid) = &user_id_opt { if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid { if &doc.owner_id == uid {
has_access = true; has_access = true;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?")
.bind(&id) .bind(&id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -513,7 +526,7 @@ pub async fn lsp_handler(
} }
let mut files_map = std::collections::HashMap::new(); let mut files_map = std::collections::HashMap::new();
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1") if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id) .bind(doc.owner_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
+2 -2
View File
@@ -3,7 +3,7 @@ use axum::{
Router, Router,
}; };
use axum_extra::extract::cookie::Key; use axum_extra::extract::cookie::Key;
use sqlx::{Pool, Postgres}; use sqlx::AnyPool;
use std::sync::Arc; use std::sync::Arc;
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -30,7 +30,7 @@ use handlers::{compile_handler, export_handler, yjs_handler};
pub struct AppState { pub struct AppState {
pub compiler: Arc<Mutex<TypstCompiler>>, pub compiler: Arc<Mutex<TypstCompiler>>,
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>, pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
pub db: Pool<Postgres>, pub db: AnyPool,
pub key: Key, pub key: Key,
} }
+10 -10
View File
@@ -16,7 +16,7 @@ pub struct Folder {
pub owner_id: String, pub owner_id: String,
pub parent_id: Option<String>, pub parent_id: Option<String>,
pub name: String, pub name: String,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -27,7 +27,7 @@ pub struct File {
pub folder_id: Option<String>, pub folder_id: Option<String>,
pub name: String, pub name: String,
pub mime_type: String, pub mime_type: String,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -43,8 +43,8 @@ pub struct Document {
#[serde(default)] #[serde(default)]
#[sqlx(default)] #[sqlx(default)]
pub effective_role: Option<String>, pub effective_role: Option<String>,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
pub updated_at: chrono::NaiveDateTime, pub updated_at: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -105,7 +105,7 @@ pub struct Collaborator {
pub document_id: String, pub document_id: String,
pub user_id: String, pub user_id: String,
pub role: String, pub role: String,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -114,8 +114,8 @@ pub struct Invitation {
pub document_id: String, pub document_id: String,
pub role: String, pub role: String,
pub token: String, pub token: String,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
pub expires_at: Option<chrono::NaiveDateTime>, pub expires_at: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -125,7 +125,7 @@ pub struct Comment {
pub user_id: String, pub user_id: String,
pub content: String, pub content: String,
pub resolved: bool, pub resolved: bool,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
pub author_name: Option<String>, pub author_name: Option<String>,
} }
@@ -140,13 +140,13 @@ pub struct UpdateCommentRequest {
pub resolved: Option<bool>, pub resolved: Option<bool>,
} }
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct DocumentVersion { pub struct DocumentVersion {
pub id: String, pub id: String,
pub document_id: String, pub document_id: String,
pub user_id: String, pub user_id: String,
pub content: String, pub content: String,
pub created_at: chrono::NaiveDateTime, pub created_at: String,
#[sqlx(default)] #[sqlx(default)]
pub author_name: Option<String>, pub author_name: Option<String>,
} }
+3 -12
View File
@@ -42,25 +42,16 @@ impl MemoryWorld {
} }
} }
// Add custom fonts from files // Add custom fonts from files, registered only by their embedded metadata
// so that all variants (Bold, Italic, etc.) resolve correctly under one family name.
for (name, data) in &files { for (name, data) in &files {
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
for font in Font::iter(Bytes::new(data.clone())) { for font in Font::iter(Bytes::new(data.clone())) {
let info = font.info().clone(); book.push(font.info().clone());
book.push(info.clone());
fonts.push(font.clone());
let mut custom_info = info;
if let Some(stem) = std::path::Path::new(name).file_stem() {
if let Some(stem_str) = stem.to_str() {
custom_info.family = stem_str.to_string();
book.push(custom_info);
fonts.push(font); fonts.push(font);
} }
} }
} }
}
}
Self { Self {
library: typst::utils::LazyHash::new(Library::builder().build()), library: typst::utils::LazyHash::new(Library::builder().build()),
+3 -3
View File
@@ -205,9 +205,9 @@
if (data.filename) { if (data.filename) {
if (data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf')) { if (data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf')) {
triggerLspReconnect.update(n => n + 1); triggerLspReconnect.update(n => n + 1);
let stem = data.filename.substring(0, data.filename.lastIndexOf('.')); const fontName = data.font_family || data.filename.substring(0, data.filename.lastIndexOf('.'));
if (!uploadedFonts.includes(stem)) { if (!uploadedFonts.includes(fontName)) {
uploadedFonts = [...uploadedFonts, stem]; uploadedFonts = [...uploadedFonts, fontName];
} }
} }
const view = $editorViewStore; const view = $editorViewStore;
+1 -1
Submodule typst updated: d6848a802e...de6f400976