diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a22d9e..a1c01cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Added `/api/v1/apps` endpoint. - Documented `http_cors_allowlist` configuration parameter. ### Changed diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 435916b..af3219b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -584,6 +584,47 @@ paths: $ref: '#/components/schemas/Relationship' 404: description: Profile not found + /api/v1/apps: + post: + summary: Create a new application to obtain OAuth2 credentials. + requestBody: + content: + application/json: + schema: + type: object + properties: + client_name: + description: A name for your application. + type: string + redirect_uris: + description: Where the user should be redirected after authorization. + type: string + scopes: + description: Space separated list of scopes. + type: string + example: 'read write' + website: + description: An URL to the homepage of your app. + type: string + nullable: true + responses: + 200: + description: Successful operation + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Application' + - type: object + properties: + client_id: + description: Client ID key, to be used for obtaining OAuth tokens. + type: string + client_secret: + description: Client secret key, to be used for obtaining OAuth tokens. + type: string + 400: + description: Invalid request data. /api/v1/custom_emojis: get: summary: Returns custom emojis that are available on the server. @@ -1360,6 +1401,19 @@ components: type: string enum: - update + Application: + type: object + properties: + name: + description: The name of your application. + type: string + website: + description: The website associated with your application. + type: string + nullable: true + redirect_uri: + description: Where the user should be redirected after authorization. + type: string Attachment: type: object properties: diff --git a/migrations/V0042__oauth_application.sql b/migrations/V0042__oauth_application.sql new file mode 100644 index 0000000..fe20fd5 --- /dev/null +++ b/migrations/V0042__oauth_application.sql @@ -0,0 +1,10 @@ +CREATE TABLE oauth_application ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app_name VARCHAR(100) NOT NULL, + website VARCHAR(100), + scopes VARCHAR(200) NOT NULL, + redirect_uri VARCHAR(200) NOT NULL, + client_id UUID UNIQUE NOT NULL, + client_secret VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/schema.sql b/migrations/schema.sql index 4c938a7..ffac55c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -51,6 +51,17 @@ CREATE TABLE user_account ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); +CREATE TABLE oauth_application ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + app_name VARCHAR(100) NOT NULL, + website VARCHAR(100), + scopes VARCHAR(200) NOT NULL, + redirect_uri VARCHAR(200) NOT NULL, + client_id UUID UNIQUE NOT NULL, + client_secret VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE oauth_token ( id SERIAL PRIMARY KEY, owner_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, diff --git a/src/main.rs b/src/main.rs index ae469c5..f352d9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use mitra::http::json_error_handler; use mitra::job_queue::scheduler; use mitra::logger::configure_logger; use mitra::mastodon_api::accounts::views::account_api_scope; +use mitra::mastodon_api::apps::views::application_api_scope; use mitra::mastodon_api::custom_emojis::views::custom_emoji_api_scope; use mitra::mastodon_api::directory::views::directory_api_scope; use mitra::mastodon_api::instance::views::instance_api_scope; @@ -145,6 +146,7 @@ async fn main() -> std::io::Result<()> { )) .service(oauth_api_scope()) .service(account_api_scope()) + .service(application_api_scope()) .service(custom_emoji_api_scope()) .service(directory_api_scope()) .service(instance_api_scope()) diff --git a/src/mastodon_api/apps/mod.rs b/src/mastodon_api/apps/mod.rs new file mode 100644 index 0000000..718ba5f --- /dev/null +++ b/src/mastodon_api/apps/mod.rs @@ -0,0 +1,2 @@ +mod types; +pub mod views; diff --git a/src/mastodon_api/apps/types.rs b/src/mastodon_api/apps/types.rs new file mode 100644 index 0000000..d192fd9 --- /dev/null +++ b/src/mastodon_api/apps/types.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct CreateAppRequest { + pub client_name: String, + pub redirect_uris: String, + pub scopes: String, + pub website: Option, +} + +/// https://docs.joinmastodon.org/entities/Application/ +#[derive(Serialize)] +pub struct OauthApp { + pub name: String, + pub website: Option, + pub redirect_uri: String, + pub client_id: Option, + pub client_secret: Option, +} diff --git a/src/mastodon_api/apps/views.rs b/src/mastodon_api/apps/views.rs new file mode 100644 index 0000000..1292144 --- /dev/null +++ b/src/mastodon_api/apps/views.rs @@ -0,0 +1,55 @@ +use actix_web::{ + post, + web, + Either, + HttpResponse, + Scope, +}; +use uuid::Uuid; + +use crate::database::{get_database_client, DbPool}; +use crate::errors::HttpError; +use crate::mastodon_api::oauth::utils::generate_access_token; +use crate::models::{ + oauth::queries::create_oauth_app, + oauth::types::DbOauthAppData, +}; +use super::types::{OauthApp, CreateAppRequest}; + +/// https://docs.joinmastodon.org/methods/apps/ +#[post("")] +async fn create_app_view( + db_pool: web::Data, + request_data: Either< + web::Json, + web::Form, + >, +) -> Result { + let request_data = match request_data { + Either::Left(json) => json.into_inner(), + Either::Right(form) => form.into_inner(), + }; + let db_client = &**get_database_client(&db_pool).await?; + let db_app_data = DbOauthAppData { + app_name: request_data.client_name, + website: request_data.website, + scopes: request_data.scopes, + redirect_uri: request_data.redirect_uris, + client_id: Uuid::new_v4(), + client_secret: generate_access_token(), + }; + let db_app = create_oauth_app(db_client, db_app_data).await?; + let app = OauthApp { + name: db_app.app_name, + website: db_app.website, + redirect_uri: db_app.redirect_uri, + client_id: Some(db_app.client_id), + client_secret: Some(db_app.client_secret), + }; + Ok(HttpResponse::Ok().json(app)) +} + +pub fn application_api_scope() -> Scope { + web::scope("/api/v1/apps") + .service(create_app_view) +} diff --git a/src/mastodon_api/mod.rs b/src/mastodon_api/mod.rs index 37e7b23..b1dcdfa 100644 --- a/src/mastodon_api/mod.rs +++ b/src/mastodon_api/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod apps; pub mod custom_emojis; pub mod directory; pub mod instance; diff --git a/src/mastodon_api/oauth/mod.rs b/src/mastodon_api/oauth/mod.rs index a334a5f..7a34eb7 100644 --- a/src/mastodon_api/oauth/mod.rs +++ b/src/mastodon_api/oauth/mod.rs @@ -1,4 +1,4 @@ pub mod auth; mod types; pub mod views; -mod utils; +pub mod utils; diff --git a/src/models/oauth/mod.rs b/src/models/oauth/mod.rs index 84c032e..6de6876 100644 --- a/src/models/oauth/mod.rs +++ b/src/models/oauth/mod.rs @@ -1 +1,2 @@ +pub mod types; pub mod queries; diff --git a/src/models/oauth/queries.rs b/src/models/oauth/queries.rs index c591770..1d623bf 100644 --- a/src/models/oauth/queries.rs +++ b/src/models/oauth/queries.rs @@ -1,9 +1,44 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -use crate::database::{DatabaseClient, DatabaseError}; +use crate::database::{ + catch_unique_violation, + DatabaseClient, + DatabaseError, +}; use crate::models::profiles::types::DbActorProfile; use crate::models::users::types::{DbUser, User}; +use super::types::{DbOauthApp, DbOauthAppData}; + +pub async fn create_oauth_app( + db_client: &impl DatabaseClient, + app_data: DbOauthAppData, +) -> Result { + let row = db_client.query_one( + " + INSERT INTO oauth_application ( + app_name, + website, + scopes, + redirect_uri, + client_id, + client_secret + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING oauth_application + ", + &[ + &app_data.app_name, + &app_data.website, + &app_data.scopes, + &app_data.redirect_uri, + &app_data.client_id, + &app_data.client_secret, + ], + ).await.map_err(catch_unique_violation("oauth_application"))?; + let app = row.try_get("oauth_application")?; + Ok(app) +} pub async fn save_oauth_token( db_client: &impl DatabaseClient, @@ -94,6 +129,18 @@ mod tests { use crate::models::users::types::UserCreateData; use super::*; + #[tokio::test] + #[serial] + async fn test_create_oauth_app() { + let db_client = &create_test_database().await; + let db_app_data = DbOauthAppData { + app_name: "My App".to_string(), + ..Default::default() + }; + let app = create_oauth_app(db_client, db_app_data).await.unwrap(); + assert_eq!(app.app_name, "My App"); + } + #[tokio::test] #[serial] async fn test_delete_oauth_token() { diff --git a/src/models/oauth/types.rs b/src/models/oauth/types.rs new file mode 100644 index 0000000..5dafc99 --- /dev/null +++ b/src/models/oauth/types.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use postgres_types::FromSql; +use uuid::Uuid; + +#[derive(FromSql)] +#[postgres(name = "oauth_application")] +pub struct DbOauthApp { + pub id: i32, + pub app_name: String, + pub website: Option, + pub scopes: String, + pub redirect_uri: String, + pub client_id: Uuid, + pub client_secret: String, + pub created_at: DateTime, +} + +#[cfg_attr(test, derive(Default))] +pub struct DbOauthAppData { + pub app_name: String, + pub website: Option, + pub scopes: String, + pub redirect_uri: String, + pub client_id: Uuid, + pub client_secret: String, +}