diff --git a/Cargo.lock b/Cargo.lock
index 5e3a90f..ccf0b51 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3282,7 +3282,7 @@ dependencies = [
[[package]]
name = "tetratto"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"ammonia",
"async-stripe",
@@ -3313,7 +3313,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"async-recursion",
"base16ct",
@@ -3337,7 +3337,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"pathbufd",
"serde",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"ammonia",
"chrono",
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 6fce0f0..408b1b0 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tetratto"
-version = "3.0.0"
+version = "3.1.0"
edition = "2024"
[features]
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 28ee298..90d5004 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -26,6 +26,7 @@ version = "1.0.0"
"general:action.open" = "Open"
"general:action.view" = "View"
"general:action.copy_link" = "Copy link"
+"general:action.post" = "Post"
"general:label.safety" = "Safety"
"general:label.share" = "Share"
"general:action.add_account" = "Add account"
@@ -121,6 +122,8 @@ version = "1.0.0"
"communities:tab.emojis" = "Emojis"
"communities:label.upload" = "Upload"
"communities:label.file" = "File"
+"communities:label.drafts" = "Drafts"
+"communities:label.load" = "Load"
"notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread"
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 4f09959..c9d77b7 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -511,6 +511,7 @@ table ol {
.card.secondary {
background: var(--color-surface);
+ color: var(--color-text);
}
.card.tertiary {
diff --git a/crates/app/src/public/html/communities/base.html b/crates/app/src/public/html/communities/base.html
index af180aa..39eee00 100644
--- a/crates/app/src/public/html/communities/base.html
+++ b/crates/app/src/public/html/communities/base.html
@@ -187,6 +187,16 @@
{{ text "communities:label.chats" }}
+ {% if user and can_post %}
+
+ {{ icon "plus" }}
+ {{ text "general:action.post" }}
+
+ {% endif %}
+
+
+ {% if drafts|length > 0 %}
+
+
+ {{ icon "notepad-text-dashed" }}
+ {{ text "communities:label.drafts" }}
+
+
+
+ {% for draft in drafts %}
+
+
+ {{ draft.content|markdown|safe }}
+ {{ draft.created }}
+
+
+
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs
new file mode 100644
index 0000000..f181f05
--- /dev/null
+++ b/crates/app/src/routes/api/v1/communities/drafts.rs
@@ -0,0 +1,75 @@
+use axum::{extract::Path, response::IntoResponse, Extension, Json};
+use axum_extra::extract::CookieJar;
+use tetratto_core::model::{communities::PostDraft, ApiReturn, Error};
+use crate::{
+ get_user_from_token,
+ routes::api::v1::{CreatePostDraft, UpdatePostContent},
+ State,
+};
+
+pub async fn create_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Json(req): Json,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ match data
+ .create_draft(PostDraft::new(req.content, user.id))
+ .await
+ {
+ Ok(id) => Json(ApiReturn {
+ ok: true,
+ message: "Draft created".to_string(),
+ payload: Some(id.to_string()),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
+
+pub async fn delete_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Path(id): Path,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ match data.delete_draft(id, user).await {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Draft deleted".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
+
+pub async fn update_content_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Path(id): Path,
+ Json(req): Json,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ match data.update_draft_content(id, user, req.content).await {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Draft updated".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
diff --git a/crates/app/src/routes/api/v1/communities/mod.rs b/crates/app/src/routes/api/v1/communities/mod.rs
index ab6e2bb..a3dbd7a 100644
--- a/crates/app/src/routes/api/v1/communities/mod.rs
+++ b/crates/app/src/routes/api/v1/communities/mod.rs
@@ -1,4 +1,5 @@
pub mod communities;
+pub mod drafts;
pub mod emojis;
pub mod images;
pub mod posts;
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 51e0119..da317bf 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -113,6 +113,13 @@ pub fn routes() -> Router {
"/posts/{id}/context",
post(communities::posts::update_context_request),
)
+ // drafts
+ .route("/drafts", post(communities::drafts::create_request))
+ .route("/drafts/{id}", delete(communities::drafts::delete_request))
+ .route(
+ "/drafts/{id}/content",
+ post(communities::drafts::update_content_request),
+ )
// questions
.route("/questions", post(communities::questions::create_request))
.route(
@@ -581,3 +588,8 @@ pub struct AddOrRemoveStackUser {
pub struct UpdateEmojiName {
pub name: String,
}
+
+#[derive(Deserialize)]
+pub struct CreatePostDraft {
+ pub content: String,
+}
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 453157b..9a4f4a2 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -8,6 +8,7 @@ use axum::{
response::{Html, IntoResponse},
};
use axum_extra::extract::CookieJar;
+use serde::Deserialize;
use tera::Context;
use tetratto_core::model::{
auth::User,
@@ -236,10 +237,19 @@ pub async fn search_request(
))
}
+#[derive(Deserialize)]
+pub struct CreatePostProps {
+ #[serde(default)]
+ pub community: usize,
+ #[serde(default)]
+ pub from_draft: usize,
+}
+
/// `/communities/intents/post`
pub async fn create_post_request(
jar: CookieJar,
Extension(data): Extension,
+ Query(props): Query,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
@@ -271,9 +281,32 @@ pub async fn create_post_request(
communities.push(community)
}
+ // get draft
+ let draft = if props.from_draft != 0 {
+ match data.0.get_draft_by_id(props.from_draft).await {
+ Ok(d) => {
+ // drafts can only be used by their owner
+ if d.owner == user.id { Some(d) } else { None }
+ }
+ Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+ }
+ } else {
+ None
+ };
+
+ let drafts = match data.0.get_drafts_by_user_all(user.id).await {
+ Ok(l) => l,
+ Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+ };
+
+ // ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
+
+ context.insert("draft", &draft);
+ context.insert("drafts", &drafts);
context.insert("communities", &communities);
+ context.insert("selected_community", &props.community);
// return
Ok(Html(
@@ -1118,10 +1151,16 @@ pub async fn question_request(
false
};
+ let is_sender = if let Some(ref ua) = user {
+ ua.id == question.owner
+ } else {
+ false
+ };
+
// check permissions
let (can_read, _) = check_permissions!(community, jar, data, user);
- if !can_read {
+ if !can_read && !is_sender {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 07b97cf..adc8e8d 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
-version = "3.0.0"
+version = "3.1.0"
edition = "2024"
[features]
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index e98a5d1..37d4ee5 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -291,6 +291,28 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
+ // delete stacks
+ let res = execute!(
+ &conn,
+ "DELETE FROM stacks WHERE owner = $1",
+ &[&(id as i64)]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ // delete drafts
+ let res = execute!(
+ &conn,
+ "DELETE FROM drafts WHERE owner = $1",
+ &[&(id as i64)]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
// delete posts
let res = execute!(&conn, "DELETE FROM posts WHERE owner = $1", &[&(id as i64)]);
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 399005b..397cf00 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -33,6 +33,7 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_UPLOADS).unwrap();
execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap();
execute!(&conn, common::CREATE_TABLE_STACKS).unwrap();
+ execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap();
self.2
.set("atto.active_connections:users".to_string(), "0".to_string())
diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs
new file mode 100644
index 0000000..239d035
--- /dev/null
+++ b/crates/core/src/database/drafts.rs
@@ -0,0 +1,185 @@
+use super::*;
+use crate::cache::Cache;
+use crate::model::moderation::AuditLogEntry;
+use crate::model::{Error, Result, auth::User, communities::PostDraft, permissions::FinePermission};
+use crate::{auto_method, execute, get, query_row, query_rows, params};
+
+#[cfg(feature = "sqlite")]
+use rusqlite::Row;
+
+#[cfg(feature = "postgres")]
+use tokio_postgres::Row;
+
+impl DataManager {
+ /// Get a [`PostDraft`] from an SQL row.
+ pub(crate) fn get_draft_from_row(
+ #[cfg(feature = "sqlite")] x: &Row<'_>,
+ #[cfg(feature = "postgres")] x: &Row,
+ ) -> PostDraft {
+ PostDraft {
+ id: get!(x->0(i64)) as usize,
+ created: get!(x->1(i64)) as usize,
+ content: get!(x->2(String)),
+ owner: get!(x->3(i64)) as usize,
+ }
+ }
+
+ auto_method!(get_draft_by_id()@get_draft_from_row -> "SELECT * FROM drafts WHERE id = $1" --name="draft" --returns=PostDraft --cache-key-tmpl="atto.draft:{}");
+
+ /// Get all drafts from the given user (from most recent, paginated).
+ ///
+ /// # Arguments
+ /// * `id` - the ID of the user the requested drafts belong to
+ /// * `batch` - the limit of posts in each page
+ /// * `page` - the page number
+ pub async fn get_drafts_by_user(
+ &self,
+ id: usize,
+ batch: usize,
+ page: usize,
+ ) -> Result> {
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ // ...
+ let res = query_rows!(
+ &conn,
+ "SELECT * FROM drafts WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
+ &[&(id as i64), &(batch as i64), &((page * batch) as i64)],
+ |x| { Self::get_draft_from_row(x) }
+ );
+
+ if res.is_err() {
+ return Err(Error::GeneralNotFound("draft".to_string()));
+ }
+
+ Ok(res.unwrap())
+ }
+
+ /// Get all drafts from the given user (from most recent).
+ ///
+ /// # Arguments
+ /// * `id` - the ID of the user the requested drafts belong to
+ pub async fn get_drafts_by_user_all(&self, id: usize) -> Result> {
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ // ...
+ let res = query_rows!(
+ &conn,
+ "SELECT * FROM drafts WHERE owner = $1 ORDER BY created DESC",
+ &[&(id as i64)],
+ |x| { Self::get_draft_from_row(x) }
+ );
+
+ if res.is_err() {
+ return Err(Error::GeneralNotFound("draft".to_string()));
+ }
+
+ Ok(res.unwrap())
+ }
+
+ /// Create a new post draft in the database.
+ ///
+ /// # Arguments
+ /// * `data` - a mock [`PostDraft`] object to insert
+ pub async fn create_draft(&self, data: PostDraft) -> Result {
+ // check values
+ if data.content.len() < 2 {
+ return Err(Error::DataTooShort("content".to_string()));
+ } else if data.content.len() > 4096 {
+ return Err(Error::DataTooLong("content".to_string()));
+ }
+
+ // ...
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ let res = execute!(
+ &conn,
+ "INSERT INTO drafts VALUES ($1, $2, $3, $4)",
+ params![
+ &(data.id as i64),
+ &(data.created as i64),
+ &data.content,
+ &(data.owner as i64),
+ ]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ Ok(data.id)
+ }
+
+ pub async fn delete_draft(&self, id: usize, user: User) -> Result<()> {
+ let y = self.get_draft_by_id(id).await?;
+
+ if user.id != y.owner {
+ if !user.permissions.check(FinePermission::MANAGE_POSTS) {
+ return Err(Error::NotAllowed);
+ } else {
+ self.create_audit_log_entry(AuditLogEntry::new(
+ user.id,
+ format!("invoked `delete_draft` with x value `{id}`"),
+ ))
+ .await?
+ }
+ }
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ let res = execute!(&conn, "DELETE FROM drafts WHERE id = $1", &[&(id as i64)]);
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ self.2.remove(format!("atto.draft:{}", id)).await;
+
+ Ok(())
+ }
+
+ pub async fn update_draft_content(&self, id: usize, user: User, x: String) -> Result<()> {
+ let y = self.get_draft_by_id(id).await?;
+
+ if user.id != y.owner {
+ if !user.permissions.check(FinePermission::MANAGE_POSTS) {
+ return Err(Error::NotAllowed);
+ } else {
+ self.create_audit_log_entry(AuditLogEntry::new(
+ user.id,
+ format!("invoked `update_draft_content` with x value `{id}`"),
+ ))
+ .await?
+ }
+ }
+
+ // ...
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ let res = execute!(
+ &conn,
+ "UPDATE drafts SET content = $1 WHERE id = $2",
+ params![&x, &(id as i64)]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index 6c67bb0..fda8e77 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -18,3 +18,4 @@ pub const CREATE_TABLE_MESSAGES: &str = include_str!("./sql/create_messages.sql"
pub const CREATE_TABLE_UPLOADS: &str = include_str!("./sql/create_uploads.sql");
pub const CREATE_TABLE_EMOJIS: &str = include_str!("./sql/create_emojis.sql");
pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.sql");
+pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql");
diff --git a/crates/core/src/database/drivers/sql/create_drafts.sql b/crates/core/src/database/drivers/sql/create_drafts.sql
new file mode 100644
index 0000000..39cba10
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_drafts.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS drafts (
+ id BIGINT NOT NULL PRIMARY KEY,
+ created BIGINT NOT NULL,
+ content TEXT NOT NULL,
+ owner BIGINT NOT NULL
+)
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index 4cabd4e..e02caff 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -3,6 +3,7 @@ mod auth;
mod common;
mod communities;
pub mod connections;
+mod drafts;
mod drivers;
mod emojis;
mod ipbans;
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index e1366be..592a373 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -918,10 +918,10 @@ impl DataManager {
}
}
- /// Create a new journal entry in the database.
+ /// Create a new post in the database.
///
/// # Arguments
- /// * `data` - a mock [`JournalEntry`] object to insert
+ /// * `data` - a mock [`Post`] object to insert
pub async fn create_post(&self, mut data: Post) -> Result {
// check values (if this isn't reposting something else)
let is_reposting = if let Some(ref repost) = data.context.repost {
diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs
index 886dfd0..5fa153b 100644
--- a/crates/core/src/model/communities.rs
+++ b/crates/core/src/model/communities.rs
@@ -349,3 +349,23 @@ pub struct QuestionContext {
#[serde(default)]
pub is_nsfw: bool,
}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct PostDraft {
+ pub id: usize,
+ pub created: usize,
+ pub content: String,
+ pub owner: usize,
+}
+
+impl PostDraft {
+ /// Create a new [`PostDraft`].
+ pub fn new(content: String, owner: usize) -> Self {
+ Self {
+ id: Snowflake::new().to_string().parse::().unwrap(),
+ created: unix_epoch_timestamp() as usize,
+ content,
+ owner,
+ }
+ }
+}
diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml
index ba20710..ff1a8f0 100644
--- a/crates/l10n/Cargo.toml
+++ b/crates/l10n/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
-version = "3.0.0"
+version = "3.1.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml
index 3130978..1b64125 100644
--- a/crates/shared/Cargo.toml
+++ b/crates/shared/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
-version = "3.0.0"
+version = "3.1.0"
edition = "2024"
authors.workspace = true
repository.workspace = true