fix terminal mesh chat and guarded IndeeHub playback
This commit is contained in:
@@ -46,6 +46,43 @@ pub(super) fn build_response(
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
|
||||
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public playback must be explicitly free in both the mutable project row and
|
||||
/// any signed rental offer. A stale project price must never override signed
|
||||
/// paid terms, and malformed offers fail closed.
|
||||
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
|
||||
if !zero_sats(listing.get("rentalPrice")) {
|
||||
return false;
|
||||
}
|
||||
let Some(offer) = listing.get("offerEvent") else {
|
||||
return true;
|
||||
};
|
||||
if offer.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(terms) = tags.iter().find_map(|tag| {
|
||||
let tag = tag.as_array()?;
|
||||
(tag.first()?.as_str()? == "rental")
|
||||
.then(|| tag.get(1)?.as_str())
|
||||
.flatten()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::from_str::<serde_json::Value>(terms)
|
||||
.ok()
|
||||
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
@@ -282,6 +319,38 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||
));
|
||||
}
|
||||
let catalog = reqwest::Client::new()
|
||||
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
if !catalog.status().is_success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||
));
|
||||
}
|
||||
let listings: serde_json::Value = catalog.json().await?;
|
||||
let listing = listings.as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
|
||||
});
|
||||
let Some(listing) = listing else {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
|
||||
));
|
||||
};
|
||||
if !indeehub_listing_is_publicly_free(listing) {
|
||||
return Ok(build_response(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"rental required"}"#),
|
||||
));
|
||||
}
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
@@ -1346,3 +1415,33 @@ mod terminal_origin_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indeehub_public_stream_tests {
|
||||
use super::indeehub_listing_is_publicly_free;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": "0",
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 15,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "invalid"]]}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
|
||||
}
|
||||
let id = format!("s-{}", Uuid::new_v4().simple());
|
||||
let tmux_name = format!("archy-{id}");
|
||||
let output = Command::new("tmux")
|
||||
let output = match Command::new("tmux")
|
||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||
.output()
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
||||
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
||||
/// Format after the response code has been removed:
|
||||
/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) +
|
||||
/// txt_type(1B) + timestamp(4B) + text.
|
||||
/// Returns (channel_idx, text).
|
||||
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
||||
if data.len() < 7 {
|
||||
if data.len() < 10 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
// data[1] = path_len, data[2] = txt_type
|
||||
// data[3..7] = timestamp
|
||||
let text = if data.len() > 7 {
|
||||
String::from_utf8_lossy(&data[7..])
|
||||
let channel_idx = data[3];
|
||||
// data[0] = SNR, data[1..3] = reserved, data[4] = path_len,
|
||||
// data[5] = txt_type, data[6..10] = timestamp.
|
||||
let text = if data.len() > 10 {
|
||||
String::from_utf8_lossy(&data[10..])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
} else {
|
||||
@@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
|
||||
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
||||
/// Returns (channel_idx, raw_payload_bytes).
|
||||
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
||||
if data.len() < 7 {
|
||||
if data.len() < 10 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
let payload = if data.len() > 7 {
|
||||
let mut p = data[7..].to_vec();
|
||||
let channel_idx = data[3];
|
||||
let payload = if data.len() > 10 {
|
||||
let mut p = data[10..].to_vec();
|
||||
// Strip trailing NUL bytes
|
||||
while p.last() == Some(&0) {
|
||||
p.pop();
|
||||
@@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> {
|
||||
// decode_frame removes the 0x11 response code before this parser sees
|
||||
// the payload. A real V3 frame then begins with SNR + two reserved
|
||||
// bytes; treating SNR as the channel index filed public messages under
|
||||
// random channels such as 47 or 208.
|
||||
let data = [
|
||||
0xd0, 0x00, 0x00, // SNR and reserved
|
||||
0x00, 0xff, 0x00, // public channel, path, text type
|
||||
0x78, 0x56, 0x34, 0x12, // timestamp
|
||||
b'h', b'e', b'l', b'l', b'o', 0x00,
|
||||
];
|
||||
assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string()));
|
||||
assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_frame() {
|
||||
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
||||
|
||||
Reference in New Issue
Block a user