first commit
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
use argon2::{
|
||||
password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct Workspace {
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Note {
|
||||
pub id: i64,
|
||||
#[serde(skip_serializing)]
|
||||
pub workspace_id: i64,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct Revision {
|
||||
pub id: i64,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Workspace>(
|
||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<Workspace, sqlx::Error> {
|
||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Workspace>(
|
||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
||||
match (&workspace.password_hash, password.filter(|value| !value.is_empty())) {
|
||||
(None, _) => true,
|
||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||
.ok()
|
||||
.and_then(|parsed| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.ok()
|
||||
})
|
||||
.is_some(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_note(
|
||||
pool: &SqlitePool,
|
||||
workspace_id: i64,
|
||||
slug: &str,
|
||||
) -> Result<Option<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? AND slug = ?",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
pool: &SqlitePool,
|
||||
workspace_id: i64,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
) -> Result<Note, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_revision(
|
||||
pool: &SqlitePool,
|
||||
note_id: i64,
|
||||
workspace_id: i64,
|
||||
content: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(note_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)")
|
||||
.bind(note_id)
|
||||
.bind(content)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok((result.last_insert_rowid(), updated_at))
|
||||
}
|
||||
|
||||
pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(note_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn random_suffix(length: usize) -> String {
|
||||
const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
|
||||
let mut bytes = vec![0_u8; length];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Argon2 hashing should succeed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_timestamp(value: &str) -> String {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|dt| dt.with_timezone(&Utc).to_rfc3339())
|
||||
.unwrap_or_else(|_| value.replace(' ', "T") + "Z")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct Pad {
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_pad(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<Pad, sqlx::Error> {
|
||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
||||
match (&pad.password_hash, password.filter(|value| !value.is_empty())) {
|
||||
(None, _) => true,
|
||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||
.ok()
|
||||
.and_then(|parsed| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.ok()
|
||||
})
|
||||
.is_some(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_pad_revision(
|
||||
pool: &SqlitePool,
|
||||
pad_id: i64,
|
||||
content: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(pad_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)")
|
||||
.bind(pad_id)
|
||||
.bind(content)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok((result.last_insert_rowid(), updated_at))
|
||||
}
|
||||
|
||||
pub async fn list_pad_revisions(
|
||||
pool: &SqlitePool,
|
||||
pad_id: i64,
|
||||
) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(pad_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
Reference in New Issue
Block a user