diff --git a/Cargo.lock b/Cargo.lock index 9f104a3..34de5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1463,7 +1463,7 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pict-rs" -version = "0.2.0-alpha.2" +version = "0.2.0-alpha.3" dependencies = [ "actix-form-data", "actix-fs", diff --git a/Cargo.toml b/Cargo.toml index 18f90f5..342edb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "pict-rs" description = "A simple image hosting service" -version = "0.2.0-alpha.2" +version = "0.2.0-alpha.3" authors = ["asonix "] license = "AGPL-3.0" readme = "README.md" diff --git a/README.md b/README.md index dd60923..c2f8645 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ _a simple image hosting service_ ## Usage ### Running ``` -pict-rs 0.2.0-alpha.2 +pict-rs 0.2.0-alpha.3 USAGE: pict-rs [FLAGS] [OPTIONS] --path @@ -17,6 +17,8 @@ FLAGS: OPTIONS: -a, --addr The address and port the server binds to. [env: PICTRS_ADDR=] [default: 0.0.0.0:8080] + --api-key An optional string to be checked on requests to privileged endpoints [env: + PICTRS_API_KEY=] -f, --format An optional image format to convert all uploaded files into, supports 'jpg', 'png', and 'webp' [env: PICTRS_FORMAT=] -m, --max-file-size Specify the maximum allowed uploaded file size (in Megabytes) [env: @@ -99,9 +101,6 @@ pict-rs offers four endpoints: "msg": "ok" } ``` -- `POST /import` for uploading an image while preserving the filename. This should not be exposed to - the public internet, as it can cause naming conflicts with saved files. The upload format and - response format are the same as the `POST /image` endpoint. - `GET /image/download?url=...` Download an image from a remote server, returning the same JSON payload as the `POST` endpoint - `GET /image/original/{file}` for getting a full-resolution image. `file` here is the `file` key from the @@ -119,8 +118,17 @@ pict-rs offers four endpoints: GET /image/process.jpg?src=asdf.png&thumbnail=256&blur=3.0 ``` which would create a 256x256px JPEG thumbnail and blur it -- `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to delete a file, - where `delete_token` and `file` are from the `/image` endpoint's JSON +- `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to + delete a file, where `delete_token` and `file` are from the `/image` endpoint's JSON +- `POST /internal/import` for uploading an image while preserving the filename. This should not be + exposed to the public internet, as it can cause naming conflicts with saved files. The upload + format and response format are the same as the `POST /image` endpoint. + + This endpoint also requires authentication via the `X-Api-Token` header, and is disabled unless + the `--api-key` option is passed to the binary or the PICTRS_API_KEY environment variable is + set. + + A secure API key can be generated by any password generator. ## Contributing Feel free to open issues for anything you find an issue with. Please note that any contributed code will be licensed under the AGPLv3. diff --git a/src/config.rs b/src/config.rs index e66a351..d5d450a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,13 @@ pub(crate) struct Config { default_value = "40" )] max_file_size: usize, + + #[structopt( + long, + env = "PICTRS_API_KEY", + help = "An optional string to be checked on requests to privileged endpoints" + )] + api_key: Option, } impl Config { @@ -78,6 +85,10 @@ impl Config { pub(crate) fn max_file_size(&self) -> usize { self.max_file_size } + + pub(crate) fn api_key(&self) -> Option<&str> { + self.api_key.as_ref().map(|s| s.as_str()) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/main.rs b/src/main.rs index 598de7b..ec609f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ mod validate; use self::{ config::Config, error::UploadError, - middleware::Tracing, + middleware::{Internal, Tracing}, processor::process_image, upload_manager::UploadManager, validate::{image_webp, video_mp4}, @@ -502,9 +502,13 @@ async fn main() -> Result<(), anyhow::Error> { .service(web::resource("/process.{ext}").route(web::get().to(process))), ) .service( - web::resource("/import") - .wrap(import_form.clone()) - .route(web::post().to(upload)), + web::scope("/internal") + .wrap(Internal(CONFIG.api_key().map(|s| s.to_owned()))) + .service( + web::resource("/import") + .wrap(import_form.clone()) + .route(web::post().to(upload)), + ), ) }) .bind(CONFIG.bind_address())? diff --git a/src/middleware.rs b/src/middleware.rs index 0271111..cd5af6f 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,5 +1,9 @@ -use actix_web::dev::{Service, Transform}; -use futures::future::{ok, Ready}; +use actix_web::{ + dev::{Service, ServiceRequest, Transform}, + http::StatusCode, + HttpResponse, ResponseError, +}; +use futures::future::{ok, LocalBoxFuture, Ready}; use std::task::{Context, Poll}; use tracing_futures::{Instrument, Instrumented}; use uuid::Uuid; @@ -10,6 +14,22 @@ pub(crate) struct TracingMiddleware { inner: S, } +pub(crate) struct Internal(pub(crate) Option); +pub(crate) struct InternalMiddleware(Option, S); +#[derive(Clone, Debug, thiserror::Error)] +#[error("Invalid API Key")] +struct ApiError; + +impl ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + StatusCode::UNAUTHORIZED + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(serde_json::json!({ "msg": self.to_string() })) + } +} + impl Transform for Tracing where S: Service, @@ -49,3 +69,47 @@ where .instrument(tracing::info_span!("request", ?uuid)) } } + +impl Transform for Internal +where + S: Service, + S::Future: 'static, +{ + type Request = S::Request; + type Response = S::Response; + type Error = S::Error; + type InitError = (); + type Transform = InternalMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(InternalMiddleware(self.0.clone(), service)) + } +} + +impl Service for InternalMiddleware +where + S: Service, + S::Future: 'static, +{ + type Request = S::Request; + type Response = S::Response; + type Error = S::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.1.poll_ready(cx) + } + + fn call(&mut self, req: S::Request) -> Self::Future { + if let Some(value) = req.headers().get("x-api-token") { + if value.to_str().is_ok() && value.to_str().ok() == self.0.as_ref().map(|s| s.as_str()) + { + let fut = self.1.call(req); + return Box::pin(async move { fut.await }); + } + } + + Box::pin(async move { Err(ApiError.into()) }) + } +}