big dev changes

This commit is contained in:
Mateusz Gruszczyński
2026-07-21 10:11:57 +02:00
parent 4bb92a343b
commit 0b4b1753a0
23 changed files with 690 additions and 51 deletions
+98 -5
View File
@@ -53,6 +53,10 @@ pub struct CreateNoteRequest {
name: String,
#[serde(default)]
password: Option<String>,
#[serde(default)]
protect: bool,
#[serde(default)]
created_by: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -84,6 +88,8 @@ pub struct NoteListItem {
created_at: String,
updated_at: String,
url: String,
protected: bool,
created_by: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -93,6 +99,7 @@ pub struct NoteInfo {
slug: String,
title: String,
protected: bool,
note_protected: bool,
created_at: String,
updated_at: String,
}
@@ -141,6 +148,8 @@ pub async fn open_workspace(
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
})
.collect();
@@ -163,7 +172,8 @@ pub async fn create_note(
}
let slug = unique_note_slug(&state, workspace.id, &base).await?;
let note = db::create_note(&state.db, workspace.id, &slug, title).await?;
let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::<String>());
let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).await?;
Ok((
StatusCode::CREATED,
Json(NoteListItem {
@@ -172,6 +182,8 @@ pub async fn create_note(
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
}),
))
}
@@ -193,6 +205,7 @@ pub async fn note_info(
slug: note.slug,
title: note.title,
protected: workspace.password_hash.is_some(),
note_protected: note.protected,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
}))
@@ -547,8 +560,30 @@ pub async fn upload_pad_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored);
}
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string();
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
}
pub async fn pad_files(
State(state): State<SharedState>,
Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = pad.content.contains(&file.url);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
}
file.created_at = db::normalize_timestamp(&file.created_at);
}
Ok(Json(files))
}
pub async fn upload_note_file(
@@ -586,11 +621,69 @@ pub async fn upload_note_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored);
}
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string();
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
}
pub async fn delete_note(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); }
db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn note_files(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = note.content.contains(&file.url);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
}
file.created_at = db::normalize_timestamp(&file.created_at);
}
Ok(Json(files))
}
pub async fn delete_note_file(
State(state): State<SharedState>,
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() {
return Err(ApiError::unauthorized());
}
let file = db::find_note_file(&state.db, note.id, file_id).await?
.ok_or_else(ApiError::not_found_file)?;
let relative = file.url.trim_start_matches('/').split('/').collect::<Vec<_>>();
if relative.len() == 3 && relative[0] == "f" {
let directory = format!("{}_{}", note.id, relative[1]);
let path = std::path::Path::new(&state.files_dir).join("notes").join(directory).join(sanitize_filename(relative[2]));
match tokio::fs::remove_file(&path).await {
Ok(()) => {},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
Err(_) => return Err(ApiError::internal("Failed to delete the file")),
}
}
db::delete_note_file(&state.db, note.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn download_file(
State(state): State<SharedState>,
Path((token, filename)): Path<(String, String)>,
+7 -3
View File
@@ -25,14 +25,14 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/pads/{slug}/history", post(api::pad_history))
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore))
.route("/api/pads/{slug}/files", post(api::upload_pad_file))
.route("/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files))
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
.route("/api/workspaces/{workspace_slug}/notes", post(api::create_note))
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info),
get(api::note_info).delete(api::delete_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
@@ -48,7 +48,11 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
post(api::upload_note_file),
post(api::upload_note_file).put(api::note_files),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}",
axum::routing::delete(api::delete_note_file),
)
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
.route(
+186 -12
View File
@@ -44,6 +44,39 @@ pub struct Note {
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub protected: bool,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNote {
id: i64,
workspace_id: i64,
slug: String,
title: String,
content: String,
created_at: String,
updated_at: String,
owner_map: String,
protected: i64,
created_by: Option<String>,
}
impl From<SqliteNote> for Note {
fn from(value: SqliteNote) -> Self {
Self {
id: value.id,
workspace_id: value.workspace_id,
slug: value.slug,
title: value.title,
content: value.content,
created_at: value.created_at,
updated_at: value.updated_at,
owner_map: value.owner_map,
protected: value.protected != 0,
created_by: value.created_by,
}
}
}
#[derive(Debug, Serialize, FromRow)]
@@ -98,10 +131,19 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
}
pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q003)
.bind(workspace_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(Note::from)
.collect());
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
.await
.bind(workspace_id)
.fetch_all(pool.pool())
.await
}
pub async fn find_note(
@@ -109,11 +151,19 @@ pub async fn find_note(
workspace_id: i64,
slug: &str,
) -> Result<Option<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q004)
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await?
.map(Note::from));
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_note(
@@ -121,19 +171,21 @@ pub async fn create_note(
workspace_id: i64,
slug: &str,
title: &str,
protected: bool,
created_by: Option<&str>,
) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id)
.bind(slug)
.bind(title)
.bind(protected)
.bind(created_by)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_one(pool.pool())
.await
find_note(pool, workspace_id, slug)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn save_revision(
@@ -453,3 +505,125 @@ pub async fn find_file_owner(pool: &Database, token: &str) -> Result<Option<File
}
Ok(None)
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NoteFile {
pub id: i64,
pub filename: String,
pub url: String,
pub mime_type: String,
pub size_bytes: i64,
pub created_at: String,
pub is_attached: bool,
pub detached_at: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNoteFile {
id: i64,
filename: String,
url: String,
mime_type: String,
size_bytes: i64,
created_at: String,
is_attached: i64,
detached_at: Option<String>,
}
impl From<SqliteNoteFile> for NoteFile {
fn from(value: SqliteNoteFile) -> Self {
Self {
id: value.id,
filename: value.filename,
url: value.url,
mime_type: value.mime_type,
size_bytes: value.size_bytes,
created_at: value.created_at,
is_attached: value.is_attached != 0,
detached_at: value.detached_at,
}
}
}
pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q031)).bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn register_note_file(pool: &Database, note_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q032))
.bind(note_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes)
.execute(pool.pool()).await?;
Ok(())
}
pub async fn list_note_files(pool: &Database, note_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q033, note_id).await
}
async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(query)
.bind(owner_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(NoteFile::from)
.collect());
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await
}
pub async fn set_note_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
sqlx::query(queries::get(pool.kind(), queries::Q034)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn register_pad_file(pool: &Database, pad_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q035))
.bind(pad_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes)
.execute(pool.pool()).await?;
Ok(())
}
pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q036, pad_id).await
}
pub async fn set_pad_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) };
sqlx::query(queries::get(pool.kind(), queries::Q037)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038)
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q039))
.bind(file_id)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
+12 -3
View File
@@ -3,9 +3,9 @@ use crate::database::DatabaseKind;
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?";
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)";
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?";
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)";
pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)";
@@ -31,6 +31,15 @@ pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?";
pub const Q028: &str = "SELECT content FROM note_revisions WHERE id = ? AND note_id = ?";
pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q031: &str = "DELETE FROM notes WHERE id = ?";
pub const Q032: &str = "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC";
pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q035: &str = "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC";
pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?";
pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();