diff --git a/Cargo.toml b/Cargo.toml index c79c82a..bb0d200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "izumo" version = "0.1.0" edition = "2021" +build = "build.rs" # [workspace] # members = ["oauth"] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d61b541 --- /dev/null +++ b/build.rs @@ -0,0 +1,15 @@ +// build.rs +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let version = env::var("CARGO_PKG_VERSION").unwrap(); + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("version.rs"); + fs::write( + dest_path, + format!("pub const VERSION: &str = \"{}\";", version), + ) + .unwrap(); +} diff --git a/migrations/2024-11-15-152616_add_bot_votes/down.sql b/migrations/2024-11-15-152616_add_bot_votes/down.sql new file mode 100644 index 0000000..f994027 --- /dev/null +++ b/migrations/2024-11-15-152616_add_bot_votes/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS bot_votes; diff --git a/migrations/2024-11-15-152616_add_bot_votes/up.sql b/migrations/2024-11-15-152616_add_bot_votes/up.sql new file mode 100644 index 0000000..a8307ca --- /dev/null +++ b/migrations/2024-11-15-152616_add_bot_votes/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE bot_votes +( + bot_id VARCHAR NOT NULL REFERENCES bots (id) ON DELETE CASCADE, + date DATE DEFAULT CURRENT_DATE NOT NULL, + votes INTEGER DEFAULT 1 NOT NULL, + PRIMARY KEY (bot_id, date) +); + diff --git a/src/controllers/bot.rs b/src/controllers/bot.rs index 139dc10..1d23dd3 100644 --- a/src/controllers/bot.rs +++ b/src/controllers/bot.rs @@ -1,3 +1,4 @@ pub mod manage; pub mod metadata; pub mod owners; +pub mod votes; diff --git a/src/controllers/bot/metadata.rs b/src/controllers/bot/metadata.rs index 25cff31..cf3c96d 100644 --- a/src/controllers/bot/metadata.rs +++ b/src/controllers/bot/metadata.rs @@ -2,8 +2,7 @@ use crate::app::AppState; use crate::models::category::{BotCategory, Category}; use crate::models::Bot; use crate::schema::categories; -use crate::task::spawn_blocking; -use crate::util::errors::{bad_request, bot_not_found, AppResult, BoxedAppError}; +use crate::util::errors::{bad_request, AppResult, BoxedAppError}; use crate::util::request_helper::RequestUtils; use crate::views::{EncodableBot, EncodableCategory}; @@ -12,54 +11,49 @@ use axum::http::request::Parts; use axum::Json; use diesel::prelude::*; -use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; +use diesel_async::RunQueryDsl; use serde_json::Value; use std::str::FromStr; /// Handles the `GET /bots/:bot_id` pub async fn show(app: AppState, Path(id): Path, req: Parts) -> AppResult> { - let conn = app.db_read().await?; - - spawn_blocking(move || { - let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); - let id = id.as_str(); - - let include = req - .query() - .get("include") - .map(|mode| ShowIncludeMode::from_str(mode)) - .transpose()? - .unwrap_or_default(); - - let bot = Bot::find(conn, id) - .optional()? - .ok_or_else(|| bot_not_found(id))?; - - let cats = if include.categories { - Some( - BotCategory::belonging_to(&bot) - .inner_join(categories::table) - .select(categories::all_columns) - .load(conn)?, - ) - } else { - None - }; - - let encodable_bot = EncodableBot::from(bot.clone(), cats.as_deref()); - - let encodable_cats = cats.map(|cats| { - cats.into_iter() - .map(Category::into) - .collect::>() - }); - - Ok(Json(serde_json::json!({ - "bot": encodable_bot, - "categories": encodable_cats, - }))) - }) - .await + let mut conn = app.db_read().await?; + + let id = id.as_str(); + + let include = req + .query() + .get("include") + .map(|mode| ShowIncludeMode::from_str(mode)) + .transpose()? + .unwrap_or_default(); + + let bot = Bot::find(&mut conn, id).await?; + + let cats = if include.categories { + Some( + BotCategory::belonging_to(&bot) + .inner_join(categories::table) + .select(categories::all_columns) + .load(&mut conn) + .await?, + ) + } else { + None + }; + + let encodable_bot = EncodableBot::from(bot.clone(), cats.as_deref()); + + let encodable_cats = cats.map(|cats| { + cats.into_iter() + .map(Category::into) + .collect::>() + }); + + Ok(Json(serde_json::json!({ + "bot": encodable_bot, + "categories": encodable_cats, + }))) } #[derive(Debug)] diff --git a/src/controllers/bot/owners.rs b/src/controllers/bot/owners.rs index 77252d6..5bdbc23 100644 --- a/src/controllers/bot/owners.rs +++ b/src/controllers/bot/owners.rs @@ -1,33 +1,20 @@ -use crate::{ - app::AppState, - models::Bot, - task::spawn_blocking, - util::errors::{bot_not_found, AppResult}, - views::EncodableBotOwner, -}; +use crate::{app::AppState, models::Bot, util::errors::AppResult, views::EncodableBotOwner}; use axum::{extract::Path, Json}; -use diesel::OptionalExtension; -use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; use serde_json::Value; /// Handles `GET /bots/:bot_id/owners` requests. pub async fn owners(state: AppState, Path(id): Path) -> AppResult> { - let conn = state.db_read().await?; - spawn_blocking(move || { - let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); - let id = id.as_str(); + let mut conn = state.db_read().await?; + let id = id.as_str(); - let bot: Bot = Bot::find(conn, id) - .optional()? - .ok_or_else(|| bot_not_found(id))?; + let bot: Bot = Bot::find(&mut conn, id).await?; - let owners = bot - .owners(conn)? - .into_iter() - .map(|owner| owner.into()) - .collect::>(); + let owners = bot + .owners(&mut conn) + .await? + .into_iter() + .map(|owner| owner.into()) + .collect::>(); - Ok(Json(json!({ "users": owners }))) - }) - .await + Ok(Json(json!({ "users": owners }))) } diff --git a/src/controllers/bot/votes.rs b/src/controllers/bot/votes.rs new file mode 100644 index 0000000..6ec1e80 --- /dev/null +++ b/src/controllers/bot/votes.rs @@ -0,0 +1,83 @@ +use crate::auth::AuthCheck; +use crate::models::vote::NewBotVote; +use crate::task::spawn_blocking; +use crate::util::errors::bot_not_found; +use crate::views::EncodableBotVote; +use crate::{ + app::AppState, + models::{Bot, BotVote}, + schema::bot_votes, + util::errors::AppResult, +}; +use axum::extract::Path; +use axum::http::request::Parts; +use axum::Json; +use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; +use serde_json::Value; + +/// Handles the `GET /bots/:bot_id/votes` route. +pub async fn votes(app: AppState, Path(bot_id): Path) -> AppResult> { + let mut conn = app.db_read().await?; + + use diesel::dsl::*; + use diesel::prelude::*; + use diesel::sql_types::BigInt; + use diesel_async::RunQueryDsl; + + let bot = Bot::find(&mut conn, bot_id.as_str()).await?; + + // last 90 votes + let votes = BotVote::belonging_to(&bot) + .filter(bot_votes::date.gt(date(now - 90.days()))) + .order((bot_votes::date.asc(), bot_votes::bot_id.desc())) + .load(&mut conn) + .await? + .into_iter() + .map(BotVote::into) + .collect::>(); + + let sum_votes = sql::("SUM(bot_votes.votes)"); + let total_votes: i64 = BotVote::belonging_to(&bot) + .select(sum_votes) + .get_result(&mut conn) + .await?; + + Ok(Json(json!({ + "bot_votes": votes, + "extra": { + "total_votes": total_votes, + } + }))) +} + +// TODO: 12h user ratelimit +/// Handles the `POST /bots/:bot_id/votes` route. +pub async fn vote( + app: AppState, + Path(bot_id): Path, + req: Parts, +) -> AppResult> { + let conn = app.db_write().await?; + use diesel::OptionalExtension; + use diesel::RunQueryDsl; + + spawn_blocking(move || { + let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); + + // Make sure user is logged in + let _ = AuthCheck::only_cookie().check(&req, conn)?.user_id(); + let bot_id = bot_id.as_str(); + + // Check if bot exists + let _: Bot = Bot::by_id(bot_id) + .first(conn) + .optional()? + .ok_or_else(|| bot_not_found(bot_id))?; + + let new_vote = NewBotVote::new(bot_id); + let vote = new_vote.create(conn)?; + + Ok(Json(EncodableBotVote::from(vote))) + }) + .await +} diff --git a/src/controllers/summary.rs b/src/controllers/summary.rs index 6cfb4ba..a089d3e 100644 --- a/src/controllers/summary.rs +++ b/src/controllers/summary.rs @@ -4,7 +4,6 @@ use crate::schema::{bots, bots_categories, categories}; use crate::util::errors::AppResult; use crate::views::{EncodableBot, EncodableCategory}; use axum::Json; -use diesel::prelude::*; use diesel::{BelongingToDsl, ExpressionMethods, QueryDsl, SelectableHelper}; use diesel_async::AsyncPgConnection; diff --git a/src/models.rs b/src/models.rs index 2d3527a..c9782b5 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,6 +4,7 @@ pub use self::owners::BotOwner; pub use self::review::BotReview; pub use self::token::{ApiToken, CreatedApiToken}; pub use self::user::User; +pub use self::vote::BotVote; pub mod bot; pub mod category; @@ -14,3 +15,4 @@ pub mod token; pub mod user; pub mod util; pub mod vanity; +pub mod vote; diff --git a/src/models/bot.rs b/src/models/bot.rs index 7d1a662..acb08ed 100644 --- a/src/models/bot.rs +++ b/src/models/bot.rs @@ -2,10 +2,11 @@ use crate::models::util::diesel::Conn; use crate::models::{BotOwner, User}; use crate::schema::{bot_owners, bots, users}; use crate::sql::pg_enum; -use crate::util::errors::AppResult; +use crate::util::errors::{bot_not_found, AppResult}; use derivative::Derivative; -use diesel::prelude::*; use diesel::{deserialize::FromSqlRow, expression::AsExpression}; +use diesel::{dsl, ExpressionMethods, QueryDsl, QueryResult, SelectableHelper}; +use diesel_async::AsyncPgConnection; use serde::{Deserialize, Serialize}; pg_enum! { @@ -114,15 +115,30 @@ pub struct Bot { } impl Bot { - pub fn find(conn: &mut impl Conn, id: &str) -> QueryResult { - bots::table.find(id).first(conn) + pub async fn find(conn: &mut AsyncPgConnection, id: &str) -> AppResult { + use diesel::OptionalExtension; + use diesel_async::RunQueryDsl; + + bots::table + .find(id) + .first::(conn) + .await + .optional()? + .ok_or_else(|| bot_not_found(id)) + } + + #[dsl::auto_type(no_type_alias)] + pub fn by_id(id: &str) -> _ { + bots::table.find(id) } - pub fn owners(&self, conn: &mut impl Conn) -> AppResult> { + pub async fn owners(&self, conn: &mut AsyncPgConnection) -> AppResult> { + use diesel_async::RunQueryDsl; let owners = BotOwner::by_bot_id(&self.id) .inner_join(users::table) .select(users::all_columns) - .load(conn)?; + .load(conn) + .await?; Ok(owners) } @@ -224,6 +240,7 @@ impl<'a> NewBot<'a> { } pub fn create(&self, conn: &mut impl Conn, user_id: String) -> QueryResult { + use diesel::RunQueryDsl; conn.transaction(|conn| { let bot: Bot = diesel::insert_into(bots::table) .values(self) diff --git a/src/models/vote.rs b/src/models/vote.rs new file mode 100644 index 0000000..8b767bc --- /dev/null +++ b/src/models/vote.rs @@ -0,0 +1,46 @@ +use crate::diesel::ExpressionMethods; +use crate::models::util::diesel::Conn; +use crate::models::Bot; +use crate::schema::bot_votes; +use chrono::NaiveDate; +use diesel::RunQueryDsl; +use diesel::{QueryResult, SelectableHelper}; + +#[derive(Queryable, Identifiable, Associations, Selectable, Debug, Clone)] +#[diesel(primary_key(bot_id, date), belongs_to(Bot))] +pub struct BotVote { + pub bot_id: String, + pub date: NaiveDate, + pub votes: i32, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel( + table_name = bot_votes, + check_for_backend(diesel::pg::Pg), +)] +pub struct NewBotVote<'a> { + pub bot_id: &'a str, +} + +impl<'a> NewBotVote<'a> { + pub fn new(bot_id: &'a str) -> NewBotVote { + Self { bot_id } + } + + pub fn create(&self, conn: &mut impl Conn) -> QueryResult { + conn.transaction(|conn| { + use crate::schema::bot_votes::dsl::*; + + let vote: BotVote = diesel::insert_into(bot_votes) + .values(self) + .on_conflict((bot_id, date)) + .do_update() + .set(votes.eq(votes + 1)) + .returning(BotVote::as_returning()) + .get_result(conn)?; + + Ok(vote) + }) + } +} diff --git a/src/router.rs b/src/router.rs index f4893e1..b3e47ab 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,21 +1,14 @@ -use std::sync::Arc; - use super::controllers::*; -use crate::{ - middleware, - util::errors::{not_found, AppResult}, -}; +use crate::app::{App, AppState}; +use crate::middleware; +use crate::util::errors::{not_found, AppResult}; +use axum::response::IntoResponse; use axum::routing::post; -use axum::{ - response::IntoResponse, - routing::{delete, get}, - Json, Router, -}; -use crates_io_env_vars::required_var; +use axum::routing::{delete, get}; +use axum::{Json, Router}; use reqwest::{Method, StatusCode}; use serde_json::Value; - -use crate::app::{App, AppState}; +use std::sync::Arc; pub fn build_handler(app: Arc) -> axum::Router { let state = AppState(app); @@ -36,6 +29,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .route("/bots/:bot_id", get(bot::metadata::show)) .route("/bots/new", post(bot::manage::publish)) .route("/bots/:bot_id/owners", get(bot::owners::owners)) + .route( + "/bots/:bot_id/votes", + get(bot::votes::votes).post(bot::votes::vote), + ) // Categories .route("/categories", get(category::index)) .route("/categories/:category_id", get(category::show)) @@ -56,11 +53,12 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .with_state(state) } -async fn handler() -> AppResult> { - let version = required_var("CARGO_PKG_VERSION"); +// imports `VERSION` constant +include!(concat!(env!("OUT_DIR"), "/version.rs")); +async fn handler() -> AppResult> { Ok(Json(serde_json::json!({ - "name": "izumo (api)", - "version": version.unwrap_or("unknown".to_string()), + "name": "Izumo (api)", + "version": VERSION }))) } diff --git a/src/schema.rs b/src/schema.rs index d0bcdf6..e2417a9 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -132,6 +132,32 @@ diesel::table! { } } +diesel::table! { + /// Representation of the `bot_votes` table. + /// + /// (Automatically generated by Diesel.) + bot_votes (bot_id, date) { + /// The `bot_id` column of the `bot_votes` table. + /// + /// Its SQL type is `Varchar`. + /// + /// (Automatically generated by Diesel.) + bot_id -> Varchar, + /// The `date` column of the `bot_votes` table. + /// + /// Its SQL type is `Date`. + /// + /// (Automatically generated by Diesel.) + date -> Date, + /// The `votes` column of the `bot_votes` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + votes -> Int4, + } +} + diesel::table! { /// Representation of the `bots` table. /// @@ -387,6 +413,7 @@ diesel::joinable!(bot_owners -> bots (bot_id)); diesel::joinable!(bot_owners -> users (user_id)); diesel::joinable!(bot_reviews -> bots (bot_id)); diesel::joinable!(bot_reviews -> users (user_id)); +diesel::joinable!(bot_votes -> bots (bot_id)); diesel::joinable!(bots_categories -> bots (bot_id)); diesel::joinable!(bots_categories -> categories (category_id)); @@ -394,6 +421,7 @@ diesel::allow_tables_to_appear_in_same_query!( api_tokens, bot_owners, bot_reviews, + bot_votes, bots, bots_categories, categories, diff --git a/src/views.rs b/src/views.rs index 24f1bf0..c34e67f 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,7 +1,7 @@ use crate::models::bot::BotLanguages; use crate::models::token::{ApiToken, CreatedApiToken}; -use crate::models::Category; use crate::models::{Bot, User}; +use crate::models::{BotVote, Category}; use crate::util::rfc3339; use chrono::NaiveDateTime; use secrecy::ExposeSecret; @@ -283,3 +283,20 @@ pub struct PublishWarnings { pub invalid_categories: Vec, pub other: Vec, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct EncodableBotVote { + pub bot_id: String, + pub votes: i32, + pub date: String, +} + +impl From for EncodableBotVote { + fn from(vote: BotVote) -> Self { + EncodableBotVote { + bot_id: vote.bot_id, + date: vote.date.to_string(), + votes: vote.votes, + } + } +}