diff --git a/src/inscription.rs b/src/inscription.rs index a09121e25a..0ea48b9a5a 100644 --- a/src/inscription.rs +++ b/src/inscription.rs @@ -100,16 +100,20 @@ impl Inscription { } } - pub(crate) fn content_type(&self) -> Option<&str> { - str::from_utf8(self.content_type.as_ref()?).ok() + pub(crate) fn content_bytes(&self) -> Option<&[u8]> { + Some(self.content.as_ref()?) + } + + pub(crate) fn content_html(&self) -> Trusted { + Trusted(ContentHtml(self.content())) } pub(crate) fn content_size(&self) -> Option { - Some(self.content.as_ref()?.len()) + Some(self.content_bytes()?.len()) } - pub(crate) fn content_html(&self) -> Trusted { - Trusted(ContentHtml(self.content())) + pub(crate) fn content_type(&self) -> Option<&str> { + str::from_utf8(self.content_type.as_ref()?).ok() } } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 3f5418113e..ae8d14a3db 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -154,10 +154,11 @@ impl Server { .route("/block/:query", get(Self::block)) .route("/bounties", get(Self::bounties)) .route("/clock", get(Self::clock)) + .route("/content/:inscription_id", get(Self::content)) .route("/faq", get(Self::faq)) .route("/favicon.ico", get(Self::favicon)) .route("/input/:block/:transaction/:input", get(Self::input)) - .route("/inscription/:txid", get(Self::inscription)) + .route("/inscription/:inscription_id", get(Self::inscription)) .route("/install.sh", get(Self::install_script)) .route("/ordinal/:sat", get(Self::ordinal)) .route("/output/:output", get(Self::output)) @@ -635,6 +636,37 @@ impl Server { Redirect::to("https://docs.ordinals.com/bounty/") } + async fn content( + Extension(index): Extension>, + Path(inscription_id): Path, + ) -> ServerResult { + let (inscription, _) = index + .get_inscription_by_inscription_id(inscription_id) + .map_err(|err| { + ServerError::Internal(anyhow!( + "failed to retrieve inscription with inscription id {inscription_id} from index: {err}" + )) + })? + .ok_or_else(|| { + ServerError::NotFound(format!("transaction {inscription_id} has no inscription")) + })?; + + let (content_type, content) = Self::content_response(inscription).ok_or_else(|| { + ServerError::NotFound(format!("inscription {inscription_id} has no content")) + })?; + + Ok(([(header::CONTENT_TYPE, content_type)], content).into_response()) + } + + fn content_response(inscription: Inscription) -> Option<(String, Vec)> { + let content = inscription.content_bytes()?; + + match inscription.content_type() { + Some(content_type) => Some((content_type.into(), content.to_vec())), + None => Some(("application/octet-stream".into(), content.to_vec())), + } + } + async fn inscription( Extension(chain): Extension, Extension(index): Extension>, @@ -1631,4 +1663,34 @@ next.*", 5, ); } + + #[test] + fn content_response_no_content() { + assert_eq!( + Server::content_response(Inscription::new( + Some("text/plain".as_bytes().to_vec()), + None + )), + None + ); + } + + #[test] + fn content_response_with_content() { + assert_eq!( + Server::content_response(Inscription::new( + Some("text/plain".as_bytes().to_vec()), + Some(vec![1, 2, 3]), + )), + Some(("text/plain".into(), vec![1, 2, 3])) + ); + } + + #[test] + fn content_response_no_content_type() { + assert_eq!( + Server::content_response(Inscription::new(None, Some(vec![]))), + Some(("application/octet-stream".into(), vec![])) + ); + } } diff --git a/tests/server.rs b/tests/server.rs index a2263dd18c..c6961dbee1 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -159,3 +159,31 @@ HELLOWORLD.*", ), ) } + +#[test] +fn inscription_content() { + let rpc_server = test_bitcoincore_rpc::spawn_with(Network::Regtest, "ord"); + let txid = rpc_server.mine_blocks(1)[0].txdata[0].txid(); + + let stdout = CommandBuilder::new(format!( + "--chain regtest wallet inscribe --satpoint {txid}:0:0 --file hello.txt" + )) + .write("hello.txt", "HELLOWORLD") + .rpc_server(&rpc_server) + .stdout_regex("commit\t[[:xdigit:]]{64}\nreveal\t[[:xdigit:]]{64}\n") + .run(); + + let reveal_tx = reveal_txid_from_inscribe_stdout(&stdout); + + rpc_server.mine_blocks(1); + + let response = + TestServer::spawn_with_args(&rpc_server, &[]).request(&format!("/content/{reveal_tx}")); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/plain;charset=utf-8" + ); + assert_eq!(response.bytes().unwrap(), "HELLOWORLD"); +} diff --git a/tests/test_server.rs b/tests/test_server.rs index c56f7cafdc..8c450b7a59 100644 --- a/tests/test_server.rs +++ b/tests/test_server.rs @@ -1,6 +1,9 @@ -use super::*; -use crate::command_builder::ToArgs; -use bitcoincore_rpc::{Auth, Client, RpcApi}; +use { + super::*, + crate::command_builder::ToArgs, + bitcoincore_rpc::{Auth, Client, RpcApi}, + reqwest::blocking::Response, +}; pub(crate) struct TestServer { child: Child, @@ -76,6 +79,24 @@ impl TestServer { assert_eq!(response.status(), StatusCode::OK); assert_regex_match!(response.text().unwrap(), regex); } + + pub(crate) fn request(&self, path: &str) -> Response { + let client = Client::new(&self.rpc_url, Auth::None).unwrap(); + let chain_block_count = client.get_block_count().unwrap() + 1; + + for i in 0.. { + let response = reqwest::blocking::get(self.url().join("/block-count").unwrap()).unwrap(); + assert_eq!(response.status(), StatusCode::OK); + if response.text().unwrap().parse::().unwrap() == chain_block_count { + break; + } else if i == 20 { + panic!("index failed to synchronize with chain"); + } + thread::sleep(Duration::from_millis(25)); + } + + reqwest::blocking::get(self.url().join(path).unwrap()).unwrap() + } } impl Drop for TestServer {