httpd: Add routes for handling embeds in issues

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez 2023-08-29 13:58:30 +02:00 committed by cloudhead
parent a3f460e67d
commit 9df1922f15
No known key found for this signature in database
8 changed files with 148 additions and 31 deletions

7
Cargo.lock generated
View File

@ -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",

View File

@ -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(),

View File

@ -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" }

View File

@ -127,6 +127,12 @@ pub struct PaginationQuery {
pub per_page: Option<usize>,
}
#[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> {

View File

@ -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(),

View File

@ -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,

View File

@ -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),

View File

@ -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() {
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;