Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
httpd: Add routes for handling embeds in issues
Sebastian Martinez committed 2 years ago
commit 9df1922f15ca5a27057ce0a8f7197efe3ff32ee3
parent a3f460e67d90f3406ec2dfbd1f21c3876a38c65a
8 files changed +148 -31
modified Cargo.lock
@@ -316,9 +316,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"

[[package]]
name = "base64"
-
version = "0.21.2"
+
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+
checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53"

[[package]]
name = "base64ct"
@@ -1928,6 +1928,7 @@ dependencies = [
 "axum",
 "axum-auth",
 "axum-server",
+
 "base64 0.21.3",
 "chrono",
 "fastrand",
 "flate2",
@@ -2981,7 +2982,7 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9"
dependencies = [
-
 "base64 0.21.2",
+
 "base64 0.21.3",
 "log",
 "once_cell",
 "serde",
modified radicle-cob/src/change/store.rs
@@ -206,7 +206,7 @@ impl Embed<Vec<u8>> {
            .into()
    }

-
    /// Return am embed where the content is replaced by a content hash.
+
    /// Return an embed where the content is replaced by a content hash.
    pub fn hashed<T: From<Oid>>(&self) -> Embed<T> {
        Embed {
            name: self.name.clone(),
modified radicle-httpd/Cargo.toml
@@ -19,6 +19,7 @@ anyhow = { version = "1" }
axum = { version = "0.6.7", default-features = false, features = ["headers", "json", "query", "tokio"] }
axum-auth = { version= "0.4.0", default-features = false, features = ["auth-bearer"] }
axum-server = { version = "0.5.1", default-features = false }
+
base64 = "0.21.3"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
fastrand = { version = "2.0.0" }
flate2 = { version = "1" }
modified radicle-httpd/src/api.rs
@@ -129,6 +129,12 @@ pub struct PaginationQuery {

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
+
pub struct RawQuery {
+
    pub mime: Option<String>,
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
pub struct CobsQuery<T> {
    pub page: Option<usize>,
    pub per_page: Option<usize>,
modified radicle-httpd/src/api/json.rs
@@ -12,7 +12,7 @@ use radicle::cob::patch::Review;
use radicle::cob::patch::{Patch, PatchId};
use radicle::cob::thread;
use radicle::cob::thread::CommentId;
-
use radicle::cob::{ActorId, Author, Reaction, Timestamp};
+
use radicle::cob::{ActorId, Author, Embed, Reaction, Timestamp, Uri};
use radicle::git::RefString;
use radicle::node::{Alias, AliasStore};
use radicle::prelude::NodeId;
@@ -241,6 +241,7 @@ struct Comment<'a> {
    id: CommentId,
    author: Value,
    body: &'a str,
+
    embeds: Vec<Embed<Uri>>,
    reactions: Vec<(&'a ActorId, &'a Reaction)>,
    #[serde(with = "radicle::serde_ext::localtime::time")]
    timestamp: Timestamp,
@@ -254,6 +255,7 @@ impl<'a> Comment<'a> {
            id: *id,
            author: author(&comment_author, aliases.alias(comment_author.id())),
            body: comment.body(),
+
            embeds: comment.embeds().to_vec(),
            reactions: comment.reactions().collect::<Vec<_>>(),
            timestamp: comment.timestamp(),
            reply_to: comment.reply_to(),
modified radicle-httpd/src/api/v1/projects.rs
@@ -7,13 +7,14 @@ use axum::response::IntoResponse;
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use axum_auth::AuthBearer;
+
use base64::prelude::{Engine, BASE64_STANDARD};
use hyper::StatusCode;
use radicle_surf::blob::{Blob, BlobRef};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_http::set_header::SetResponseHeaderLayer;

-
use radicle::cob::{issue, patch, Label};
+
use radicle::cob::{issue, patch, Embed, Label, Uri};
use radicle::identity::{Did, Id};
use radicle::node::routing::Store;
use radicle::node::AliasStore;
@@ -511,6 +512,7 @@ pub struct IssueCreate {
    pub description: String,
    pub labels: Vec<Label>,
    pub assignees: Vec<Did>,
+
    pub embeds: Vec<Embed<Uri>>,
}

/// Create a new issue.
@@ -528,6 +530,25 @@ async fn issue_create_handler(
        .signer()
        .map_err(|_| Error::Auth("Unauthorized"))?;
    let repo = storage.repository(project)?;
+
    let embeds: Vec<Embed> = issue
+
        .embeds
+
        .into_iter()
+
        .filter_map(|embed| {
+
            if let Some(content) = embed
+
                .content
+
                .as_str()
+
                .strip_prefix("data:content/type;base64,")
+
            {
+
                return BASE64_STANDARD.decode(content).ok().map(|content| Embed {
+
                    name: embed.name,
+
                    content,
+
                });
+
            }
+

+
            None
+
        })
+
        .collect();
+

    let mut issues = issue::Issues::open(&repo)?;
    let issue = issues
        .create(
@@ -535,7 +556,7 @@ async fn issue_create_handler(
            issue.description,
            &issue.labels,
            &issue.assignees,
-
            [],
+
            embeds,
            &signer,
        )
        .map_err(Error::from)?;
@@ -575,9 +596,30 @@ async fn issue_update_handler(
        issue::Action::Edit { title } => {
            issue.edit(title, &signer)?;
        }
-
        issue::Action::Comment { body, reply_to, .. } => {
+
        issue::Action::Comment {
+
            body,
+
            reply_to,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    if let Some(content) = embed
+
                        .content
+
                        .as_str()
+
                        .strip_prefix("data:content/type;base64,")
+
                    {
+
                        return BASE64_STANDARD.decode(content).ok().map(|content| Embed {
+
                            name: embed.name,
+
                            content,
+
                        });
+
                    }
+

+
                    None
+
                })
+
                .collect();
            if let Some(to) = reply_to {
-
                issue.comment(body, to, [], &signer)?;
+
                issue.comment(body, to, embeds, &signer)?;
            } else {
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
            }
@@ -1777,6 +1819,7 @@ mod routes {
                      "id": DID
                    },
                    "body": "Change 'hello world' to 'hello everyone'",
+
                    "embeds": [],
                    "reactions": [],
                    "timestamp": TIMESTAMP,
                    "replyTo": null
@@ -1790,7 +1833,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "c7cff5ab610408470406e023baa1ab087ce78adc";
+
        const CREATED_ISSUE_ID: &str = "e712eb0b5874d5256022fb620f26caf847d96723";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -1802,6 +1845,12 @@ mod routes {
            "title": "Issue #2",
            "description": "Change 'hello world' to 'hello everyone'",
            "labels": ["bug"],
+
            "embeds": [
+
              {
+
                "name": "example.html",
+
                "content": "data:content/type;base64,PGh0bWw+SGVsbG8gV29ybGQhPC9odG1sPg=="
+
              }
+
            ],
            "assignees": [],
        }))
        .unwrap();
@@ -1833,17 +1882,23 @@ mod routes {
              "author": {
                "id": CONTRIBUTOR_DID,
              },
-
              "assignees": [],
              "title": "Issue #2",
              "state": {
                "status": "open",
              },
+
              "assignees": [],
              "discussion": [{
                "id": CREATED_ISSUE_ID,
                "author": {
                  "id": CONTRIBUTOR_DID,
                },
                "body": "Change 'hello world' to 'hello everyone'",
+
                "embeds": [
+
                  {
+
                    "name": "example.html",
+
                    "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84"
+
                  }
+
                ],
                "reactions": [],
                "timestamp": TIMESTAMP,
                "replyTo": null,
@@ -1866,6 +1921,12 @@ mod routes {
        let body = serde_json::to_vec(&json!({
          "type": "comment",
          "body": "This is first-level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:content/type;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
          "replyTo": CONTRIBUTOR_ISSUE_ID,
        }))
        .unwrap();
@@ -1883,7 +1944,7 @@ mod routes {

        let body = serde_json::to_vec(&json!({
          "type": "comment.react",
-
          "id": "26cadcc7cb51ee9c56b6232023e9bf63b7b0df60",
+
          "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
          "reaction": "🚀",
          "active": true,
        }))
@@ -1921,16 +1982,23 @@ mod routes {
                    "id": CONTRIBUTOR_DID,
                  },
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "embeds": [],
                  "reactions": [],
                  "timestamp": TIMESTAMP,
                  "replyTo": null,
                },
                {
-
                  "id": "26cadcc7cb51ee9c56b6232023e9bf63b7b0df60",
+
                  "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
                  "body": "This is first-level comment",
+
                  "embeds": [
+
                    {
+
                      "name": "image.jpg",
+
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                    },
+
                  ],
                  "reactions": [
                    [
                      "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8",
@@ -1998,6 +2066,7 @@ mod routes {
                    "id": CONTRIBUTOR_DID,
                  },
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "embeds": [],
                  "reactions": [],
                  "timestamp": TIMESTAMP,
                  "replyTo": null,
@@ -2008,6 +2077,7 @@ mod routes {
                    "id": CONTRIBUTOR_DID,
                  },
                  "body": "This is a reply to the first comment",
+
                  "embeds": [],
                  "reactions": [],
                  "timestamp": TIMESTAMP,
                  "replyTo": ISSUE_DISCUSSION_ID,
@@ -2498,6 +2568,7 @@ mod routes {
                        "id": CONTRIBUTOR_DID,
                      },
                      "body": "EDIT: This is a root level comment",
+
                      "embeds": [],
                      "reactions": [["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8","🚀"]],
                      "timestamp": TIMESTAMP,
                      "replyTo": null,
@@ -2508,6 +2579,7 @@ mod routes {
                        "id": CONTRIBUTOR_DID,
                      },
                      "body": "This is a root level comment",
+
                      "embeds": [],
                      "reactions": [],
                      "timestamp": TIMESTAMP,
                      "replyTo": CONTRIBUTOR_COMMENT_1,
modified radicle-httpd/src/error.rs
@@ -65,6 +65,18 @@ pub enum RawError {
    #[error(transparent)]
    Surf(#[from] radicle_surf::Error),

+
    /// Git error.
+
    #[error(transparent)]
+
    Git(#[from] radicle::git::ext::Error),
+

+
    /// Radicle Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+

+
    /// Http Headers error.
+
    #[error(transparent)]
+
    Headers(#[from] http::header::InvalidHeaderValue),
+

    /// Surf file error.
    #[error(transparent)]
    SurfFile(#[from] radicle_surf::fs::error::File),
modified radicle-httpd/src/raw.rs
@@ -1,8 +1,8 @@
use std::sync::Arc;
use std::time::Duration;

-
use axum::extract::State;
-
use axum::http::{header, Method, StatusCode};
+
use axum::extract::{Query, State};
+
use axum::http::{header, HeaderValue, Method, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
@@ -11,9 +11,10 @@ use tower_http::cors;

use radicle::prelude::Id;
use radicle::profile::Profile;
-
use radicle::storage::git::paths;
+
use radicle::storage::{ReadRepository, ReadStorage};
use radicle_surf::{Oid, Repository};

+
use crate::api::RawQuery;
use crate::axum_extra::Path;
use crate::error::RawError as Error;

@@ -93,7 +94,8 @@ static MIMES: &[(&str, &str)] = &[

pub fn router(profile: Arc<Profile>) -> Router {
    Router::new()
-
        .route("/:project/:sha/*path", get(file_handler))
+
        .route("/:project/:sha/*path", get(file_by_path_handler))
+
        .route("/:project/blobs/:oid", get(file_by_oid_handler))
        .with_state(profile)
        .layer(
            cors::CorsLayer::new()
@@ -104,34 +106,55 @@ pub fn router(profile: Arc<Profile>) -> Router {
        )
}

-
async fn file_handler(
+
async fn file_by_path_handler(
    Path((project, sha, path)): Path<(Id, Oid, String)>,
    State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
    let storage = &profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let repo = storage.repository(project)?;
    let mut response_headers = HeaderMap::new();
+
    let repo: Repository = repo.backend.into();
+
    let blob = repo.blob(sha, &path)?;

-
    if repo.file(sha, &path)?.content(&repo)?.size() > MAX_BLOB_SIZE {
+
    if blob.size() > MAX_BLOB_SIZE {
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
    }

-
    let blob = repo.blob(sha, &path)?;
-
    let mime = {
-
        if let Some(ext) = path.split('.').last() {
-
            MIMES
-
                .binary_search_by(|(k, _)| k.cmp(&ext))
-
                .map(|k| MIMES[k].1)
-
                .unwrap_or("text; charset=utf-8")
-
        } else {
-
            "application/octet-stream"
-
        }
+
    let mime = if let Some(ext) = path.split('.').last() {
+
        MIMES
+
            .binary_search_by(|(k, _)| k.cmp(&ext))
+
            .map(|k| MIMES[k].1)
+
            .unwrap_or("text; charset=utf-8")
+
    } else {
+
        "application/octet-stream"
    };
-
    response_headers.insert(header::CONTENT_TYPE, mime.parse().unwrap());
+
    response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(mime)?);

    Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_owned()))
}

+
async fn file_by_oid_handler(
+
    Path((project, oid)): Path<(Id, Oid)>,
+
    State(profile): State<Arc<Profile>>,
+
    Query(qs): Query<RawQuery>,
+
) -> impl IntoResponse {
+
    let storage = &profile.storage;
+
    let repo = storage.repository(project)?;
+
    let blob = repo.blob(oid)?;
+
    let mut response_headers = HeaderMap::new();
+

+
    if blob.size() > MAX_BLOB_SIZE {
+
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
+
    }
+

+
    response_headers.insert(
+
        header::CONTENT_TYPE,
+
        HeaderValue::from_str(&qs.mime.unwrap_or("application/octet-stream".to_string()))?,
+
    );
+

+
    Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_vec()))
+
}
+

#[cfg(test)]
mod routes {
    use axum::http::StatusCode;