diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.rs | 94 | ||||
| -rw-r--r-- | src/media.rs | 137 | ||||
| -rw-r--r-- | src/micropub.rs | 67 | ||||
| -rw-r--r-- | src/oauth.rs | 85 | 
4 files changed, 172 insertions, 211 deletions
| diff --git a/src/main.rs b/src/main.rs index a3a7f76..60bced9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,12 @@ -use actix_web::client::Client; -use actix_web::{middleware, web, App, HttpServer}; - +use actix_middleware_rfc7662::RequireAuthorizationConfig; +use actix_web::web::Data; +use actix_web::{middleware, App, HttpServer};  use rusoto_core::Region;  use rusoto_s3::S3Client; -  use serde::{Deserialize, Serialize};  mod media;  mod micropub; -mod oauth;  #[derive(Serialize, Deserialize, Clone)]  #[serde(rename_all = "PascalCase")] @@ -16,9 +14,15 @@ pub struct SiteConfig {      bind: String,      media_url: String, -    token_endpoint: String,      s3_bucket: String, +    oauth2_auth_endpoint: String, +    oauth2_introspect_endpoint: String, +    oauth2_client_id: String, +    oauth2_client_secret: String, + +    allowed_username: String, +      default_width: u32,      default_height: u32,  } @@ -33,9 +37,25 @@ impl SiteConfig {          &self.media_url      } -    /// The URI to use to validate an access token. -    pub fn token_endpoint(&self) -> &str { -        &self.token_endpoint +    pub fn oauth2_auth_endpoint(&self) -> &str { +        &self.oauth2_auth_endpoint +    } + +    pub fn oauth2_introspect_endpoint(&self) -> &str { +        &self.oauth2_introspect_endpoint +    } + +    pub fn oauth2_client_id(&self) -> &str { +        &self.oauth2_client_id +    } + +    pub fn oauth2_client_secret(&self) -> &str { +        &self.oauth2_client_secret +    } + +    /// The username that is allowed to upload to this endpoint. +    pub fn allowed_username(&self) -> &str { +        &self.allowed_username      }      /// S3 output bucket @@ -52,34 +72,58 @@ impl SiteConfig {      }  } -#[actix_rt::main] +#[actix_web::main]  async fn main() -> std::io::Result<()> { -    std::env::set_var("RUST_LOG", "actix_web=info"); +    dotenv::dotenv().ok();      env_logger::init(); -    let site_config = SiteConfig { +    let site_config = Data::new(SiteConfig {          bind: std::env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8180".to_string()),          s3_bucket: std::env::var("S3_BUCKET").expect("Expected S3_BUCKET env var"),          media_url: std::env::var("MEDIA_URL").expect("Expected MEDIA_URL env var"), -        token_endpoint: std::env::var("TOKEN_ENDPOINT").expect("Expected TOKEN_ENDPOINT env var"), -        default_width: std::env::var("DEFAULT_WIDTH").ok().and_then(|v| v.parse().ok()).unwrap_or(1000), -        default_height: std::env::var("DEFAULT_HEIGHT").ok().and_then(|v| v.parse().ok()).unwrap_or(0), -    }; +        oauth2_auth_endpoint: std::env::var("OAUTH2_AUTH_ENDPOINT") +            .expect("Expected OAUTH2_AUTH_ENDPOINT env var"), +        oauth2_introspect_endpoint: std::env::var("OAUTH2_INTROSPECT_ENDPOINT") +            .expect("Expected OAUTH2_INTROSPECT_ENDPOINT env var"), +        oauth2_client_id: std::env::var("OAUTH2_CLIENT_ID") +            .expect("Expected OAUTH2_CLIENT_ID env var"), +        oauth2_client_secret: std::env::var("OAUTH2_CLIENT_SECRET") +            .expect("Expected OAUTH2_CLIENT_SECRET env var"), +        allowed_username: std::env::var("ALLOWED_USERNAME") +            .expect("Expected ALLOWED_USERNAME env var"), +        default_width: std::env::var("DEFAULT_WIDTH") +            .ok() +            .and_then(|v| v.parse().ok()) +            .unwrap_or(1000), +        default_height: std::env::var("DEFAULT_HEIGHT") +            .ok() +            .and_then(|v| v.parse().ok()) +            .unwrap_or(0), +    });      let bind = site_config.bind().to_string(); -    let s3_client = S3Client::new(Region::default()); -    let token_endpoint = site_config.token_endpoint().to_string(); +    let s3_client = Data::new(S3Client::new(Region::default())); + +    let oauth2_config = RequireAuthorizationConfig::new( +        site_config.oauth2_client_id().to_string(), +        Some(site_config.oauth2_client_secret().to_string()), +        site_config +            .oauth2_auth_endpoint() +            .parse() +            .expect("invalid url"), +        site_config +            .oauth2_introspect_endpoint() +            .parse() +            .expect("invalid url"), +    );      HttpServer::new(move || {          App::new()              .wrap(middleware::Logger::default()) -            .data(Client::new()) -            .data(site_config.clone()) -            .data(s3_client.clone()) -            .data(oauth::VerificationService::new(token_endpoint.clone())) -            .service( -                web::resource("/micropub/media").route(web::post().to(micropub::handle_upload)), -            ) +            .app_data(site_config.clone()) +            .app_data(s3_client.clone()) +            .app_data(oauth2_config.clone()) +            .service(micropub::handle_upload)              .configure(media::configure)      })      .bind(bind)? diff --git a/src/media.rs b/src/media.rs index 89d70e7..47eb53e 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,6 +1,6 @@ -use actix_web::error::{ErrorBadRequest, ErrorNotFound, ErrorInternalServerError}; +use actix_web::error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound};  use actix_web::http::header; -use actix_web::{web, Error, HttpRequest, HttpResponse}; +use actix_web::{get, head, web, Error, HttpRequest, HttpResponse};  use image::imageops::FilterType;  use image::GenericImageView; @@ -9,57 +9,63 @@ use image::ImageFormat;  use futures::TryFutureExt;  use tokio::io::AsyncReadExt; -use rusoto_s3::{HeadObjectRequest, GetObjectRequest, S3Client, S3}; +use rusoto_s3::{GetObjectRequest, HeadObjectRequest, S3Client, S3};  use crate::SiteConfig;  /// Build an HttpResponse for an AWS response  macro_rules! response_for { -    ($resp:expr) => { -        { -            let mut client_resp = HttpResponse::Ok(); - -            // This will be the default cache-control header if the object doesn't have its own. -            client_resp.set(header::CacheControl(vec![header::CacheDirective::MaxAge( -                31557600u32, -            )])); - -            // Allow CORS -            client_resp.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - -            // Copy all of the relevant S3 headers. -            $resp.cache_control.map(|v| client_resp.set_header(header::CACHE_CONTROL, v)); -            $resp.content_disposition.map(|v| client_resp.set_header(header::CONTENT_DISPOSITION, v)); -            $resp.content_encoding.map(|v| client_resp.set_header(header::CONTENT_ENCODING, v)); -            $resp.content_language.map(|v| client_resp.set_header(header::CONTENT_LANGUAGE, v)); -            $resp.content_type.map(|v| client_resp.set_header(header::CONTENT_TYPE, v)); -            $resp.e_tag.map(|v| client_resp.set_header(header::ETAG, v)); -            $resp.last_modified.map(|v| client_resp.set_header(header::LAST_MODIFIED, v)); - -            client_resp -        } -    }; +    ($resp:expr) => {{ +        let mut client_resp = HttpResponse::Ok(); + +        // This will be the default cache-control header if the object doesn't have its own. +        client_resp.insert_header(header::CacheControl(vec![header::CacheDirective::MaxAge( +            31557600u32, +        )])); + +        // Allow CORS +        client_resp.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")); + +        // Copy all of the relevant S3 headers. +        $resp +            .cache_control +            .map(|v| client_resp.insert_header((header::CACHE_CONTROL, v))); +        $resp +            .content_disposition +            .map(|v| client_resp.insert_header((header::CONTENT_DISPOSITION, v))); +        $resp +            .content_encoding +            .map(|v| client_resp.insert_header((header::CONTENT_ENCODING, v))); +        $resp +            .content_language +            .map(|v| client_resp.insert_header((header::CONTENT_LANGUAGE, v))); +        $resp +            .content_type +            .map(|v| client_resp.insert_header((header::CONTENT_TYPE, v))); +        $resp +            .e_tag +            .map(|v| client_resp.insert_header((header::ETAG, v))); +        $resp +            .last_modified +            .map(|v| client_resp.insert_header((header::LAST_MODIFIED, v))); + +        client_resp +    }};  }  pub fn configure(cfg: &mut web::ServiceConfig) { -    cfg.service( -        web::resource("/media/photo/{width:\\d+}x{height:\\d+}/{filename}") -            .route(web::get().to(serve_photo)), -    ); -    cfg.service( -        web::resource("/media/{type}/{filename:.+}") -            .route(web::get().to(serve_file)) -            .route(web::head().to(head_file)), -    ); +    cfg.service(serve_photo) +        .service(serve_file) +        .service(head_file);  } +#[head("/media/{type}/{filename:.+}")]  async fn head_file(      req: HttpRequest,      config: web::Data<SiteConfig>,      s3_client: web::Data<S3Client>,  ) -> Result<HttpResponse, Error> { - -    // Get the path paramaters +    // Get the path parameters      let media_type = req          .match_info()          .get("type") @@ -71,27 +77,27 @@ async fn head_file(      // Construct an S3 key      let key = format!("{}/{}", media_type, filename); -    let resp = s3_client.head_object(HeadObjectRequest { -        bucket: config.s3_bucket().to_owned(), -        key, -        ..Default::default() -    }) -    .map_err(|e| ErrorInternalServerError(e)) -    .await?; +    let resp = s3_client +        .head_object(HeadObjectRequest { +            bucket: config.s3_bucket().to_owned(), +            key, +            ..Default::default() +        }) +        .map_err(|e| ErrorInternalServerError(e)) +        .await?;      let mut client_resp = response_for!(resp);      // TODO: trick actix into returning the content-length.      Ok(client_resp.finish())  } - +#[get("/media/{type}/{filename:.+}")]  async fn serve_file(      req: HttpRequest,      config: web::Data<SiteConfig>,      s3_client: web::Data<S3Client>,  ) -> Result<HttpResponse, Error> { - -    // Get the path paramaters +    // Get the path parameters      let media_type = req          .match_info()          .get("type") @@ -103,13 +109,14 @@ async fn serve_file(      // Construct an S3 key      let key = format!("{}/{}", media_type, filename); -    let resp = s3_client.get_object(GetObjectRequest { -        bucket: config.s3_bucket().to_owned(), -        key, -        ..Default::default() -    }) -    .map_err(|e| ErrorInternalServerError(e)) -    .await?; +    let resp = s3_client +        .get_object(GetObjectRequest { +            bucket: config.s3_bucket().to_owned(), +            key, +            ..Default::default() +        }) +        .map_err(|e| ErrorInternalServerError(e)) +        .await?;      // If there is no payload, return a 404.      let data = resp.body.ok_or(ErrorNotFound("Not found"))?; @@ -118,6 +125,7 @@ async fn serve_file(      Ok(client_resp.streaming(data))  } +#[get("/media/photo/{width:\\d+}x{height:\\d+}/{filename}")]  async fn serve_photo(      req: HttpRequest,      config: web::Data<SiteConfig>, @@ -139,13 +147,14 @@ async fn serve_photo(          .ok_or(ErrorBadRequest("Bad URI"))?;      let key = format!("photo/{}", filename); -    let resp = s3_client.get_object(GetObjectRequest { -        bucket: config.s3_bucket().to_owned(), -        key, -        ..Default::default() -    }) -    .map_err(|e| ErrorInternalServerError(e)) -    .await?; +    let resp = s3_client +        .get_object(GetObjectRequest { +            bucket: config.s3_bucket().to_owned(), +            key, +            ..Default::default() +        }) +        .map_err(|e| ErrorInternalServerError(e)) +        .await?;      let mut data = Vec::new();      resp.body @@ -156,12 +165,12 @@ async fn serve_photo(      // Resize the image      let (mime, new_data) = web::block(move || scale_image(data.as_ref(), width, height)) -        .await +        .await?          .map_err(|e| ErrorInternalServerError(e))?;      // Send the new image to the client.      let mut client_resp = response_for!(resp); -    client_resp.set_header(header::CONTENT_TYPE, mime); +    client_resp.insert_header((header::CONTENT_TYPE, mime));      Ok(client_resp.body(new_data))  } diff --git a/src/micropub.rs b/src/micropub.rs index e420188..212929c 100644 --- a/src/micropub.rs +++ b/src/micropub.rs @@ -1,23 +1,18 @@ +use actix_middleware_rfc7662::{RequireAuthorization, RequireScope};  use actix_multipart::Multipart;  use actix_web::http::header; -use actix_web::{web, HttpRequest, HttpResponse}; - +use actix_web::{post, web, HttpRequest, HttpResponse};  use chrono::Utc; -  use futures::{StreamExt, TryStreamExt}; - +use oauth2::TokenIntrospectionResponse;  use rand::distributions::Alphanumeric;  use rand::{thread_rng, Rng}; -  use rusoto_s3::{PutObjectRequest, S3Client, S3}; -  use serde::{Deserialize, Serialize}; -  use std::collections::HashMap;  use std::fmt::Display;  use std::iter; -use crate::oauth;  use crate::SiteConfig;  // To make the timepart shorter, we'll offset it with a custom epoch. @@ -53,6 +48,14 @@ impl MicropubError {      }  } +/// The scope we require to allow uploads. +pub struct MediaScope; +impl RequireScope for MediaScope { +    fn scope() -> &'static str { +        "media" +    } +} +  fn random_id() -> String {      let now = Utc::now(); @@ -74,37 +77,20 @@ fn random_id() -> String {      format!("{}-{}", time_part, random_part)  } +#[post("/micropub/media")]  pub async fn handle_upload( -    req: HttpRequest, +    auth: RequireAuthorization<MediaScope>,      mut payload: Multipart,      site: web::Data<SiteConfig>,      s3_client: web::Data<S3Client>, -    verification_service: web::Data<oauth::VerificationService>,  ) -> HttpResponse { -    let auth_header = match req -        .headers() -        .get(header::AUTHORIZATION) -        .and_then(|s| s.to_str().ok()) -    { -        Some(auth_header) => auth_header, -        None => return HttpResponse::Unauthorized().json(MicropubError::new("unauthorized")), -    }; - -    let access_token = match verification_service.validate(auth_header).await { -        Ok(token) => token, -        Err(e) => { -            return HttpResponse::Unauthorized() -                .json(MicropubError::with_description("unauthorized", e)) -        } -    }; - -    if !access_token.scopes().any(|s| s == "media") { +    if auth.introspection().username() != Some(site.allowed_username()) {          return HttpResponse::Unauthorized().json(MicropubError::new("unauthorized"));      }      // iterate over multipart stream      if let Ok(Some(field)) = payload.try_next().await { -        let content_disp = field.content_disposition().unwrap(); +        let content_disp = field.content_disposition();          let content_type = field.content_type().clone();          let filename = content_disp.get_filename();          let ext = filename.and_then(|f| f.rsplit('.').next()); @@ -123,17 +109,24 @@ pub async fn handle_upload(          // This will be the publicly accessible URL for the file.          let url = if classification == "photo" { -            format!("{}/photo/{}x{}/{}", site.media_url(), site.default_width(), site.default_height(), key) +            format!( +                "{}/media/photo/{}x{}/{}", +                site.media_url(), +                site.default_width(), +                site.default_height(), +                key +            )          } else { -            format!("{}/{}/{}", site.media_url(), classification, key) +            format!("{}/media/{}/{}", site.media_url(), classification, key)          };          let mut metadata: HashMap<String, String> = HashMap::new(); -        metadata.insert( -            "client-id".to_string(), -            access_token.client_id().to_string(), -        ); -        metadata.insert("author".to_string(), access_token.me().to_string()); +        if let Some(client_id) = auth.introspection().client_id() { +            metadata.insert("client-id".to_string(), client_id.to_string()); +        } +        if let Some(username) = auth.introspection().username() { +            metadata.insert("author".to_string(), username.to_string()); +        }          if let Some(f) = filename {              metadata.insert("filename".to_string(), f.to_string());          } @@ -156,7 +149,7 @@ pub async fn handle_upload(          match s3_client.put_object(put_request).await {              Ok(_) => {                  return HttpResponse::Created() -                    .header(header::LOCATION, url) +                    .insert_header((header::LOCATION, url))                      .finish();              }              Err(e) => return HttpResponse::InternalServerError().body(format!("{}", e)), diff --git a/src/oauth.rs b/src/oauth.rs deleted file mode 100644 index 4d9bd1e..0000000 --- a/src/oauth.rs +++ /dev/null @@ -1,85 +0,0 @@ -use actix_web::client::Client; -use actix_web::error::Error; -use actix_web::http::{header, StatusCode}; -use actix_web::ResponseError; -use derive_more::Display; -use futures::{FutureExt, TryFutureExt}; -use serde::{Deserialize, Serialize}; - -/// Representation of an OAuth Access Token -#[derive(Serialize, Deserialize)] -pub struct AccessToken { -    me: String, -    client_id: String, -    scope: String, -} - -impl AccessToken { -    pub fn me(&self) -> &str { -        &self.me -    } - -    pub fn client_id(&self) -> &str { -        &self.client_id -    } - -    pub fn scopes(&self) -> impl Iterator<Item = &str> + '_ { -        self.scope.split_ascii_whitespace() -    } -} - -/// Verification Service takes an Authorization header and checks if it's valid. -pub struct VerificationService { -    token_endpoint: String, -    client: Client, -} - -impl VerificationService { -    pub fn new<S>(token_endpoint: S) -> VerificationService -    where -        S: Into<String>, -    { -        VerificationService { -            token_endpoint: token_endpoint.into(), -            client: Client::new(), -        } -    } - -    pub async fn validate(&self, auth_token: &str) -> Result<AccessToken, impl std::error::Error> { -        self.client -            .get(&self.token_endpoint) -            .header(header::AUTHORIZATION, auth_token) -            .send() -            .map_err(Error::from) -            .map(|res| { -                res.and_then(|r| { -                    if r.status().is_success() { -                        Ok(r) -                    } else if r.status() == StatusCode::UNAUTHORIZED { -                        Err(VerificationError::Unauthenticated.into()) -                    } else { -                        Err(VerificationError::InternalError( -                            r.status() -                                .canonical_reason() -                                .unwrap_or("Unknown Error") -                                .to_string(), -                        ) -                        .into()) -                    } -                }) -            }) -            .map_err(Error::from) -            .and_then(|mut resp| resp.json().map_err(Error::from)) -            .await -    } -} - -#[derive(Display, Debug)] -pub enum VerificationError { -    #[display(fmt = "Unauthenticated")] -    Unauthenticated, -    #[display(fmt = "AuthServer Error")] -    InternalError(String), -} - -impl ResponseError for VerificationError {} | 
