add: api routes

This commit is contained in:
trisua 2025-08-24 12:29:36 -04:00
parent d7ee379a9a
commit ce9ce4f635
16 changed files with 1119 additions and 109 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
target/ target/
tetratto.toml
app.toml

652
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -32,3 +32,4 @@ toml = "0.9.4"
regex = "1.11.1" regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] } oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] }
buckets-core = "1.0.4" buckets-core = "1.0.4"
axum-image = "0.1.1"

View file

@ -5,6 +5,7 @@ use tetratto_core::{
auto_method, auto_method,
model::{Error, Result, auth::User}, model::{Error, Result, auth::User},
}; };
use tetratto_shared::unix_epoch_timestamp;
impl DataManager { impl DataManager {
/// Get a [`Message`] from an SQL row. /// Get a [`Message`] from an SQL row.
@ -12,10 +13,11 @@ impl DataManager {
Message { Message {
id: get!(x->0(i64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize, created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize, edited: get!(x->2(i64)) as usize,
chat: get!(x->3(i64)) as usize, owner: get!(x->3(i64)) as usize,
content: get!(x->4(String)), chat: get!(x->4(i64)) as usize,
uploads: serde_json::from_str(&get!(x->5(String))).unwrap(), content: get!(x->5(String)),
uploads: serde_json::from_str(&get!(x->6(String))).unwrap(),
} }
} }
@ -41,10 +43,11 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO t_messages VALUES ($1, $2, $3, $4, $5, $6)", "INSERT INTO t_messages VALUES ($1, $2, $3, $4, $5, $6, $7)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
&(data.edited as i64),
&(data.owner as i64), &(data.owner as i64),
&(data.chat as i64), &(data.chat as i64),
&data.content, &data.content,
@ -95,5 +98,37 @@ impl DataManager {
Ok(()) Ok(())
} }
auto_method!(update_message_content(&str) -> "UPDATE t_messages SET content = $1 WHERE id = $2" --serde --cache-key-tmpl="twny.message:{}"); /// Update the content of the given message.
pub async fn update_message_content(
&self,
id: usize,
content: &str,
user: &User,
) -> Result<()> {
let message = self.get_message_by_id(id).await?;
if message.owner != user.id {
return Err(Error::NotAllowed);
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
// update message
let res = execute!(
&conn,
"UPDATE t_messages SET content = $1, edited = $2 WHERE id = $3",
params![&content, &(unix_epoch_timestamp() as i64), &(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
Ok(())
}
} }

View file

@ -6,10 +6,18 @@ use crate::config::Config;
use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager}; use buckets_core::{Config as BucketsConfig, DataManager as BucketsManager};
use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult}; use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult};
use std::collections::HashMap; use std::collections::HashMap;
use tetratto_core::model::{Error, Result}; use tetratto_core::{
DataManager as TetrattoManager,
config::Config as TetrattoConfig,
model::{Error, Result},
};
#[derive(Clone)] #[derive(Clone)]
pub struct DataManager(pub OiseauManager<Config>, pub BucketsManager); pub struct DataManager(
pub OiseauManager<Config>,
pub BucketsManager,
pub TetrattoManager,
);
impl DataManager { impl DataManager {
/// Create a new [`DataManager`]. /// Create a new [`DataManager`].
@ -22,7 +30,15 @@ impl DataManager {
.await .await
.expect("failed to create buckets manager"); .expect("failed to create buckets manager");
Ok(Self(OiseauManager::new(config).await?, buckets_manager)) let tetratto_manager = TetrattoManager::new(TetrattoConfig::get_config())
.await
.expect("failed to create tetratto manager");
Ok(Self(
OiseauManager::new(config).await?,
buckets_manager,
tetratto_manager,
))
} }
/// Initialize tables. /// Initialize tables.

View file

@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS t_messages ( CREATE TABLE IF NOT EXISTS t_messages (
id BIGINT NOT NULL, id BIGINT NOT NULL,
created BIGINT NOT NULL, created BIGINT NOT NULL,
edited BIGINT NOT NULL,
owner BIGINT NOT NULL, owner BIGINT NOT NULL,
chats BIGINT NOT NULL, chats BIGINT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,

49
src/macros.rs Normal file
View file

@ -0,0 +1,49 @@
#[macro_export]
macro_rules! get_user_from_token {
($jar:ident, $db:expr) => {{
// pages; regular token only
if let Some(token) = $jar.get("__Secure-atto-token") {
match $db
.get_user_by_token(&tetratto_shared::hash::hash(
token.to_string().replace("__Secure-atto-token=", ""),
))
.await
{
Ok(ua) => {
if ua.permissions.check_banned() {
// check expiration
let now = tetratto_shared::unix_epoch_timestamp();
let expired = ua.ban_expire <= now;
if expired && ua.ban_expire != 0 {
$db.update_user_role(
ua.id,
ua.permissions
- tetratto_core::model::permissions::FinePermission::BANNED,
&ua,
true,
)
.await
.expect("failed to auto unban user");
Some(ua)
} else {
// banned
let mut banned_user = tetratto_core::model::auth::User::banned();
banned_user.ban_reason = ua.ban_reason;
banned_user.ban_expire = ua.ban_expire;
Some(banned_user)
}
} else {
Some(ua)
}
}
Err(_) => None,
}
} else {
None
}
}};
}

View file

@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
mod config; mod config;
mod database; mod database;
mod macros;
mod markdown; mod markdown;
mod model; mod model;
mod routes; mod routes;

View file

@ -1,12 +1,12 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupChatInfo { pub struct GroupChatInfo {
pub name: String, pub name: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum ChatStyle { pub enum ChatStyle {
/// Direct messages between two users. /// Direct messages between two users.
Direct, Direct,
@ -52,6 +52,7 @@ impl Chat {
pub struct Message { pub struct Message {
pub id: usize, pub id: usize,
pub created: usize, pub created: usize,
pub edited: usize,
pub owner: usize, pub owner: usize,
pub chat: usize, pub chat: usize,
pub content: String, pub content: String,
@ -61,9 +62,12 @@ pub struct Message {
impl Message { impl Message {
/// Create a new [`Message`]. /// Create a new [`Message`].
pub fn new(owner: usize, chat: usize, content: String, uploads: Vec<usize>) -> Self { pub fn new(owner: usize, chat: usize, content: String, uploads: Vec<usize>) -> Self {
let created = unix_epoch_timestamp();
Self { Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(), id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(), created,
edited: created,
owner, owner,
chat, chat,
content, content,

View file

@ -1,88 +0,0 @@
use crate::{State, config::Config};
use axum::{
Extension, Router,
extract::Path,
response::{Html, IntoResponse},
routing::{get, get_service},
};
use pathbufd::PathBufD;
use tera::Context;
use tetratto_core::model::Error;
pub fn routes() -> Router {
Router::new()
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new("./public")),
)
.fallback(not_found_request)
.route("/docs/{name}", get(view_doc_request))
// pages
.route("/", get(index_request))
// api
// ...
}
fn default_context(config: &Config, build_code: &str) -> Context {
let mut ctx = Context::new();
ctx.insert("name", &config.name);
ctx.insert("theme_color", &config.theme_color);
ctx.insert("build_code", &build_code);
ctx
}
// pages
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
let mut ctx = default_context(&data.0.0, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("page".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
}
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
Html(
tera.render("index.lisp", &default_context(&data.0.0, &build_code))
.unwrap(),
)
}
async fn view_doc_request(
Extension(data): Extension<State>,
Path(name): Path<String>,
) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
if !std::fs::exists(&path).unwrap_or(false) {
let mut ctx = default_context(&data.0.0, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
let mut ctx = default_context(&data.0.0, &build_code);
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
};
let mut ctx = default_context(&data.0.0, &build_code);
ctx.insert("text", &text);
ctx.insert("file_name", &name);
return Html(tera.render("doc.lisp", &ctx).unwrap());
}
// api
// ...

137
src/routes/api/chats.rs Normal file
View file

@ -0,0 +1,137 @@
use crate::{
State, get_user_from_token,
model::{Chat, ChatStyle, GroupChatInfo},
};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{ApiReturn, Error};
#[derive(Deserialize)]
pub struct CreateChat {
pub style: ChatStyle,
pub members: Vec<usize>,
}
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateChat>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
if req.members.len() > 2 && req.style == ChatStyle::Direct {
return Json(Error::DataTooLong("members".to_string()).into());
}
match data
.create_chat(Chat::new(req.style, {
let mut x = req.members;
x.push(user.id);
x
}))
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: x.id.to_string(),
}),
Err(e) => Json(e.into()),
}
}
pub async fn leave_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let mut chat = match data.get_chat_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if !chat.members.contains(&user.id) {
return Json(Error::NotAllowed.into());
}
chat.members
.remove(chat.members.iter().position(|x| *x == user.id).unwrap());
if chat.members.len() == 0 {
// we were the last member
match data.delete_chat(id).await {
Ok(_) => {
return Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
});
}
Err(e) => return Json(e.into()),
}
}
match data.update_chat_members(chat.id, chat.members).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
#[derive(Deserialize)]
pub struct UpdateChatInfo {
pub info: GroupChatInfo,
}
pub async fn update_info_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateChatInfo>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let chat = match data.get_chat_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if !chat.members.contains(&user.id) {
return Json(Error::NotAllowed.into());
}
match chat.style {
ChatStyle::Group(_) => {
match data
.update_chat_style(chat.id, ChatStyle::Group(req.info))
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
_ => return Json(Error::DoesNotSupportField("info".to_string()).into()),
}
}

147
src/routes/api/messages.rs Normal file
View file

@ -0,0 +1,147 @@
use crate::{State, get_user_from_token, model::Message};
use axum::{Extension, Json, body::Bytes, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use axum_image::{encode::save_webp_buffer, extract::JsonMultipart};
use buckets_core::model::{MediaType, MediaUpload};
use serde::Deserialize;
use tetratto_core::model::{ApiReturn, Error};
#[derive(Deserialize)]
pub struct CreateMessage {
pub content: String,
}
const MAXIMUM_UPLOAD_SIZE: usize = 4_194_304; // 4 MiB
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
JsonMultipart(byte_parts, req): JsonMultipart<CreateMessage>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
// check fields
if req.content.trim().len() < 2 {
return Json(Error::DataTooShort("content".to_string()).into());
} else if req.content.len() > 2048 {
return Json(Error::DataTooLong("content".to_string()).into());
}
// check chat permissions
let chat = match data.get_chat_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if !chat.members.contains(&user.id) {
return Json(Error::NotAllowed.into());
}
// create uploads
let mut uploads: Vec<(MediaUpload, Bytes)> = Vec::new();
for part in &byte_parts {
if part.len() < MAXIMUM_UPLOAD_SIZE {
return Json(Error::FileTooLarge.into());
}
}
for part in byte_parts {
uploads.push((
MediaUpload::new(MediaType::Webp, user.id, "message_media".to_string()),
part,
));
}
// create message
match data
.create_message(Message::new(
user.id,
chat.id,
req.content,
uploads.iter().map(|x| x.0.id).collect(),
))
.await
{
Ok(x) => {
// store uploads
for (upload, part) in uploads {
let upload = match data.1.create_upload(upload).await {
Ok(x) => x,
Err(_) => continue,
};
if save_webp_buffer(
&upload.path(&data.1.0.0.directory).to_string(),
part.to_vec(),
None,
)
.is_err()
{
continue;
}
}
// ...
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: x.id.to_string(),
})
}
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_message(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
#[derive(Deserialize)]
pub struct UpdateMessageContent {
pub content: String,
}
pub async fn update_content_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateMessageContent>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data.2) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
match data.update_message_content(id, &req.content, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

14
src/routes/api/mod.rs Normal file
View file

@ -0,0 +1,14 @@
pub mod chats;
pub mod messages;
use axum::routing::{Router, delete, post, put};
pub fn routes() -> Router {
Router::new()
.route("/chats", post(chats::create_request))
.route("/chats/{id}/leave", post(chats::leave_request))
.route("/chats/{id}/info", post(chats::update_info_request))
.route("/messages", post(messages::create_request))
.route("/messages/{id}", delete(messages::delete_request))
.route("/messages/{id}", put(messages::update_content_request))
}

25
src/routes/mod.rs Normal file
View file

@ -0,0 +1,25 @@
use crate::config::Config;
use axum::{Router, routing::get_service};
use tera::Context;
pub mod api;
pub mod pages;
pub fn routes() -> Router {
Router::new()
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new("./public")),
)
.fallback(pages::misc::not_found_request)
.merge(pages::routes())
.nest("/api/v1", api::routes())
}
pub fn default_context(config: &Config, build_code: &str) -> Context {
let mut ctx = Context::new();
ctx.insert("name", &config.name);
ctx.insert("theme_color", &config.theme_color);
ctx.insert("build_code", &build_code);
ctx
}

25
src/routes/pages/misc.rs Normal file
View file

@ -0,0 +1,25 @@
use crate::{State, routes::default_context};
use axum::{
Extension,
response::{Html, IntoResponse},
};
use tetratto_core::model::Error;
pub async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
let mut ctx = default_context(&data.0.0, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("page".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
}
pub async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
Html(
tera.render("index.lisp", &default_context(&data.0.0, &build_code))
.unwrap(),
)
}

7
src/routes/pages/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod misc;
use axum::routing::{Router, get};
pub fn routes() -> Router {
Router::new().route("/", get(misc::index_request))
}