diff --git a/Cargo.lock b/Cargo.lock index 00b3b11..1fb4530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ dependencies = [ "mime-sniffer", "mime_guess", "num_cpus", + "postgres-protocol", "postgres-types", "rand 0.8.3", "refinery", diff --git a/Cargo.toml b/Cargo.toml index 97f9e8b..c65e17a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,6 @@ mime_guess = "2.0.3" mime-sniffer = "0.1.2" # Used to determine the number of CPUs on the system num_cpus = "1.13.0" -# Used to map postgres types to rust types -postgres-types = { version = "0.1.2", features = ["derive", "with-chrono-0_4", "with-uuid-0_8", "with-serde_json-1"] } # Used for working with regular expressions regex = "1.5.4" # Used to generate random numbers @@ -69,6 +67,8 @@ thiserror = "1.0.24" tokio = { version = "0.2.25", features = ["macros"] } # Used for working with Postgresql database (compatible with tokio 0.2) tokio-postgres = { version = "0.5.5", features = ["with-chrono-0_4", "with-uuid-0_8", "with-serde_json-1"] } +postgres-types = { version = "0.1.2", features = ["derive", "with-chrono-0_4", "with-uuid-0_8", "with-serde_json-1"] } +postgres-protocol = "0.5.3" # Used to work with URLs url = "2.2.2" # Used to work with UUIDs diff --git a/migrations/V0010__timeline_marker.sql b/migrations/V0010__timeline_marker.sql new file mode 100644 index 0000000..26479d3 --- /dev/null +++ b/migrations/V0010__timeline_marker.sql @@ -0,0 +1,8 @@ +CREATE TABLE timeline_marker ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, + timeline SMALLINT NOT NULL, + last_read_id VARCHAR(100) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + UNIQUE (user_id, timeline) +); diff --git a/migrations/schema.sql b/migrations/schema.sql index bd54402..7d45361 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -91,3 +91,12 @@ CREATE TABLE notification ( event_type SMALLINT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); + +CREATE TABLE timeline_marker ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, + timeline SMALLINT NOT NULL, + last_read_id VARCHAR(100) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + UNIQUE (user_id, timeline) +); diff --git a/src/main.rs b/src/main.rs index cc74f95..55444b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use mitra::logger::configure_logger; use mitra::mastodon_api::accounts::views::account_api_scope; use mitra::mastodon_api::directory::views::profile_directory; use mitra::mastodon_api::instance::views as instance_api; +use mitra::mastodon_api::markers::views::marker_api_scope; use mitra::mastodon_api::media::views::media_api_scope; use mitra::mastodon_api::notifications::views::notification_api_scope; use mitra::mastodon_api::oauth::auth::create_auth_error_handler; @@ -77,6 +78,7 @@ async fn main() -> std::io::Result<()> { .service(oauth_api_scope()) .service(profile_directory) .service(account_api_scope()) + .service(marker_api_scope()) .service(media_api_scope()) .service(notification_api_scope()) .service(status_api_scope()) diff --git a/src/mastodon_api/markers/mod.rs b/src/mastodon_api/markers/mod.rs new file mode 100644 index 0000000..718ba5f --- /dev/null +++ b/src/mastodon_api/markers/mod.rs @@ -0,0 +1,2 @@ +mod types; +pub mod views; diff --git a/src/mastodon_api/markers/types.rs b/src/mastodon_api/markers/types.rs new file mode 100644 index 0000000..5daff8c --- /dev/null +++ b/src/mastodon_api/markers/types.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::errors::ValidationError; +use crate::models::markers::types::{DbTimelineMarker, Timeline}; + +#[derive(Deserialize)] +pub struct MarkerQueryParams { + #[serde(rename = "timeline[]")] + pub timeline: String, +} + +impl MarkerQueryParams { + pub fn to_timeline(&self) -> Result { + let timeline = match self.timeline.as_ref() { + "home" => Timeline::Home, + "notifications" => Timeline::Notifications, + _ => return Err(ValidationError("invalid timeline name")), + }; + Ok(timeline) + } +} + +#[derive(Deserialize)] +pub struct MarkerCreateData { + #[serde(rename = "notifications[last_read_id]")] + pub notifications: String, +} + +/// https://docs.joinmastodon.org/entities/marker/ +#[derive(Serialize)] +pub struct Marker { + last_read_id: String, + version: i32, + updated_at: DateTime, +} + +#[derive(Serialize)] +pub struct Markers { + #[serde(skip_serializing_if = "Option::is_none")] + pub notifications: Option, +} + +impl From for Marker { + + fn from(value: DbTimelineMarker) -> Self { + Self { + last_read_id: value.last_read_id, + version: 0, + updated_at: value.updated_at, + } + } +} diff --git a/src/mastodon_api/markers/views.rs b/src/mastodon_api/markers/views.rs new file mode 100644 index 0000000..1dcd8ca --- /dev/null +++ b/src/mastodon_api/markers/views.rs @@ -0,0 +1,53 @@ +use actix_web::{get, post, web, HttpResponse, Scope}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +use crate::database::{Pool, get_database_client}; +use crate::errors::HttpError; +use crate::mastodon_api::oauth::auth::get_current_user; +use crate::models::markers::queries::{ + create_or_update_marker, + get_marker_opt, +}; +use crate::models::markers::types::Timeline; +use super::types::{MarkerQueryParams, MarkerCreateData, Markers}; + +/// https://docs.joinmastodon.org/methods/timelines/markers/ +#[get("")] +async fn get_marker_view( + auth: BearerAuth, + db_pool: web::Data, + query_params: web::Query, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let current_user = get_current_user(db_client, auth.token()).await?; + let timeline = query_params.to_timeline()?; + let maybe_db_marker = get_marker_opt(db_client, ¤t_user.id, timeline).await?; + let markers = Markers { + notifications: maybe_db_marker.map(|db_marker| db_marker.into()), + }; + Ok(HttpResponse::Ok().json(markers)) +} + +#[post("")] +async fn update_marker_view( + auth: BearerAuth, + db_pool: web::Data, + data: web::Json, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let current_user = get_current_user(db_client, auth.token()).await?; + let db_marker = create_or_update_marker( + db_client, + ¤t_user.id, + Timeline::Notifications, + data.into_inner().notifications, + ).await?; + let markers = Markers { notifications: Some(db_marker.into()) }; + Ok(HttpResponse::Ok().json(markers)) +} + +pub fn marker_api_scope() -> Scope { + web::scope("/api/v1/markers") + .service(get_marker_view) + .service(update_marker_view) +} diff --git a/src/mastodon_api/mod.rs b/src/mastodon_api/mod.rs index 8920f2c..b8efc32 100644 --- a/src/mastodon_api/mod.rs +++ b/src/mastodon_api/mod.rs @@ -1,6 +1,7 @@ pub mod accounts; pub mod directory; pub mod instance; +pub mod markers; pub mod media; pub mod notifications; pub mod oauth; diff --git a/src/mastodon_api/timelines/views.rs b/src/mastodon_api/timelines/views.rs index 7af5ee5..d488cc1 100644 --- a/src/mastodon_api/timelines/views.rs +++ b/src/mastodon_api/timelines/views.rs @@ -9,6 +9,7 @@ use crate::mastodon_api::statuses::types::Status; use crate::models::posts::helpers::get_actions_for_posts; use crate::models::posts::queries::get_posts; +/// https://docs.joinmastodon.org/methods/timelines/ #[get("/api/v1/timelines/home")] pub async fn home_timeline( auth: BearerAuth, diff --git a/src/models/markers/mod.rs b/src/models/markers/mod.rs new file mode 100644 index 0000000..0333ab5 --- /dev/null +++ b/src/models/markers/mod.rs @@ -0,0 +1,2 @@ +pub mod queries; +pub mod types; diff --git a/src/models/markers/queries.rs b/src/models/markers/queries.rs new file mode 100644 index 0000000..de841dd --- /dev/null +++ b/src/models/markers/queries.rs @@ -0,0 +1,45 @@ +use tokio_postgres::GenericClient; +use uuid::Uuid; + +use crate::errors::DatabaseError; +use super::types::{DbTimelineMarker, Timeline}; + +pub async fn create_or_update_marker( + db_client: &impl GenericClient, + user_id: &Uuid, + timeline: Timeline, + last_read_id: String, +) -> Result { + let row = db_client.query_one( + " + INSERT INTO timeline_marker (user_id, timeline, last_read_id) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, timeline) DO UPDATE + SET last_read_id = $3, updated_at = now() + RETURNING timeline_marker + ", + &[&user_id, &timeline, &last_read_id], + ).await?; + let marker = row.try_get("timeline_marker")?; + Ok(marker) +} + +pub async fn get_marker_opt( + db_client: &impl GenericClient, + user_id: &Uuid, + timeline: Timeline, +) -> Result, DatabaseError> { + let maybe_row = db_client.query_opt( + " + SELECT timeline_marker + FROM timeline_marker + WHERE user_id = $1 AND timeline = $2 + ", + &[&user_id, &timeline], + ).await?; + let maybe_marker = match maybe_row { + Some(row) => row.try_get("timeline_marker")?, + None => None, + }; + Ok(maybe_marker) +} diff --git a/src/models/markers/types.rs b/src/models/markers/types.rs new file mode 100644 index 0000000..84cfc52 --- /dev/null +++ b/src/models/markers/types.rs @@ -0,0 +1,74 @@ +use std::convert::TryFrom; + +use chrono::{DateTime, Utc}; +use postgres_protocol::types::{int2_from_sql, int2_to_sql}; +use postgres_types::{ + FromSql, ToSql, IsNull, Type, + accepts, to_sql_checked, + private::BytesMut, +}; +use uuid::Uuid; + +use crate::errors::ConversionError; + +#[derive(Debug)] +pub enum Timeline { + Home, + Notifications, +} + +impl From<&Timeline> for i16 { + fn from(value: &Timeline) -> i16 { + match value { + Timeline::Home => 1, + Timeline::Notifications => 2, + } + } +} + +impl TryFrom for Timeline { + type Error = ConversionError; + + fn try_from(value: i16) -> Result { + let timeline = match value { + 1 => Self::Home, + 2 => Self::Notifications, + _ => return Err(ConversionError), + }; + Ok(timeline) + } +} + +type SqlError = Box; + +impl<'a> FromSql<'a> for Timeline { + fn from_sql(_: &Type, raw: &'a [u8]) -> Result { + let int_value = int2_from_sql(raw)?; + let timeline = Timeline::try_from(int_value)?; + Ok(timeline) + } + + accepts!(INT2); +} + +impl ToSql for Timeline { + fn to_sql(&self, _: &Type, out: &mut BytesMut) -> Result { + let int_value: i16 = self.into(); + int2_to_sql(int_value, out); + Ok(IsNull::No) + } + + accepts!(INT2); + to_sql_checked!(); +} + +#[allow(dead_code)] +#[derive(FromSql)] +#[postgres(name = "timeline_marker")] +pub struct DbTimelineMarker { + id: i32, + user_id: Uuid, + pub timeline: Timeline, + pub last_read_id: String, + pub updated_at: DateTime, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 6fe6ac6..d0791cf 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod attachments; mod cleanup; +pub mod markers; pub mod notifications; pub mod oauth; pub mod posts;